CSV
The CSV module provides utilities for reading and writing CSV (Comma-Separated Values) data in Ruby. It handles various CSV formats including custom delimiters, quoting rules, and encoding options.
CSV is a good fit when your data comes from spreadsheets, exports, or other tabular sources. Ruby’s CSV library gives you a readable way to move between plain text, arrays, hashes, and row objects without building your own parser.
Key takeaways
- CSV is part of Ruby’s standard library, so you can start parsing and generating data without extra gems.
- Use
CSV.parsefor strings,CSV.readfor files, andCSV.foreachfor large files. - Headers, converters, and custom delimiters cover most everyday CSV formats.
- Stream rows when the file is large, instead of loading everything into memory at once.
- Keep the parser options near the call site so the format stays obvious.
Overview
CSV is a common format for spreadsheets, data exports, and data interchange. Ruby’s CSV module makes it easy to:
- Parse: Read CSV data into arrays, hashes, or table objects
- Generate: Create CSV output from Ruby data structures
- Transform: Filter, map, and process CSV data
- Stream: Process large files row by row
Loading CSV
require "csv"
Reading CSV data
CSV.parse
CSV.parse converts a CSV string into an array of arrays.
CSV.parse takes a string and returns an array of arrays — one inner array per row, with each field as a string element. This is the simplest entry point: you have a CSV string in memory (from a web request, a test fixture, or a file you already read) and you want structured access to the rows and columns.
require "csv"
csv_string = "name,age,city\nAlice,30,NYC\nBob,25,LA"
rows = CSV.parse(csv_string)
rows[0] # => ["name", "age", "city"]
rows[1] # => ["Alice", "30", "NYC"]
With headers:
Setting headers: true tells CSV.parse to treat the first row as column names rather than data. Each subsequent row becomes a CSV::Row object that you can index by header name. This is the most common configuration for CSV files that include a header row.
rows = CSV.parse(csv_string, headers: true)
rows.first["name"] # => "Alice"
rows.first["age"] # => "30"
CSV.read
CSV.read reads directly from a file.
CSV.read loads an entire CSV file into an array of arrays in one call. It is convenient for small to medium files, but it allocates memory for every row at once. For production code that handles files of unknown size, prefer CSV.foreach for streaming.
require "csv"
# Read entire file into array
data = CSV.read("data.csv")
# Read with headers
data = CSV.read("data.csv", headers: true)
CSV.foreach
Process large files row by row without loading everything into memory:
CSV.foreach is the streaming alternative to CSV.read. It opens the file, yields each row to the block, and closes the file automatically. For files that are too large to fit in memory, foreach is the correct choice — it processes one row at a time and never builds the full array.
require "csv"
CSV.foreach("large_file.csv") do |row|
# Process each row
puts row.join(", ")
end
With headers and conversion:
Adding headers: true and converters: :numeric to CSV.foreach turns each row into a CSV::Row object with named access and automatically converts numeric-looking fields to Ruby numbers. This eliminates manual to_i or to_f calls inside the block.
CSV.foreach("data.csv", headers: true, converters: :numeric) do |row|
puts "#{row["name"]} is #{row["age"]} years old"
end
Generating CSV
CSV.generate
Create CSV strings programmatically:
After reading CSV data, the next logical step is generating it. CSV.generate is the inverse of CSV.parse: it takes Ruby data structures and produces a CSV-formatted string. The block receives a writer object, and each call to csv << appends one row of output.
require "csv"
csv_string = CSV.generate do |csv|
csv << ["name", "age", "city"]
csv << ["Alice", 30, "NYC"]
csv << ["Bob", 25, "LA"]
end
puts csv_string
# name,age,city
# Alice,30,NYC
# Bob,25,LA
CSV.generate_line
Quickly generate a single CSV row:
CSV.generate takes a block and yields a CSV writer object. Each csv << call appends one row. The block form collects all rows into a single string, which is returned when the block finishes. This is the preferred way to build CSV output programmatically when you have multiple rows to write.
require "csv"
CSV.generate_line(["Alice", 30, "NYC"])
# => "Alice,30,NYC\n"
# Custom separator
CSV.generate_line(["a,b", "c"], quote_char: "\"")
# => "\"a,b\",c\n"
CSV.table
Read CSV into a table with column names as symbols:
CSV.generate_line produces a single CSV row as a string, including the trailing newline. When the data itself contains the delimiter character (a comma inside "a,b"), the method wraps it in quotes automatically to prevent ambiguity during parsing.
require "csv"
table = CSV.table("data.csv")
table[0][:name] # => "Alice"
table[0][:age] # => 30
Common options
| Option | Default | Description |
|---|---|---|
col_sep | "," | Column separator |
row_sep | "\r\n" or "\n" | Row separator |
quote_char | "\" | Quote character |
headers | false | Parse first row as headers |
skip_blanks | false | Skip empty rows |
encoding | "UTF-8" | Character encoding |
Custom delimiters
# Tab-separated values
CSV.parse(tsv_string, col_sep: "\t")
# Semicolon-separated
CSV.parse(str, col_sep: ";")
# Read pipe-delimited file
CSV.read("data.txt", col_sep: "|")
Handling quotes
CSV’s col_sep option handles any delimiter, not just commas. Setting it to "\t" for tab-separated values or "|" for pipe-delimited files makes the parser work with non-standard CSV formats that are common in legacy systems, log files, and data exports from older mainframe applications.
# Force quoting of all fields
CSV.generate_line(["a", "b", "c"], force_quotes: true)
# => "\"a\",\"b\",\"c\"\n"
# Use single quotes
CSV.generate_line(["it's", "fine"], quote_char: "'")
# => "'it''s','fine'\n"
Working with headers
Header options
When quote_char is set to a single quote, fields containing single quotes are escaped by doubling them (it''s). This is the same escaping convention used in SQL string literals. The force_quotes: true option wraps every field in the quote character regardless of content.
# Array of symbols as headers
CSV.parse(csv_string, headers: [:name, :age, :city])
# True uses first row as headers
CSV.parse(csv_string, headers: true)
# First row is removed from data
# Header converters
CSV.parse(csv_string, headers: true, header_converters: :symbol)
# Headers become symbols, spaces -> underscores
Accessing header data
The header_converters: :symbol option transforms header strings into symbols and replaces spaces with underscores. So a column named "First Name" becomes accessible as row[:first_name]. This is the idiomatic Ruby approach for CSV headers that will be used as hash keys.
data = CSV.parse(csv_string, headers: true)
# Get headers
data.headers # => ["name", "age", "city"]
# Find column index
data.headers.index("age") # => 1
Data conversion
Built-in converters
Once you have parsed CSV data with headers, data.headers returns the column names as an array and data.headers.index("age") gives you the numeric column position. This is useful when you need to validate that expected columns exist before processing the data.
# Convert numeric fields
CSV.parse(csv_string, converters: :numeric)
# [["Alice", 30, "NYC"], ["Bob", 25, "LA"]]
# Convert dates
CSV.parse(csv_string, converters: :date)
# Multiple converters
CSV.parse(csv_string, converters: [:numeric, :date])
Custom converters
When you pass an array of converter symbols like [:numeric, :date], Ruby applies them in order from left to right. This means a later converter sees the output of the earlier one. The :numeric converter turns "30" into 30, and :date would try to parse fields that look like dates.
CSV::Converters[:uppercase] = ->(field) { field.upcase }
CSV.parse("hello,world", converters: :uppercase)
# => [["HELLO", "WORLD"]]
Practical examples
The examples below move from reading and filtering to writing and reshaping data. They show that the same module can handle small inline strings and larger files with the same API.
Processing survey data
Custom converters let you define transformations that run on every field during parsing. The example defines an :uppercase converter that calls upcase on each field. Built-in converters like :numeric and :date handle type coercion, but custom converters give you arbitrary control over field-level preprocessing.
require "csv"
results = []
CSV.foreach("survey.csv", headers: true, converters: :numeric) do |row|
if row["satisfaction"] >= 4
results << {
name: row["name"],
score: row["satisfaction"]
}
end
end
puts "High satisfaction: #{results.length}"
Export database to CSV
When you need to export database records to CSV, CSV.open with a block gives you a writer object. Calling csv << with an array appends a row. The header row is written first, then each database record follows — this is the standard pattern for generating CSV reports from ActiveRecord or any ORM.
require "csv"
CSV.open("export.csv", "wb") do |csv|
csv << ["id", "name", "email", "created_at"]
User.find_each do |user|
csv << [user.id, user.name, user.email, user.created_at]
end
end
Transform CSV format
The CSV.open block form automatically closes the file handle when the block finishes, even if an exception occurs midway through writing. The "wb" mode writes in binary, which avoids extra newline translation on Windows and ensures consistent line endings across platforms.
require "csv"
# Convert tab-separated to comma-separated
input = "name\tage\nAlice\t30"
output = CSV.generate do |csv|
CSV.parse(input, col_sep: "\t").each do |row|
csv << row
end
end
Handle large files
Converting between delimiters is a two-step pipeline: parse with the source delimiter, then generate with the target delimiter. The intermediate array produced by CSV.parse is small for the inline string shown here, but for production use on large files, combine CSV.foreach with CSV.open to stream both read and write.
require "csv"
# Count rows without loading entire file
count = 0
CSV.foreach("large.csv", headers: true) { count += 1 }
puts "Total rows: #{count}"
# Filter and write to new file
CSV.foreach("input.csv", headers: true) do |row|
if row["status"] == "active"
CSV.open("filtered.csv", "a") do |output|
output << row
end
end
end
csv::row and csv::field
Working with rows
Streaming a large CSV through foreach, filtering rows by a column value, and appending matching rows to an output file handles arbitrarily large datasets without loading them into memory. The "a" append mode on CSV.open lets you build the filtered file incrementally as each row passes the condition check.
require "csv"
row = CSV::Row.new(["name", "age"], ["Alice", 30])
row[0] # => "Alice"
row["name"] # => "Alice" (with headers)
row.fields # => ["Alice", 30]
row.to_a # => ["Alice", 30]
row.to_h # => {"name" => "Alice", "age" => 30}
Field manipulation
CSV::Row combines array-style positional access and hash-style keyed access into a single object. Indexing by integer position (row[0]) and by header name (row["name"]) both work, which makes row objects flexible when the column order is not known ahead of time.
row = CSV::Row.new(["name", "age"], ["Alice", 30], headers: ["name", "age"])
# Check if field exists
row["name"] # => "Alice"
row["missing"] # => nil
# Delete field
row.delete("age")
row.to_h # => {"name" => "Alice"}
Ruby version history
- Ruby 1.9: Added built-in CSV library (based on FasterCSV)
- Ruby 2.0: Improved encoding support
- Ruby 2.6: Added
skip_blanksoption - Ruby 3.0: Fixed various parsing edge cases
Common mistakes
The first common mistake is forgetting that CSV.parse returns strings by default. If you want numbers or dates, add converters so the values arrive in a useful form.
The second mistake is reading a large file with CSV.read when you really need streaming. CSV.foreach is the safer choice when memory matters or the file is too big to load all at once.
The third mistake is hiding the delimiter choice. If your data uses tabs, pipes, or semicolons, say so next to the call. That makes the code easier to maintain and helps the next reader understand why the parser is configured the way it is.
Conclusion
Ruby’s CSV module keeps tabular data work simple. It can parse, generate, and stream rows with very little setup, and it handles the common format details for you. Start with the smallest API that fits the task, then add headers or converters only when the data actually needs them.