CSV #
CSV (Comma-Separated Values) adalah format data tabular yang sudah ada sejak dekade 1970-an dan masih menjadi format pertukaran data paling universal hingga hari ini — ekspor dari spreadsheet, laporan dari sistem legacy, data dari database, dan output dari berbagai tool analitik hampir semua menggunakan CSV. Ruby menyediakan library CSV yang komprehensif sebagai bagian dari standard library. Berbeda dari sekadar split string dengan koma, library ini menangani semua edge case yang membuat CSV susah diproses secara manual: nilai yang mengandung koma, nilai yang mengandung newline, quoting, encoding, dan konversi tipe data otomatis. Artikel ini membahas seluruh API CSV Ruby — dari parsing sederhana hingga pemrosesan file besar secara efisien.
Mengapa Tidak Sekadar split(",") #
Sebelum masuk ke API, penting memahami mengapa string.split(",") tidak cukup untuk CSV nyata.
require "csv"
# CSV yang terlihat sederhana tapi mengandung edge case
csv_sederhana = 'Alice,28,"Jakarta, Selatan",Developer'
# ANTI-PATTERN: split koma — salah!
csv_sederhana.split(",")
# => ["Alice", "28", "\"Jakarta", " Selatan\"", "Developer"]
# "Jakarta, Selatan" terpecah jadi dua! Kuotasi tidak ditangani!
# BENAR: gunakan CSV.parse — menangani quoting dengan benar
CSV.parse(csv_sederhana).first
# => ["Alice", "28", "Jakarta, Selatan", "Developer"]
# Edge case lain: nilai mengandung newline
csv_multiline = "Alice,\"Jalan Merdeka\nNo. 1\",Developer"
CSV.parse(csv_multiline).first
# => ["Alice", "Jalan Merdeka\nNo. 1", "Developer"]
# Edge case: nilai mengandung quote ganda (escaped dengan double-quote)
csv_quoted = 'Alice,"Dia berkata ""Halo!""",Developer'
CSV.parse(csv_quoted).first
# => ["Alice", 'Dia berkata "Halo!"', "Developer"]
Parsing CSV #
Parse dari String #
require "csv"
# Parse string multi-baris
csv_string = <<~CSV
nama,umur,kota,aktif
Alice,28,Jakarta,true
Bob,35,Bandung,false
Charlie,22,Surabaya,true
CSV
# parse tanpa opsi — mengembalikan Array of Array
baris = CSV.parse(csv_string)
# => [["nama", "umur", "kota", "aktif"],
# ["Alice", "28", "Jakarta", "true"],
# ["Bob", "35", "Bandung", "false"],
# ["Charlie", "22", "Surabaya", "true"]]
# parse dengan headers: true — baris pertama jadi header
data = CSV.parse(csv_string, headers: true)
# => #<CSV::Table>
data.each do |baris|
puts "#{baris["nama"]} dari #{baris["kota"]}"
end
# Alice dari Jakarta
# Bob dari Bandung
# Charlie dari Surabaya
# Akses kolom
data["nama"] # => ["Alice", "Bob", "Charlie"]
data[0] # => #<CSV::Row "nama":"Alice" "umur":"28" ...>
data[0]["nama"] # => "Alice"
data[0][:nama] # => nil! key adalah String, bukan Symbol
Parse dari File #
require "csv"
# Baca semua ke memori — untuk file kecil
data = CSV.read("pengguna.csv", headers: true)
data.each { |baris| puts baris["nama"] }
# Iterasi baris — untuk file besar, lebih hemat memori
CSV.foreach("pengguna.csv", headers: true) do |baris|
# Hanya satu baris di memori pada satu waktu
proses(baris["nama"], baris["email"])
end
# CSV.open — kendali penuh atas file handle
CSV.open("pengguna.csv", "r", headers: true) do |csv|
csv.each do |baris|
next if baris["aktif"] == "false"
puts baris.to_h
end
end
Opsi Parsing #
Library CSV menyediakan banyak opsi untuk menyesuaikan perilaku parsing dengan format file yang berbeda-beda.
Separator dan Encoding #
require "csv"
# col_sep — separator kolom (default: ",")
CSV.parse("Alice;28;Jakarta", col_sep: ";")
# => [["Alice", "28", "Jakarta"]]
# row_sep — separator baris (default: "\n" atau "\r\n")
CSV.parse("Alice,28\r\nBob,35", row_sep: "\r\n")
# quote_char — karakter quoting (default: '"')
CSV.parse("Alice|28|'Jakarta, Selatan'", col_sep: "|", quote_char: "'")
# => [["Alice", "28", "Jakarta, Selatan"]]
# Encoding — penting untuk file dari Windows atau sistem lama
CSV.read("windows_file.csv",
headers: true,
encoding: "Windows-1252:UTF-8" # baca Windows-1252, konversi ke UTF-8
)
CSV.foreach("data.csv",
headers: true,
encoding: "UTF-8"
) do |baris|
# ...
end
Konversi Tipe Data Otomatis #
Secara default, semua nilai CSV adalah String. Opsi converters memungkinkan konversi tipe otomatis.
require "csv"
csv_data = "Alice,28,150000.5,true,2024-01-15"
# Tanpa converter — semua String
CSV.parse(csv_data).first
# => ["Alice", "28", "150000.5", "true", "2024-01-15"]
# Dengan converter bawaan
CSV.parse(csv_data, converters: :numeric).first
# => ["Alice", 28, 150000.5, "true", "2024-01-15"]
# Integer dan Float dikonversi otomatis
CSV.parse(csv_data, converters: :all).first
# => ["Alice", 28, 150000.5, "true", 2024-01-15 00:00:00 +0000]
# Tambahan: Date dan DateTime coba dikonversi
# Converter tersedia
# :integer — konversi ke Integer jika memungkinkan
# :float — konversi ke Float jika memungkinkan
# :numeric — :integer + :float
# :date — konversi ke Date
# :date_time — konversi ke DateTime
# :all — semua converter di atas
# Custom converter — fungsi yang menerima field dan mengembalikan nilai
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 — transformasi nama header
data = CSV.parse(csv_with_headers,
headers: true,
header_converters: :symbol
)
# Header dikonversi ke symbol: :nama_lengkap, :umur, :kota_asal?
# Sebenarnya :symbol hanya downcase dan konversi spasi jadi _
data[0][:nama_lengkap] # => nil? perlu cek konversi aktual
# 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"
Menulis CSV #
Generate ke String #
require "csv"
# CSV.generate — buat 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
# Penanganan otomatis untuk nilai yang perlu di-quote
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"""
Menulis ke File #
require "csv"
pengguna = [
{ nama: "Alice", umur: 28, kota: "Jakarta" },
{ nama: "Bob", umur: 35, kota: "Bandung" }
]
# CSV.open dengan mode "w"
CSV.open("pengguna.csv", "w") do |csv|
csv << ["nama", "umur", "kota"] # header
pengguna.each do |p|
csv << [p[:nama], p[:umur], p[:kota]]
end
end
# Append ke file yang sudah ada
CSV.open("pengguna.csv", "a") do |csv|
csv << ["Charlie", 22, "Surabaya"]
end
# Menulis dengan opsi separator
CSV.open("pengguna.tsv", "w", col_sep: "\t") do |csv|
csv << ["nama", "umur"]
csv << ["Alice", 28]
end
Iterasi Efisien untuk File Besar #
Untuk file CSV yang besar (ratusan MB hingga beberapa GB), penting untuk tidak memuat semua data ke memori sekaligus.
require "csv"
# ANTI-PATTERN: baca semua ke memori
semua_data = CSV.read("data_besar.csv", headers: true)
semua_data.each { |baris| proses(baris) }
# Untuk file 1GB, ini bisa menghabiskan RAM puluhan GB!
# BENAR: iterasi baris per baris
CSV.foreach("data_besar.csv", headers: true) do |baris|
proses(baris)
# Hanya satu baris di memori pada satu waktu
end
# Memproses dalam batch — berguna untuk bulk database insert
def proses_dalam_batch(file_path, ukuran_batch: 1000)
batch = []
CSV.foreach(file_path, headers: true) do |baris|
batch << baris.to_h
if batch.size >= ukuran_batch
simpan_ke_database(batch)
batch.clear
end
end
# Jangan lupa sisa batch terakhir
simpan_ke_database(batch) unless batch.empty?
end
proses_dalam_batch("transaksi.csv", ukuran_batch: 500)
# Streaming dengan Enumerator untuk kontrol lebih
def csv_enumerator(path, **opsi)
Enumerator.new do |y|
CSV.foreach(path, **opsi) { |baris| y << baris }
end
end
# Sekarang bisa menggunakan lazy enumeration
csv_enumerator("besar.csv", headers: true)
.lazy
.select { |baris| baris["aktif"] == "true" }
.map { |baris| { id: baris["id"].to_i, nama: baris["nama"] } }
.first(100) # hanya proses sampai 100 baris aktif ditemukan
CSV::Table dan CSV::Row #
Ketika parsing dengan headers: true, hasilnya bukan Array biasa tapi CSV::Table berisi CSV::Row.
require "csv"
csv_string = "nama,umur,kota\nAlice,28,Jakarta\nBob,35,Bandung"
table = CSV.parse(csv_string, headers: true)
# CSV::Table — seperti Hash multidimensi
table.class # => CSV::Table
table.headers # => ["nama", "umur", "kota"]
table.size # => 2 (jumlah baris data, tidak termasuk header)
# Akses kolom (mode by_col)
table["nama"] # => ["Alice", "Bob"]
table["umur"] # => ["28", "35"]
# Akses baris (mode by_row)
table[0] # => #<CSV::Row "nama":"Alice" "umur":"28" "kota":"Jakarta">
table[1]["kota"] # => "Bandung"
# CSV::Row — seperti Hash tapi terurut
baris = table[0]
baris.class # => CSV::Row
baris["nama"] # => "Alice"
baris.to_h # => {"nama"=>"Alice", "umur"=>"28", "kota"=>"Jakarta"}
baris.fields # => ["Alice", "28", "Jakarta"] (hanya values)
baris.headers # => ["nama", "umur", "kota"]
# Modifikasi
baris["umur"] = "29"
baris.to_h # => {"nama"=>"Alice", "umur"=>"29", "kota"=>"Jakarta"}
# Iterasi CSV::Table
table.each do |baris|
puts "#{baris["nama"]}: #{baris["kota"]}"
end
# Konversi ke Array of Hash
array_of_hash = table.map(&:to_h)
# => [{"nama"=>"Alice", "umur"=>"28", "kota"=>"Jakarta"},
# {"nama"=>"Bob", "umur"=>"35", "kota"=>"Bandung"}]
Integrasi dengan Kelas Custom #
Pola umum di aplikasi nyata adalah mengkonversi baris CSV langsung ke objek domain.
require "csv"
class Produk
attr_reader :id, :nama, :harga, :stok, :kategori
def initialize(attrs)
@id = attrs["id"].to_i
@nama = attrs["nama"]
@harga = attrs["harga"].to_f
@stok = attrs["stok"].to_i
@kategori = attrs["kategori"]
end
def tersedia?
@stok > 0
end
def diskon(persen)
@harga * (1 - persen / 100.0)
end
def to_csv_row
[@id, @nama, @harga, @stok, @kategori]
end
def self.from_csv(path)
CSV.foreach(path, headers: true).map { |baris| new(baris) }
end
def self.ekspor_csv(produk_list, path)
CSV.open(path, "w") do |csv|
csv << ["id", "nama", "harga", "stok", "kategori"]
produk_list.each { |p| csv << p.to_csv_row }
end
end
end
# Penggunaan
produk_list = Produk.from_csv("produk.csv")
tersedia = produk_list.select(&:tersedia?)
mahal = produk_list.select { |p| p.harga > 1_000_000 }
# Ekspor subset
Produk.ekspor_csv(tersedia, "produk_tersedia.csv")
Pola Transformasi Data #
CSV sering digunakan untuk transformasi data — baca dari satu format, proses, tulis ke format lain.
require "csv"
require "json"
# CSV ke JSON
def csv_ke_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 ke CSV
def json_ke_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
# Transformasi: filter dan reshape
def transform_laporan(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 { |baris| baris["nama"] }
data.each do |nama, baris_list|
jumlah_list = baris_list.map { |b| b["jumlah"] }.compact
total = jumlah_list.sum
rata = jumlah_list.empty? ? 0 : total / jumlah_list.length.to_f
output << [nama, total, rata.round(2)]
end
end
end
Menangani CSV yang Tidak Sempurna #
Data dunia nyata sering tidak sempurna — ada kolom hilang, encoding aneh, atau baris yang tidak konsisten.
require "csv"
# Penanganan baris yang bermasalah
def baca_csv_toleran(path)
hasil = []
error_rows = []
CSV.foreach(path, headers: true) do |baris|
hasil << baris.to_h
rescue CSV::MalformedCSVError => e
error_rows << { baris: $., error: e.message }
end
{ data: hasil, errors: error_rows }
end
# Encoding detection dan konversi
def baca_csv_dengan_encoding(path)
# Coba UTF-8 dulu
CSV.read(path, headers: true, encoding: "UTF-8")
rescue Encoding::InvalidByteSequenceError
# Fallback ke Windows-1252 (Latin-1)
CSV.read(path, headers: true, encoding: "Windows-1252:UTF-8")
end
# strip_whitespace — bersihkan whitespace dari semua nilai
csv_data = " nama , umur , kota \n Alice , 28 , Jakarta "
CSV.parse(csv_data,
headers: true,
header_converters: ->(h) { h.strip },
converters: ->(v) { v&.strip }
)
Ringkasan #
- Jangan
split(",")untuk CSV nyata — nilai yang mengandung koma, quote, atau newline akan salah diparse; selalu gunakan library CSV untuk keandalan.CSV.foreachuntuk file besar — tidak memuat seluruh file ke memori; iterasi baris per baris adalah pilihan terbaik untuk file yang besar.headers: trueuntuk data terstruktur — mengubah setiap baris menjadiCSV::Rowyang bisa diakses dengan nama kolom, jauh lebih aman dari akses by-index.converters: :numericuntuk konversi tipe otomatis — tanpa ini, semua nilai adalah String; gunakan:numericuntuk Integer dan Float, atau:alluntuk Date juga.- Penanganan encoding penting — file dari Windows sering menggunakan
Windows-1252; gunakan opsiencoding: "Windows-1252:UTF-8"untuk konversi saat membaca.CSV.generatedengan blok untuk menulis — gunakancsv << arraydi dalam blok; library menangani quoting otomatis untuk nilai yang mengandung koma atau newline.- Proses dalam batch untuk performa — ketika memproses data ke database, kumpulkan dalam array hingga ukuran tertentu sebelum flush; ini jauh lebih cepat dari insert satu per satu.
to_hpadaCSV::Row— konversi baris ke Hash biasa untuk kompatibilitas dengan kode yang tidak mengharapkanCSV::Row.