CSV #

CSV (Comma-Separated Values) is a tabular data format that has existed since the 1970s and remains the most universal data exchange format today — spreadsheet exports, reports from legacy systems, database data, and output from various analytical tools almost all use CSV. Ruby provides a comprehensive CSV library as part of its standard library. Unlike simply splitting a string by commas, this library handles all the edge cases that make CSV hard to process manually: values containing commas, values containing newlines, quoting, encoding, and automatic type conversion. This article covers the whole Ruby CSV API — from simple parsing to efficient large-file processing.

Why Not Just split(",") #

Before diving into the API, it’s important to understand why string.split(",") isn’t enough for real CSV.

require "csv"

# A seemingly simple CSV containing edge cases
simple_csv = 'Alice,28,"Jakarta, Selatan",Developer'

# ANTI-PATTERN: comma split — wrong!
simple_csv.split(",")
# => ["Alice", "28", "\"Jakarta", " Selatan\"", "Developer"]
# "Jakarta, Selatan" splits into two! Quoting isn't handled!

# CORRECT: use CSV.parse — handles quoting properly
CSV.parse(simple_csv).first
# => ["Alice", "28", "Jakarta, Selatan", "Developer"]

# Another edge case: values containing newlines
multiline_csv = "Alice,\"Jalan Merdeka\nNo. 1\",Developer"
CSV.parse(multiline_csv).first
# => ["Alice", "Jalan Merdeka\nNo. 1", "Developer"]

# Edge case: values containing double quotes (escaped with doubled quotes)
quoted_csv = 'Alice,"Dia berkata ""Halo!""",Developer'
CSV.parse(quoted_csv).first
# => ["Alice", 'Dia berkata "Halo!"', "Developer"]

Parsing CSV #

Parsing from a String #

require "csv"

# Parse a multi-line string
csv_string = <<~CSV
  nama,umur,kota,aktif
  Alice,28,Jakarta,true
  Bob,35,Bandung,false
  Charlie,22,Surabaya,true
CSV

# parse without options — returns an Array of Arrays
rows = CSV.parse(csv_string)
# => [["nama", "umur", "kota", "aktif"],
#     ["Alice", "28", "Jakarta", "true"],
#     ["Bob", "35", "Bandung", "false"],
#     ["Charlie", "22", "Surabaya", "true"]]

# parse with headers: true — the first row becomes the header
data = CSV.parse(csv_string, headers: true)
# => #<CSV::Table>

data.each do |row|
  puts "#{row["nama"]} from #{row["kota"]}"
end
# Alice from Jakarta
# Bob from Bandung
# Charlie from Surabaya

# Column access
data["nama"]   # => ["Alice", "Bob", "Charlie"]
data[0]        # => #<CSV::Row "nama":"Alice" "umur":"28" ...>
data[0]["nama"]     # => "Alice"
data[0][:nama]      # => nil!  the key is a String, not a Symbol

Parsing from a File #

require "csv"

# Read everything into memory — for small files
data = CSV.read("users.csv", headers: true)
data.each { |row| puts row["nama"] }

# Iterate rows — for large files, more memory efficient
CSV.foreach("users.csv", headers: true) do |row|
  # Only one row in memory at a time
  process(row["nama"], row["email"])
end

# CSV.open — full control over the file handle
CSV.open("users.csv", "r", headers: true) do |csv|
  csv.each do |row|
    next if row["aktif"] == "false"
    puts row.to_h
  end
end

Parsing Options #

The CSV library provides many options for customizing parsing behavior to different file formats.

Separators and Encoding #

require "csv"

# col_sep — column separator (default: ",")
CSV.parse("Alice;28;Jakarta", col_sep: ";")
# => [["Alice", "28", "Jakarta"]]

# row_sep — row separator (default: "\n" or "\r\n")
CSV.parse("Alice,28\r\nBob,35", row_sep: "\r\n")

# quote_char — quoting character (default: '"')
CSV.parse("Alice|28|'Jakarta, Selatan'", col_sep: "|", quote_char: "'")
# => [["Alice", "28", "Jakarta, Selatan"]]

# Encoding — important for files from Windows or legacy systems
CSV.read("windows_file.csv",
  headers: true,
  encoding: "Windows-1252:UTF-8"   # read Windows-1252, convert to UTF-8
)

CSV.foreach("data.csv",
  headers: true,
  encoding: "UTF-8"
) do |row|
  # ...
end

Automatic Type Conversion #

By default, all CSV values are Strings. The converters option enables automatic type conversion.

require "csv"

csv_data = "Alice,28,150000.5,true,2024-01-15"

# Without converters — everything is a String
CSV.parse(csv_data).first
# => ["Alice", "28", "150000.5", "true", "2024-01-15"]

# With built-in converters
CSV.parse(csv_data, converters: :numeric).first
# => ["Alice", 28, 150000.5, "true", "2024-01-15"]
# Integer and Float are converted automatically

CSV.parse(csv_data, converters: :all).first
# => ["Alice", 28, 150000.5, "true", 2024-01-15 00:00:00 +0000]
# Additionally: Date and DateTime are attempted

# Available converters
# :integer  — convert to Integer when possible
# :float    — convert to Float when possible
# :numeric  — :integer + :float
# :date     — convert to Date
# :date_time — convert to DateTime
# :all      — all converters above
# Custom converters — a function receiving a field and returning a value
bool_converter = ->(field) { field == "true" ? true : (field == "false" ? false : field) }

CSV::Converters[:boolean] = bool_converter

csv_string = "Alice,28,true\nBob,35,false"
CSV.parse(csv_string, converters: [:numeric, :boolean])
# => [["Alice", 28, true], ["Bob", 35, false]]

Header Converters #

require "csv"

csv_with_headers = "Nama Lengkap,Umur,Kota Asal\nAlice,28,Jakarta"

# header_converters — header name transformation
data = CSV.parse(csv_with_headers,
  headers: true,
  header_converters: :symbol
)
# Headers are converted to symbols: :nama_lengkap, :umur, :kota_asal?
# Actually :symbol only downcases and converts spaces to _

data[0][:nama_lengkap]   # => nil?  need to check the actual conversion

# Custom header converter
data = CSV.parse(csv_with_headers,
  headers: true,
  header_converters: ->(h) { h.downcase.gsub(" ", "_").to_sym }
)
data[0][:nama_lengkap]   # => "Alice"
data[0][:umur]           # => "28"

Writing CSV #

Generating to a String #

require "csv"

# CSV.generate — create a CSV string
csv_string = CSV.generate do |csv|
  csv << ["nama", "umur", "kota"]    # header
  csv << ["Alice", 28, "Jakarta"]
  csv << ["Bob", 35, "Bandung"]
  csv << ["Charlie", 22, "Surabaya"]
end

puts csv_string
# nama,umur,kota
# Alice,28,Jakarta
# Bob,35,Bandung
# Charlie,22,Surabaya

# Automatic handling of values needing quoting
csv_string = CSV.generate do |csv|
  csv << ["nama", "alamat", "catatan"]
  csv << ["Alice", "Jalan Merdeka, No. 1", "pelanggan \"VIP\""]
end
# nama,alamat,catatan
# Alice,"Jalan Merdeka, No. 1","pelanggan ""VIP"""

Writing to a File #

require "csv"

users = [
  { nama: "Alice", umur: 28, kota: "Jakarta" },
  { nama: "Bob", umur: 35, kota: "Bandung" }
]

# CSV.open with mode "w"
CSV.open("users.csv", "w") do |csv|
  csv << ["nama", "umur", "kota"]   # header
  users.each do |u|
    csv << [u[:nama], u[:umur], u[:kota]]
  end
end

# Appending to an existing file
CSV.open("users.csv", "a") do |csv|
  csv << ["Charlie", 22, "Surabaya"]
end

# Writing with a separator option
CSV.open("users.tsv", "w", col_sep: "\t") do |csv|
  csv << ["nama", "umur"]
  csv << ["Alice", 28]
end

Efficient Iteration for Large Files #

For large CSV files (hundreds of MB to several GB), it’s important not to load all data into memory at once.

require "csv"

# ANTI-PATTERN: reading everything into memory
all_data = CSV.read("large_data.csv", headers: true)
all_data.each { |row| process(row) }
# For a 1GB file, this can use tens of GB of RAM!

# CORRECT: iterate row by row
CSV.foreach("large_data.csv", headers: true) do |row|
  process(row)
  # Only one row in memory at a time
end

# Processing in batches — useful for bulk database inserts
def process_in_batches(file_path, batch_size: 1000)
  batch = []

  CSV.foreach(file_path, headers: true) do |row|
    batch << row.to_h

    if batch.size >= batch_size
      save_to_database(batch)
      batch.clear
    end
  end

  # Don't forget the remaining batch
  save_to_database(batch) unless batch.empty?
end

process_in_batches("transactions.csv", batch_size: 500)
# Streaming with an Enumerator for more control
def csv_enumerator(path, **options)
  Enumerator.new do |y|
    CSV.foreach(path, **options) { |row| y << row }
  end
end

# Now lazy enumeration can be used
csv_enumerator("large.csv", headers: true)
  .lazy
  .select { |row| row["aktif"] == "true" }
  .map { |row| { id: row["id"].to_i, nama: row["nama"] } }
  .first(100)   # only processes until 100 active rows are found

CSV::Table and CSV::Row #

When parsing with headers: true, the result isn’t a plain Array but a CSV::Table containing CSV::Row objects.

require "csv"

csv_string = "nama,umur,kota\nAlice,28,Jakarta\nBob,35,Bandung"
table = CSV.parse(csv_string, headers: true)

# CSV::Table — like a multidimensional Hash
table.class     # => CSV::Table
table.headers   # => ["nama", "umur", "kota"]
table.size      # => 2  (number of data rows, excluding the header)

# Column access (by_col mode)
table["nama"]   # => ["Alice", "Bob"]
table["umur"]   # => ["28", "35"]

# Row access (by_row mode)
table[0]        # => #<CSV::Row "nama":"Alice" "umur":"28" "kota":"Jakarta">
table[1]["kota"]  # => "Bandung"

# CSV::Row — like a Hash but ordered
row = table[0]
row.class         # => CSV::Row
row["nama"]       # => "Alice"
row.to_h          # => {"nama"=>"Alice", "umur"=>"28", "kota"=>"Jakarta"}
row.fields        # => ["Alice", "28", "Jakarta"]  (values only)
row.headers       # => ["nama", "umur", "kota"]

# Modification
row["umur"] = "29"
row.to_h          # => {"nama"=>"Alice", "umur"=>"29", "kota"=>"Jakarta"}

# Iterating a CSV::Table
table.each do |row|
  puts "#{row["nama"]}: #{row["kota"]}"
end

# Converting to an Array of Hashes
array_of_hashes = table.map(&:to_h)
# => [{"nama"=>"Alice", "umur"=>"28", "kota"=>"Jakarta"},
#     {"nama"=>"Bob", "umur"=>"35", "kota"=>"Bandung"}]

Integration with Custom Classes #

A common pattern in real applications is converting CSV rows directly into domain objects.

require "csv"

class Product
  attr_reader :id, :name, :price, :stock, :category

  def initialize(attrs)
    @id       = attrs["id"].to_i
    @name     = attrs["nama"]
    @price    = attrs["harga"].to_f
    @stock    = attrs["stok"].to_i
    @category = attrs["kategori"]
  end

  def available?
    @stock > 0
  end

  def discount(percent)
    @price * (1 - percent / 100.0)
  end

  def to_csv_row
    [@id, @name, @price, @stock, @category]
  end

  def self.from_csv(path)
    CSV.foreach(path, headers: true).map { |row| new(row) }
  end

  def self.export_csv(product_list, path)
    CSV.open(path, "w") do |csv|
      csv << ["id", "nama", "harga", "stok", "kategori"]
      product_list.each { |p| csv << p.to_csv_row }
    end
  end
end

# Usage
product_list = Product.from_csv("products.csv")
available = product_list.select(&:available?)
expensive = product_list.select { |p| p.price > 1_000_000 }

# Export a subset
Product.export_csv(available, "available_products.csv")

Data Transformation Patterns #

CSV is often used for data transformation — reading from one format, processing, writing to another format.

require "csv"
require "json"

# CSV to JSON
def csv_to_json(csv_path, json_path = nil)
  data = CSV.foreach(csv_path, headers: true, converters: :numeric)
    .map(&:to_h)

  if json_path
    File.write(json_path, JSON.pretty_generate(data))
  else
    JSON.generate(data)
  end
end

# JSON to CSV
def json_to_csv(json_path, csv_path)
  data = JSON.parse(File.read(json_path))
  return unless data.is_a?(Array) && !data.empty?

  headers = data.first.keys

  CSV.open(csv_path, "w") do |csv|
    csv << headers
    data.each { |item| csv << headers.map { |h| item[h] } }
  end
end

# Transformation: filter and reshape
def transform_report(input_path, output_path)
  CSV.open(output_path, "w") do |output|
    output << ["nama", "total_transaksi", "rata_rata"]

    data = CSV.foreach(input_path, headers: true, converters: :numeric)
      .group_by { |row| row["nama"] }

    data.each do |nama, rows|
      amount_list = rows.map { |r| r["jumlah"] }.compact
      total = amount_list.sum
      average = amount_list.empty? ? 0 : total / amount_list.length.to_f
      output << [nama, total, average.round(2)]
    end
  end
end

Handling Imperfect CSV #

Real-world data is often imperfect — missing columns, odd encodings, or inconsistent rows.

require "csv"

# Handling problematic rows
def tolerant_csv_reader(path)
  results = []
  error_rows = []

  CSV.foreach(path, headers: true) do |row|
    results << row.to_h
  rescue CSV::MalformedCSVError => e
    error_rows << { row: $., error: e.message }
  end

  { data: results, errors: error_rows }
end

# Encoding detection and conversion
def read_csv_with_encoding(path)
  # Try UTF-8 first
  CSV.read(path, headers: true, encoding: "UTF-8")
rescue Encoding::InvalidByteSequenceError
  # Fall back to Windows-1252 (Latin-1)
  CSV.read(path, headers: true, encoding: "Windows-1252:UTF-8")
end

# strip_whitespace — clean whitespace from all values
csv_data = "  nama  , umur , kota  \n  Alice  ,  28  ,  Jakarta  "
CSV.parse(csv_data,
  headers: true,
  header_converters: ->(h) { h.strip },
  converters: ->(v) { v&.strip }
)

Summary #

  • Don’t split(",") for real CSV — values containing commas, quotes, or newlines will parse incorrectly; always use the CSV library for reliability.
  • CSV.foreach for large files — doesn’t load the entire file into memory; iterating row by row is the best choice for large files.
  • headers: true for structured data — turns every row into a CSV::Row accessible by column name, far safer than by-index access.
  • converters: :numeric for automatic type conversion — without it, all values are Strings; use :numeric for Integer and Float, or :all for Date as well.
  • Encoding handling matters — files from Windows often use Windows-1252; use the encoding: "Windows-1252:UTF-8" option for conversion while reading.
  • CSV.generate with a block for writing — use csv << array inside the block; the library handles quoting automatically for values containing commas or newlines.
  • Process in batches for performance — when processing data into a database, accumulate in an array up to a certain size before flushing; this is far faster than inserting one at a time.
  • to_h on CSV::Row — convert a row to a plain Hash for compatibility with code that doesn’t expect CSV::Row.

← Previous: JSON   Next: URI →

About | Author | Content Scope | Editorial Policy | Privacy Policy | Disclaimer | Contact