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.foreachfor large files — doesn’t load the entire file into memory; iterating row by row is the best choice for large files.headers: truefor structured data — turns every row into aCSV::Rowaccessible by column name, far safer than by-index access.converters: :numericfor automatic type conversion — without it, all values are Strings; use:numericfor Integer and Float, or:allfor Date as well.- Encoding handling matters — files from Windows often use
Windows-1252; use theencoding: "Windows-1252:UTF-8"option for conversion while reading.CSV.generatewith a block for writing — usecsv << arrayinside 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_honCSV::Row— convert a row to a plain Hash for compatibility with code that doesn’t expectCSV::Row.