Encoding Csv #
CSV (Comma-Separated Values) adalah format pertukaran data yang paling universal — hampir setiap sistem bisa mengekspor dan mengimpor CSV, dari spreadsheet Excel hingga database PostgreSQL. Tapi CSV memiliki banyak edge case yang mudah diabaikan: field yang mengandung koma harus dikutip, kutipan dalam field harus di-escape, newline di dalam field juga valid, dan delimiter bisa bukan koma (tab, titik koma, pipe). Package encoding/csv menangani semua kerumitan ini dengan benar sesuai RFC 4180. Artikel ini membahas cara membaca dan menulis CSV dengan tepat, menangani edge case, mengkonfigurasi reader dan writer, serta pola pemrosesan CSV di aplikasi produksi.
Gambaran Besar Package encoding/csv #
flowchart LR
subgraph Read["Membaca CSV"]
R1["csv.NewReader(r)"] --> R2["reader.Read()\nsatu baris → []string"]
R1 --> R3["reader.ReadAll()\nsemua baris → [][]string"]
R2 --> R4["loop sampai io.EOF"]
end
subgraph Write["Menulis CSV"]
W1["csv.NewWriter(w)"] --> W2["writer.Write([]string)\ntulis satu baris"]
W1 --> W3["writer.WriteAll([][]string)\ntulis semua baris"]
W2 --> W4["writer.Flush()\nwajib dipanggil!"]
W3 --> W5["writer.Error()\nperiksa error setelah Flush"]
end
subgraph Config["Konfigurasi"]
C1["reader.Comma = ';'\ndelimiter kustom"]
C2["reader.Comment = '#'\nskip baris komentar"]
C3["reader.FieldsPerRecord\nvalidasi jumlah field"]
C4["reader.LazyQuotes = true\ntolerasi kutip tidak standar"]
C5["reader.TrimLeadingSpace\nhapus spasi di awal"]
C6["writer.Comma = '\\t'\ntulis TSV"]
C7["writer.UseCRLF\ngunakan \\r\\n"]
end
style Read fill:#e8f5e9
style Write fill:#e3f2fd
style Config fill:#fff3e0Membaca CSV — csv.Reader #
Membaca Baris per Baris #
package main
import (
"encoding/csv"
"fmt"
"io"
"os"
"strings"
)
func main() {
input := `nama,email,kota
Budi Santoso,[email protected],Jakarta
Ani Wijaya,[email protected],Bandung
Charlie,[email protected],"Surabaya, Jawa Timur"
`
reader := csv.NewReader(strings.NewReader(input))
for {
record, err := reader.Read()
// EOF berarti selesai — bukan error sebenarnya
if err == io.EOF {
break
}
if err != nil {
fmt.Fprintf(os.Stderr, "error membaca: %v\n", err)
return
}
// record adalah []string — satu elemen per field
fmt.Println(record)
}
// [nama email kota]
// [Budi Santoso [email protected] Jakarta]
// [Ani Wijaya [email protected] Bandung]
// [Charlie [email protected] Surabaya, Jawa Timur] ← tanda kutip sudah dihapus!
}
ReadAll — Baca Semua Sekaligus #
func bacaCSVSederhana(path string) ([][]string, error) {
f, err := os.Open(path)
if err != nil {
return nil, fmt.Errorf("bacaCSVSederhana: %w", err)
}
defer f.Close()
reader := csv.NewReader(f)
// ReadAll — baca semua baris sekaligus
// Cocok untuk file kecil yang muat di memori
records, err := reader.ReadAll()
if err != nil {
return nil, fmt.Errorf("bacaCSVSederhana ReadAll: %w", err)
}
return records, nil
}
// Penggunaan
records, err := bacaCSVSederhana("data.csv")
if err != nil {
log.Fatal(err)
}
// records[0] adalah header
header := records[0]
fmt.Println("Kolom:", header)
// records[1:] adalah data
for _, row := range records[1:] {
fmt.Println(row)
}
Konfigurasi csv.Reader #
flowchart TD
Reader["csv.NewReader(r)"] --> Config["Konfigurasi sebelum Read()"]
Config --> Comma["reader.Comma\ndefault: ',' (koma)\nbisa diubah ke ';' '\\t' '|' dll"]
Config --> Comment["reader.Comment\ndefault: 0 (tidak ada)\ncontoh: '#' untuk skip komentar"]
Config --> Fields["reader.FieldsPerRecord\ndefault: 0 (auto dari baris pertama)\n-1: fleksibel (tidak divalidasi)\nN: harus tepat N field per baris"]
Config --> Lazy["reader.LazyQuotes\ndefault: false\ntrue: tolerasi kutip tidak standar"]
Config --> Trim["reader.TrimLeadingSpace\ndefault: false\ntrue: hapus spasi di awal field"]
Config --> ReuseRecord["reader.ReuseRecord\ndefault: false\ntrue: reuse slice (lebih cepat,\ntapi isi sebelumnya dioverwrite)"]
style Reader fill:#4f86c6,color:#fff
style Config fill:#e8f5e9// TSV (Tab-Separated Values)
readerTSV := csv.NewReader(r)
readerTSV.Comma = '\t'
// CSV dengan delimiter titik koma (umum di Eropa)
readerSemicolon := csv.NewReader(r)
readerSemicolon.Comma = ';'
// CSV dengan komentar
readerWithComment := csv.NewReader(r)
readerWithComment.Comment = '#'
// Input:
// # ini komentar, diabaikan
// nama,nilai
// Budi,90
// Validasi jumlah field
readerStrict := csv.NewReader(r)
readerStrict.FieldsPerRecord = 3 // HARUS tepat 3 field per baris
// Error jika ada baris dengan jumlah field yang berbeda
readerFlexible := csv.NewReader(r)
readerFlexible.FieldsPerRecord = -1 // terima berapa saja field per baris
// LazyQuotes — untuk CSV dari sistem lain yang tidak 100% sesuai RFC 4180
// Contoh: field "Jakarta" Barat (kutip tidak ditutup dengan benar)
readerLazy := csv.NewReader(r)
readerLazy.LazyQuotes = true
// TrimLeadingSpace — berguna untuk CSV yang punya spasi setelah koma
// Contoh: nama, email, kota (ada spasi setelah koma)
readerTrim := csv.NewReader(r)
readerTrim.TrimLeadingSpace = true
// ReuseRecord — performa lebih baik jika kamu menyalin data sebelum iterasi berikutnya
readerFast := csv.NewReader(r)
readerFast.ReuseRecord = true // HATI-HATI: record sebelumnya dioverwrite!
for {
record, err := readerFast.Read()
if err == io.EOF {
break
}
// WAJIB: salin record sebelum iterasi berikutnya jika ingin disimpan
salinan := make([]string, len(record))
copy(salinan, record)
// ANTI-PATTERN: simpan record langsung tanpa copy
// data = append(data, record) — isi data akan ter-overwrite di iterasi berikutnya!
}
Menulis CSV — csv.Writer #
func tulisCSV(path string, headers []string, rows [][]string) error {
f, err := os.Create(path)
if err != nil {
return fmt.Errorf("tulisCSV create: %w", err)
}
defer f.Close()
writer := csv.NewWriter(f)
// Tulis header
if err := writer.Write(headers); err != nil {
return fmt.Errorf("tulisCSV tulis header: %w", err)
}
// Tulis baris data
for _, row := range rows {
if err := writer.Write(row); err != nil {
return fmt.Errorf("tulisCSV tulis baris: %w", err)
}
}
// WAJIB: Flush memindahkan data dari buffer ke file
writer.Flush()
// Periksa error setelah Flush
if err := writer.Error(); err != nil {
return fmt.Errorf("tulisCSV flush: %w", err)
}
return nil
}
// WriteAll — tulis semua sekaligus
func tulisCSVSekaligus(path string, records [][]string) error {
f, err := os.Create(path)
if err != nil {
return fmt.Errorf("tulisCSVSekaligus: %w", err)
}
defer f.Close()
writer := csv.NewWriter(f)
if err := writer.WriteAll(records); err != nil {
return fmt.Errorf("tulisCSVSekaligus WriteAll: %w", err)
}
// WriteAll otomatis flush, tapi tetap periksa error
return writer.Error()
}
Selalu panggilwriter.Flush()dan periksawriter.Error()setelah selesai menulis.csv.Writermenggunakanbufio.Writerdi dalamnya — data yang belum di-flush akan hilang saat file ditutup tanpa error apapun.WriteAllsudah memanggilFlushsecara internal, tapi tetap periksawriter.Error()setelahnya.
Konfigurasi csv.Writer #
// TSV — Tab-Separated Values
writer := csv.NewWriter(f)
writer.Comma = '\t'
// CSV dengan delimiter titik koma
writer.Comma = ';'
// Gunakan CRLF (Windows-style line ending)
writer.UseCRLF = true
// Penulisan ke http.ResponseWriter untuk download
func handlerDownloadCSV(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/csv")
w.Header().Set("Content-Disposition", `attachment; filename="data.csv"`)
writer := csv.NewWriter(w)
writer.UseCRLF = true // Excel di Windows butuh CRLF
// Tulis header
writer.Write([]string{"ID", "Nama", "Email", "Kota"})
// Tulis data dari database
rows, _ := db.Query("SELECT id, nama, email, kota FROM pengguna")
defer rows.Close()
for rows.Next() {
var id int
var nama, email, kota string
rows.Scan(&id, &nama, &email, &kota)
writer.Write([]string{
strconv.Itoa(id),
nama,
email,
kota,
})
}
writer.Flush()
}
Menangani Header — Mapping ke Struct #
Package encoding/csv tidak secara langsung mendukung mapping ke struct, tapi pola ini mudah diimplementasikan:
sequenceDiagram
participant File as CSV File
participant Reader as csv.Reader
participant Code as Kode Go
participant Struct as []Produk
File->>Reader: baca
Reader->>Code: record[0] = header row\n["id","nama","harga","stok"]
Code->>Code: buat map: header → indeks\n{"id":0,"nama":1,"harga":2,"stok":3}
loop setiap baris data
Reader->>Code: record[n] = data row\n["1","Laptop","15000000","10"]
Code->>Struct: Produk{\n ID: record[idx["id"]],\n Nama: record[idx["nama"]],\n ...\n}
endtype Produk struct {
ID int
Nama string
Harga float64
Stok int
Kategori string
}
func bacaProdukDariCSV(path string) ([]Produk, error) {
f, err := os.Open(path)
if err != nil {
return nil, fmt.Errorf("bacaProdukDariCSV: %w", err)
}
defer f.Close()
reader := csv.NewReader(f)
reader.TrimLeadingSpace = true
// Baca header
header, err := reader.Read()
if err != nil {
return nil, fmt.Errorf("baca header: %w", err)
}
// Buat map header → indeks
idx := make(map[string]int)
for i, h := range header {
idx[strings.ToLower(strings.TrimSpace(h))] = i
}
// Validasi kolom yang diperlukan
required := []string{"id", "nama", "harga", "stok"}
for _, col := range required {
if _, ada := idx[col]; !ada {
return nil, fmt.Errorf("kolom '%s' tidak ditemukan di CSV", col)
}
}
var produk []Produk
nomorBaris := 1
for {
record, err := reader.Read()
if err == io.EOF {
break
}
if err != nil {
return nil, fmt.Errorf("baca baris %d: %w", nomorBaris, err)
}
nomorBaris++
// Parse setiap field dengan validasi
id, err := strconv.Atoi(strings.TrimSpace(record[idx["id"]]))
if err != nil {
return nil, fmt.Errorf("baris %d: ID tidak valid %q: %w",
nomorBaris, record[idx["id"]], err)
}
harga, err := strconv.ParseFloat(
strings.ReplaceAll(record[idx["harga"]], ",", ""), 64)
if err != nil {
return nil, fmt.Errorf("baris %d: harga tidak valid %q: %w",
nomorBaris, record[idx["harga"]], err)
}
stok, err := strconv.Atoi(strings.TrimSpace(record[idx["stok"]]))
if err != nil {
return nil, fmt.Errorf("baris %d: stok tidak valid %q: %w",
nomorBaris, record[idx["stok"]], err)
}
p := Produk{
ID: id,
Nama: strings.TrimSpace(record[idx["nama"]]),
Harga: harga,
Stok: stok,
}
// Kolom opsional
if i, ada := idx["kategori"]; ada && i < len(record) {
p.Kategori = strings.TrimSpace(record[i])
}
produk = append(produk, p)
}
return produk, nil
}
Menulis Struct ke CSV #
func tulisProdukKeCSV(path string, produk []Produk) error {
f, err := os.Create(path)
if err != nil {
return fmt.Errorf("tulisProdukKeCSV: %w", err)
}
defer f.Close()
writer := csv.NewWriter(f)
// Tulis header
if err := writer.Write([]string{"id", "nama", "harga", "stok", "kategori"}); err != nil {
return fmt.Errorf("tulis header: %w", err)
}
// Tulis setiap produk
for _, p := range produk {
record := []string{
strconv.Itoa(p.ID),
p.Nama,
strconv.FormatFloat(p.Harga, 'f', 2, 64),
strconv.Itoa(p.Stok),
p.Kategori,
}
if err := writer.Write(record); err != nil {
return fmt.Errorf("tulis produk %d: %w", p.ID, err)
}
}
writer.Flush()
return writer.Error()
}
Edge Case yang Perlu Diperhatikan #
Field dengan Koma, Kutip, dan Newline #
// csv.Writer menangani semua edge case secara otomatis!
writer := csv.NewWriter(os.Stdout)
// Field dengan koma — otomatis dikutip
writer.Write([]string{"Surabaya, Jawa Timur", "60000"})
// Output: "Surabaya, Jawa Timur",60000
// Field dengan tanda kutip — otomatis di-escape dengan kutip ganda
writer.Write([]string{`Produk "Premium"`, "15000"})
// Output: "Produk ""Premium""",15000
// Field dengan newline — otomatis dikutip
writer.Write([]string{"Deskripsi\nbaris kedua", "active"})
// Output: "Deskripsi
// baris kedua",active
// Field kosong
writer.Write([]string{"", "nilai", ""})
// Output: ,nilai,
writer.Flush()
Mendeteksi dan Menangani Error CSV #
func bacaCSVDenganErrorHandling(r io.Reader) ([][]string, error) {
reader := csv.NewReader(r)
reader.LazyQuotes = true // toleran terhadap CSV yang tidak sempurna
var records [][]string
var parseErrors []string
nomorBaris := 0
for {
nomorBaris++
record, err := reader.Read()
if err == io.EOF {
break
}
if err != nil {
// Periksa jenis error CSV
var csvErr *csv.ParseError
if errors.As(err, &csvErr) {
// ParseError menyertakan informasi baris dan kolom
parseErrors = append(parseErrors,
fmt.Sprintf("baris %d, kolom %d: %v",
csvErr.Line, csvErr.Column, csvErr.Err))
continue // lanjutkan ke baris berikutnya
}
// Error I/O yang serius — hentikan
return nil, fmt.Errorf("baris %d: %w", nomorBaris, err)
}
records = append(records, record)
}
if len(parseErrors) > 0 {
fmt.Fprintf(os.Stderr, "Peringatan: %d baris dilewati karena error:\n",
len(parseErrors))
for _, e := range parseErrors {
fmt.Fprintf(os.Stderr, " - %s\n", e)
}
}
return records, nil
}
Pola Penggunaan di Produksi #
Pipeline: Baca → Transformasi → Tulis #
// Transformasi CSV: filter, ubah format, tambah kolom
func transformasiCSV(src io.Reader, dst io.Writer, minHarga float64) error {
reader := csv.NewReader(src)
reader.TrimLeadingSpace = true
writer := csv.NewWriter(dst)
defer writer.Flush()
// Baca dan teruskan header dengan kolom tambahan
header, err := reader.Read()
if err != nil {
return fmt.Errorf("baca header: %w", err)
}
// Tambahkan kolom "kategori_harga"
headerBaru := append(header, "kategori_harga")
if err := writer.Write(headerBaru); err != nil {
return err
}
// Cari indeks kolom harga
idxHarga := -1
for i, h := range header {
if strings.EqualFold(h, "harga") {
idxHarga = i
break
}
}
if idxHarga < 0 {
return fmt.Errorf("kolom 'harga' tidak ditemukan")
}
nomorBaris := 1
diproses, dilewati := 0, 0
for {
record, err := reader.Read()
if err == io.EOF {
break
}
if err != nil {
nomorBaris++
dilewati++
fmt.Fprintf(os.Stderr, "skip baris %d: %v\n", nomorBaris, err)
continue
}
nomorBaris++
// Parse harga
harga, err := strconv.ParseFloat(
strings.ReplaceAll(record[idxHarga], ",", ""), 64)
if err != nil {
dilewati++
continue
}
// Filter: skip produk di bawah harga minimum
if harga < minHarga {
dilewati++
continue
}
// Tambahkan kolom kategori harga
var kategori string
switch {
case harga >= 10000000:
kategori = "premium"
case harga >= 1000000:
kategori = "menengah"
default:
kategori = "ekonomis"
}
recordBaru := append(record, kategori)
if err := writer.Write(recordBaru); err != nil {
return fmt.Errorf("tulis baris %d: %w", nomorBaris, err)
}
diproses++
}
fmt.Fprintf(os.Stderr, "Selesai: %d diproses, %d dilewati\n",
diproses, dilewati)
return writer.Error()
}
Import CSV ke Database #
func importCSVKeBD(path string, db *sql.DB) error {
f, err := os.Open(path)
if err != nil {
return fmt.Errorf("buka file: %w", err)
}
defer f.Close()
reader := csv.NewReader(f)
reader.TrimLeadingSpace = true
// Baca header
header, err := reader.Read()
if err != nil {
return fmt.Errorf("baca header: %w", err)
}
// Validasi header
required := map[string]bool{"nama": false, "email": false, "kota": false}
idx := make(map[string]int)
for i, h := range header {
key := strings.ToLower(strings.TrimSpace(h))
idx[key] = i
if _, ada := required[key]; ada {
required[key] = true
}
}
for col, ada := range required {
if !ada {
return fmt.Errorf("kolom wajib '%s' tidak ada", col)
}
}
// Mulai transaksi database
tx, err := db.Begin()
if err != nil {
return fmt.Errorf("mulai transaksi: %w", err)
}
defer tx.Rollback() // akan di-rollback jika Commit tidak dipanggil
stmt, err := tx.Prepare(
"INSERT INTO pengguna (nama, email, kota) VALUES ($1, $2, $3) " +
"ON CONFLICT (email) DO UPDATE SET nama=$1, kota=$3")
if err != nil {
return fmt.Errorf("prepare statement: %w", err)
}
defer stmt.Close()
berhasil, gagal := 0, 0
nomorBaris := 1
for {
record, err := reader.Read()
if err == io.EOF {
break
}
nomorBaris++
if err != nil {
gagal++
fmt.Fprintf(os.Stderr, "skip baris %d: %v\n", nomorBaris, err)
continue
}
nama := strings.TrimSpace(record[idx["nama"]])
email := strings.TrimSpace(record[idx["email"]])
kota := strings.TrimSpace(record[idx["kota"]])
if nama == "" || email == "" {
gagal++
continue
}
if _, err := stmt.Exec(nama, email, kota); err != nil {
fmt.Fprintf(os.Stderr, "baris %d gagal insert: %v\n", nomorBaris, err)
gagal++
continue
}
berhasil++
// Commit setiap 1000 baris untuk menghindari transaksi yang terlalu besar
if berhasil%1000 == 0 {
if err := tx.Commit(); err != nil {
return fmt.Errorf("commit: %w", err)
}
tx, _ = db.Begin()
stmt, _ = tx.Prepare(
"INSERT INTO pengguna (nama, email, kota) VALUES ($1, $2, $3) " +
"ON CONFLICT (email) DO UPDATE SET nama=$1, kota=$3")
fmt.Fprintf(os.Stderr, "Progress: %d berhasil\n", berhasil)
}
}
if err := tx.Commit(); err != nil {
return fmt.Errorf("commit akhir: %w", err)
}
fmt.Printf("Import selesai: %d berhasil, %d gagal\n", berhasil, gagal)
return nil
}
Export dari Database ke CSV #
func eksporPenggunakeCSV(db *sql.DB, w io.Writer) error {
rows, err := db.Query(
"SELECT id, nama, email, kota, created_at FROM pengguna ORDER BY id")
if err != nil {
return fmt.Errorf("query: %w", err)
}
defer rows.Close()
writer := csv.NewWriter(w)
// Tulis header
writer.Write([]string{"id", "nama", "email", "kota", "tanggal_daftar"})
for rows.Next() {
var id int
var nama, email, kota string
var createdAt time.Time
if err := rows.Scan(&id, &nama, &email, &kota, &createdAt); err != nil {
fmt.Fprintf(os.Stderr, "scan error: %v\n", err)
continue
}
writer.Write([]string{
strconv.Itoa(id),
nama,
email,
kota,
createdAt.Format("2006-01-02"),
})
}
writer.Flush()
if err := rows.Err(); err != nil {
return fmt.Errorf("iterasi rows: %w", err)
}
return writer.Error()
}
Kapan Beralih ke Alternatif #
Tetap gunakan encoding/csv jika:
✓ Baca dan tulis CSV standar (RFC 4180)
✓ CSV dari Excel, Google Sheets, atau sistem lain yang umum
✓ CSV sederhana dengan delimiter koma atau tab
✓ File CSV kecil hingga menengah
✓ Import/export data ke database
Pertimbangkan parsing manual dengan bufio.Scanner jika:
✗ CSV sangat sederhana tanpa quoting sama sekali
✗ Format tidak sesuai RFC 4180 dan LazyQuotes tidak cukup
✗ Butuh kontrol penuh atas parsing setiap karakter
Pertimbangkan library eksternal jika:
✗ CSV dengan jutaan baris → gocsv untuk mapping struct otomatis
✗ Infer tipe kolom otomatis (int, float, bool, date)
→ csvutil, gocsv
✗ Schema validation per kolom
✗ Parallel processing CSV besar
✗ Excel (.xlsx) bukan CSV → trs/excelize atau github.com/360EntSecGroup-Skylar/excelize
Pertimbangkan encoding/json jika:
✗ Pertukaran data antar service — JSON lebih ekspresif untuk data hierarkis
✗ Data dengan tipe yang kompleks (nested, array)
Ringkasan #
reader.Read()mengembalikanio.EOFsaat selesai — ini bukan error, tangani secara terpisah denganif err == io.EOF { break }.csv.Writermenggunakan buffer internal — selalu panggilwriter.Flush()setelah selesai, dan periksawriter.Error()untuk mengetahui jika ada error saat buffering.encoding/csvmenangani edge case secara otomatis — field dengan koma, tanda kutip, dan newline dikutip dan di-escape dengan benar sesuai RFC 4180.reader.TrimLeadingSpace = trueuntuk CSV yang punya spasi setelah delimiter — umum di file CSV yang dibuat manusia atau diekspor dari spreadsheet.reader.LazyQuotes = trueuntuk toleransi CSV yang tidak sempurna — berguna untuk file dari sistem legacy yang tidak sepenuhnya mengikuti RFC 4180.reader.FieldsPerRecord = -1untuk CSV dengan jumlah field yang tidak konsisten — default (0) menggunakan baris pertama sebagai acuan dan error jika baris lain berbeda.reader.ReuseRecord = trueuntuk performa maksimal — tapi simpan salinan dengancopy()jika ingin menyimpan record, karena slice asli akan di-overwrite di iterasi berikutnya.- Buat map header → indeks saat membaca CSV dengan header — lebih robust dari mengakses
record[0],record[1]secara hardcoded jika urutan kolom berubah.- Commit database per batch saat import CSV besar — jangan satu transaksi untuk jutaan baris, commit setiap N baris untuk menghindari transaksi yang terlalu besar.