Encoding Csv #
CSV (Comma-Separated Values) is the most universal data exchange format — almost every system can export and import CSV, from Excel spreadsheets to PostgreSQL databases. But CSV has many edge cases that are easy to overlook: fields containing commas must be quoted, quotes inside fields must be escaped, newlines inside fields are valid too, and the delimiter can be something other than a comma (tab, semicolon, pipe). The encoding/csv package handles all this complexity correctly per RFC 4180. This article covers how to read and write CSV correctly, handle edge cases, configure the reader and writer, and CSV processing patterns in production applications.
An Overview of the encoding/csv Package #
flowchart LR
subgraph Read["Reading CSV"]
R1["csv.NewReader(r)"] --> R2["reader.Read()\none row → []string"]
R1 --> R3["reader.ReadAll()\nall rows → [][]string"]
R2 --> R4["loop until io.EOF"]
end
subgraph Write["Writing CSV"]
W1["csv.NewWriter(w)"] --> W2["writer.Write([]string)\nwrite one row"]
W1 --> W3["writer.WriteAll([][]string)\nwrite all rows"]
W2 --> W4["writer.Flush()\nrequired!"]
W3 --> W5["writer.Error()\ncheck the error after Flush"]
end
subgraph Config["Configuration"]
C1["reader.Comma = ';'\ncustom delimiter"]
C2["reader.Comment = '#'\nskip comment lines"]
C3["reader.FieldsPerRecord\nvalidate the field count"]
C4["reader.LazyQuotes = true\ntolerate non-standard quotes"]
C5["reader.TrimLeadingSpace\nremove leading spaces"]
C6["writer.Comma = '\t'\nwrite TSV"]
C7["writer.UseCRLF\nuse \r\n"]
end
style Read fill:#e8f5e9
style Write fill:#e3f2fd
style Config fill:#fff3e0Reading CSV — csv.Reader #
Reading Row by Row #
package main
import (
"encoding/csv"
"fmt"
"io"
"os"
"strings"
)
func main() {
input := `name,email,city
Budi Santoso,[email protected],Jakarta
Ani Wijaya,[email protected],Bandung
Charlie,[email protected],"Surabaya, East Java"
`
reader := csv.NewReader(strings.NewReader(input))
for {
record, err := reader.Read()
// EOF means done — not a real error
if err == io.EOF {
break
}
if err != nil {
fmt.Fprintf(os.Stderr, "read error: %v\n", err)
return
}
// record is a []string — one element per field
fmt.Println(record)
}
// [name email city]
// [Budi Santoso [email protected] Jakarta]
// [Ani Wijaya [email protected] Bandung]
// [Charlie [email protected] Surabaya, East Java] ← quotes already removed!
}
ReadAll — Reading Everything at Once #
func readCSVSimple(path string) ([][]string, error) {
f, err := os.Open(path)
if err != nil {
return nil, fmt.Errorf("readCSVSimple: %w", err)
}
defer f.Close()
reader := csv.NewReader(f)
// ReadAll — read all rows at once
// Suitable for small files that fit in memory
records, err := reader.ReadAll()
if err != nil {
return nil, fmt.Errorf("readCSVSimple ReadAll: %w", err)
}
return records, nil
}
// Usage
records, err := readCSVSimple("data.csv")
if err != nil {
log.Fatal(err)
}
// records[0] is the header
header := records[0]
fmt.Println("Columns:", header)
// records[1:] is the data
for _, row := range records[1:] {
fmt.Println(row)
}
Configuring csv.Reader #
flowchart TD
Reader["csv.NewReader(r)"] --> Config["Configure before Read()"]
Config --> Comma["reader.Comma\ndefault: ',' (comma)\ncan be changed to ';' '\t' '|' etc."]
Config --> Comment["reader.Comment\ndefault: 0 (none)\ne.g. '#' to skip comments"]
Config --> Fields["reader.FieldsPerRecord\ndefault: 0 (auto from the first row)\n-1: flexible (no validation)\nN: exactly N fields per row"]
Config --> Lazy["reader.LazyQuotes\ndefault: false\ntrue: tolerate non-standard quotes"]
Config --> Trim["reader.TrimLeadingSpace\ndefault: false\ntrue: remove leading spaces in fields"]
Config --> ReuseRecord["reader.ReuseRecord\ndefault: false\ntrue: reuse the slice (faster,\nbut previous contents are overwritten)"]
style Reader fill:#4f86c6,color:#fff
style Config fill:#e8f5e9// TSV (Tab-Separated Values)
readerTSV := csv.NewReader(r)
readerTSV.Comma = '\t'
// CSV with a semicolon delimiter (common in Europe)
readerSemicolon := csv.NewReader(r)
readerSemicolon.Comma = ';'
// CSV with comments
readerWithComment := csv.NewReader(r)
readerWithComment.Comment = '#'
// Input:
// # this is a comment, ignored
// name,score
// Budi,90
// Field count validation
readerStrict := csv.NewReader(r)
readerStrict.FieldsPerRecord = 3 // MUST be exactly 3 fields per row
// Errors if any row has a different field count
readerFlexible := csv.NewReader(r)
readerFlexible.FieldsPerRecord = -1 // accept any number of fields per row
// LazyQuotes — for CSV from other systems that aren't 100% RFC 4180 compliant
// Example: a field like "Jakarta" Barat (quote not closed correctly)
readerLazy := csv.NewReader(r)
readerLazy.LazyQuotes = true
// TrimLeadingSpace — useful for CSV that has spaces after commas
// Example: name, email, city (space after the comma)
readerTrim := csv.NewReader(r)
readerTrim.TrimLeadingSpace = true
// ReuseRecord — better performance if you copy the data before the next iteration
readerFast := csv.NewReader(r)
readerFast.ReuseRecord = true // WARNING: the previous record is overwritten!
for {
record, err := readerFast.Read()
if err == io.EOF {
break
}
// REQUIRED: copy the record before the next iteration if you want to keep it
copy := make([]string, len(record))
copy(copy, record)
// ANTI-PATTERN: store the record directly without copying
// data = append(data, record) — the data will be overwritten on the next iteration!
}
Writing CSV — csv.Writer #
func writeCSV(path string, headers []string, rows [][]string) error {
f, err := os.Create(path)
if err != nil {
return fmt.Errorf("writeCSV create: %w", err)
}
defer f.Close()
writer := csv.NewWriter(f)
// Write the header
if err := writer.Write(headers); err != nil {
return fmt.Errorf("writeCSV write header: %w", err)
}
// Write the data rows
for _, row := range rows {
if err := writer.Write(row); err != nil {
return fmt.Errorf("writeCSV write row: %w", err)
}
}
// REQUIRED: Flush moves the data from the buffer to the file
writer.Flush()
// Check the error after Flush
if err := writer.Error(); err != nil {
return fmt.Errorf("writeCSV flush: %w", err)
}
return nil
}
// WriteAll — write everything at once
func writeCSVAllAtOnce(path string, records [][]string) error {
f, err := os.Create(path)
if err != nil {
return fmt.Errorf("writeCSVAllAtOnce: %w", err)
}
defer f.Close()
writer := csv.NewWriter(f)
if err := writer.WriteAll(records); err != nil {
return fmt.Errorf("writeCSVAllAtOnce WriteAll: %w", err)
}
// WriteAll flushes automatically, but still check the error
return writer.Error()
}
Always callwriter.Flush()and checkwriter.Error()after finishing writes. Thecsv.Writeruses abufio.Writerinternally — unflushed data will be lost when the file is closed without any error.WriteAllcallsFlushinternally, but still checkwriter.Error()afterwards.
Configuring csv.Writer #
// TSV — Tab-Separated Values
writer := csv.NewWriter(f)
writer.Comma = '\t'
// CSV with a semicolon delimiter
writer.Comma = ';'
// Use CRLF (Windows-style line endings)
writer.UseCRLF = true
// Writing to an http.ResponseWriter for download
func downloadCSVHandler(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 on Windows needs CRLF
// Write the header
writer.Write([]string{"ID", "Name", "Email", "City"})
// Write data from the database
rows, _ := db.Query("SELECT id, name, email, city FROM users")
defer rows.Close()
for rows.Next() {
var id int
var name, email, city string
rows.Scan(&id, &name, &email, &city)
writer.Write([]string{
strconv.Itoa(id),
name,
email,
city,
})
}
writer.Flush()
}
Handling Headers — Mapping to Structs #
The encoding/csv package doesn’t directly support mapping to structs, but this pattern is easy to implement:
sequenceDiagram
participant File as CSV File
participant Reader as csv.Reader
participant Code as Go Code
participant Struct as []Product
File->>Reader: read
Reader->>Code: record[0] = header row\n["id","name","price","stock"]
Code->>Code: build map: header → index\n{"id":0,"name":1,"price":2,"stock":3}
loop every data row
Reader->>Code: record[n] = data row\n["1","Laptop","15000000","10"]
Code->>Struct: Product{\n ID: record[idx["id"]],\n Name: record[idx["name"]],\n ...\n}
endtype Product struct {
ID int
Name string
Price float64
Stock int
Category string
}
func readProductsFromCSV(path string) ([]Product, error) {
f, err := os.Open(path)
if err != nil {
return nil, fmt.Errorf("readProductsFromCSV: %w", err)
}
defer f.Close()
reader := csv.NewReader(f)
reader.TrimLeadingSpace = true
// Read the header
header, err := reader.Read()
if err != nil {
return nil, fmt.Errorf("read header: %w", err)
}
// Build a header → index map
idx := make(map[string]int)
for i, h := range header {
idx[strings.ToLower(strings.TrimSpace(h))] = i
}
// Validate the required columns
required := []string{"id", "name", "price", "stock"}
for _, col := range required {
if _, exists := idx[col]; !exists {
return nil, fmt.Errorf("column '%s' not found in the CSV", col)
}
}
var products []Product
lineNumber := 1
for {
record, err := reader.Read()
if err == io.EOF {
break
}
if err != nil {
return nil, fmt.Errorf("read line %d: %w", lineNumber, err)
}
lineNumber++
// Parse each field with validation
id, err := strconv.Atoi(strings.TrimSpace(record[idx["id"]]))
if err != nil {
return nil, fmt.Errorf("line %d: invalid ID %q: %w",
lineNumber, record[idx["id"]], err)
}
price, err := strconv.ParseFloat(
strings.ReplaceAll(record[idx["price"]], ",", ""), 64)
if err != nil {
return nil, fmt.Errorf("line %d: invalid price %q: %w",
lineNumber, record[idx["price"]], err)
}
stock, err := strconv.Atoi(strings.TrimSpace(record[idx["stock"]]))
if err != nil {
return nil, fmt.Errorf("line %d: invalid stock %q: %w",
lineNumber, record[idx["stock"]], err)
}
p := Product{
ID: id,
Name: strings.TrimSpace(record[idx["name"]]),
Price: price,
Stock: stock,
}
// Optional column
if i, exists := idx["category"]; exists && i < len(record) {
p.Category = strings.TrimSpace(record[i])
}
products = append(products, p)
}
return products, nil
}
Writing Structs to CSV #
func writeProductsToCSV(path string, products []Product) error {
f, err := os.Create(path)
if err != nil {
return fmt.Errorf("writeProductsToCSV: %w", err)
}
defer f.Close()
writer := csv.NewWriter(f)
// Write the header
if err := writer.Write([]string{"id", "name", "price", "stock", "category"}); err != nil {
return fmt.Errorf("write header: %w", err)
}
// Write every product
for _, p := range products {
record := []string{
strconv.Itoa(p.ID),
p.Name,
strconv.FormatFloat(p.Price, 'f', 2, 64),
strconv.Itoa(p.Stock),
p.Category,
}
if err := writer.Write(record); err != nil {
return fmt.Errorf("write product %d: %w", p.ID, err)
}
}
writer.Flush()
return writer.Error()
}
Edge Cases to Watch Out For #
Fields with Commas, Quotes, and Newlines #
// csv.Writer handles all edge cases automatically!
writer := csv.NewWriter(os.Stdout)
// A field with a comma — automatically quoted
writer.Write([]string{"Surabaya, East Java", "60000"})
// Output: "Surabaya, East Java",60000
// A field with quotes — automatically escaped with double quotes
writer.Write([]string{`Product "Premium"`, "15000"})
// Output: "Product ""Premium""",15000
// A field with a newline — automatically quoted
writer.Write([]string{"Description\nsecond line", "active"})
// Output: "Description
// second line",active
// An empty field
writer.Write([]string{"", "value", ""})
// Output: ,value,
writer.Flush()
Detecting and Handling CSV Errors #
func readCSVWithErrorHandling(r io.Reader) ([][]string, error) {
reader := csv.NewReader(r)
reader.LazyQuotes = true // tolerant of imperfect CSV
var records [][]string
var parseErrors []string
lineNumber := 0
for {
lineNumber++
record, err := reader.Read()
if err == io.EOF {
break
}
if err != nil {
// Check the CSV error type
var csvErr *csv.ParseError
if errors.As(err, &csvErr) {
// ParseError includes the line and column information
parseErrors = append(parseErrors,
fmt.Sprintf("line %d, column %d: %v",
csvErr.Line, csvErr.Column, csvErr.Err))
continue // continue to the next row
}
// A serious I/O error — stop
return nil, fmt.Errorf("line %d: %w", lineNumber, err)
}
records = append(records, record)
}
if len(parseErrors) > 0 {
fmt.Fprintf(os.Stderr, "Warning: %d rows skipped due to errors:\n",
len(parseErrors))
for _, e := range parseErrors {
fmt.Fprintf(os.Stderr, " - %s\n", e)
}
}
return records, nil
}
Production Usage Patterns #
Pipeline: Read → Transform → Write #
// CSV transformation: filter, change formats, add columns
func transformCSV(src io.Reader, dst io.Writer, minPrice float64) error {
reader := csv.NewReader(src)
reader.TrimLeadingSpace = true
writer := csv.NewWriter(dst)
defer writer.Flush()
// Read and pass through the header with an extra column
header, err := reader.Read()
if err != nil {
return fmt.Errorf("read header: %w", err)
}
// Add a "price_category" column
newHeader := append(header, "price_category")
if err := writer.Write(newHeader); err != nil {
return err
}
// Find the price column index
priceIdx := -1
for i, h := range header {
if strings.EqualFold(h, "price") {
priceIdx = i
break
}
}
if priceIdx < 0 {
return fmt.Errorf("'price' column not found")
}
lineNumber := 1
processed, skipped := 0, 0
for {
record, err := reader.Read()
if err == io.EOF {
break
}
if err != nil {
lineNumber++
skipped++
fmt.Fprintf(os.Stderr, "skip line %d: %v\n", lineNumber, err)
continue
}
lineNumber++
// Parse the price
price, err := strconv.ParseFloat(
strings.ReplaceAll(record[priceIdx], ",", ""), 64)
if err != nil {
skipped++
continue
}
// Filter: skip products below the minimum price
if price < minPrice {
skipped++
continue
}
// Add the price category column
var category string
switch {
case price >= 10000000:
category = "premium"
case price >= 1000000:
category = "mid-range"
default:
category = "budget"
}
newRecord := append(record, category)
if err := writer.Write(newRecord); err != nil {
return fmt.Errorf("write line %d: %w", lineNumber, err)
}
processed++
}
fmt.Fprintf(os.Stderr, "Done: %d processed, %d skipped\n",
processed, skipped)
return writer.Error()
}
Importing CSV into a Database #
func importCSVToDB(path string, db *sql.DB) error {
f, err := os.Open(path)
if err != nil {
return fmt.Errorf("open file: %w", err)
}
defer f.Close()
reader := csv.NewReader(f)
reader.TrimLeadingSpace = true
// Read the header
header, err := reader.Read()
if err != nil {
return fmt.Errorf("read header: %w", err)
}
// Validate the header
required := map[string]bool{"name": false, "email": false, "city": false}
idx := make(map[string]int)
for i, h := range header {
key := strings.ToLower(strings.TrimSpace(h))
idx[key] = i
if _, exists := required[key]; exists {
required[key] = true
}
}
for col, exists := range required {
if !exists {
return fmt.Errorf("required column '%s' is missing", col)
}
}
// Start a database transaction
tx, err := db.Begin()
if err != nil {
return fmt.Errorf("begin transaction: %w", err)
}
defer tx.Rollback() // will be rolled back if Commit isn't called
stmt, err := tx.Prepare(
"INSERT INTO users (name, email, city) VALUES ($1, $2, $3) " +
"ON CONFLICT (email) DO UPDATE SET name=$1, city=$3")
if err != nil {
return fmt.Errorf("prepare statement: %w", err)
}
defer stmt.Close()
succeeded, failed := 0, 0
lineNumber := 1
for {
record, err := reader.Read()
if err == io.EOF {
break
}
lineNumber++
if err != nil {
failed++
fmt.Fprintf(os.Stderr, "skip line %d: %v\n", lineNumber, err)
continue
}
name := strings.TrimSpace(record[idx["name"]])
email := strings.TrimSpace(record[idx["email"]])
city := strings.TrimSpace(record[idx["city"]])
if name == "" || email == "" {
failed++
continue
}
if _, err := stmt.Exec(name, email, city); err != nil {
fmt.Fprintf(os.Stderr, "line %d insert failed: %v\n", lineNumber, err)
failed++
continue
}
succeeded++
// Commit every 1000 rows to avoid an overly large transaction
if succeeded%1000 == 0 {
if err := tx.Commit(); err != nil {
return fmt.Errorf("commit: %w", err)
}
tx, _ = db.Begin()
stmt, _ = tx.Prepare(
"INSERT INTO users (name, email, city) VALUES ($1, $2, $3) " +
"ON CONFLICT (email) DO UPDATE SET name=$1, city=$3")
fmt.Fprintf(os.Stderr, "Progress: %d succeeded\n", succeeded)
}
}
if err := tx.Commit(); err != nil {
return fmt.Errorf("final commit: %w", err)
}
fmt.Printf("Import done: %d succeeded, %d failed\n", succeeded, failed)
return nil
}
Exporting from a Database to CSV #
func exportUsersToCSV(db *sql.DB, w io.Writer) error {
rows, err := db.Query(
"SELECT id, name, email, city, created_at FROM users ORDER BY id")
if err != nil {
return fmt.Errorf("query: %w", err)
}
defer rows.Close()
writer := csv.NewWriter(w)
// Write the header
writer.Write([]string{"id", "name", "email", "city", "registered_date"})
for rows.Next() {
var id int
var name, email, city string
var createdAt time.Time
if err := rows.Scan(&id, &name, &email, &city, &createdAt); err != nil {
fmt.Fprintf(os.Stderr, "scan error: %v\n", err)
continue
}
writer.Write([]string{
strconv.Itoa(id),
name,
email,
city,
createdAt.Format("2006-01-02"),
})
}
writer.Flush()
if err := rows.Err(); err != nil {
return fmt.Errorf("rows iteration: %w", err)
}
return writer.Error()
}
When to Switch to Alternatives #
Keep using encoding/csv if:
✓ Reading and writing standard CSV (RFC 4180)
✓ CSV from Excel, Google Sheets, or other common systems
✓ Simple CSV with a comma or tab delimiter
✓ Small to medium CSV files
✓ Importing/exporting data to a database
Consider manual parsing with bufio.Scanner if:
✗ Very simple CSV without any quoting at all
✗ The format isn't RFC 4180 compliant and LazyQuotes isn't enough
✗ You need full control over parsing every character
Consider external libraries if:
✗ CSV with millions of rows → gocsv for automatic struct mapping
✗ Automatic column type inference (int, float, bool, date)
→ csvutil, gocsv
✗ Per-column schema validation
✗ Parallel processing of large CSVs
✗ Excel (.xlsx) rather than CSV → github.com/qax-os/excelize
Consider encoding/json if:
✗ Data exchange between services — JSON is more expressive for hierarchical data
✗ Data with complex types (nested, arrays)
Summary #
reader.Read()returnsio.EOFwhen finished — this isn’t an error; handle it separately withif err == io.EOF { break }.- The
csv.Writeruses an internal buffer — always callwriter.Flush()when done, and checkwriter.Error()to learn if there was an error during buffering.encoding/csvhandles edge cases automatically — fields with commas, quotes, and newlines are quoted and escaped correctly per RFC 4180.reader.TrimLeadingSpace = truefor CSV with spaces after the delimiter — common in human-created CSV files or those exported from spreadsheets.reader.LazyQuotes = truefor tolerating imperfect CSV — useful for files from legacy systems that don’t fully follow RFC 4180.reader.FieldsPerRecord = -1for CSV with an inconsistent field count — the default (0) uses the first row as the reference and errors if other rows differ.reader.ReuseRecord = truefor maximum performance — but save a copy withcopy()if you want to keep the record, because the original slice is overwritten on the next iteration.- Build a header → index map when reading CSV with headers — more robust than accessing
record[0],record[1]hardcoded if the column order changes.- Commit the database in batches when importing large CSVs — don’t use one transaction for millions of rows; commit every N rows to avoid an overly large transaction.