Go CSV - read and write CSV
last modified October 30, 2026
In this article we show how to read and write CSV data in Go with the
encoding/csv package from the standard library.
CSV (comma-separated values) stores a table as text: every line is a record,
and the fields of a record are separated by a delimiter. The format looks
simple, but fields can contain the delimiter, quotes or line breaks, so parsing
it with strings.Split breaks quickly. The
encoding/csv reader handles quoting, doubled quotes, CRLF line
endings, comments and configurable delimiters.
All examples in this article were compiled and run with Go 1.27.0.
Reading all records
ReadAll reads the whole file and returns a slice of records, where
each record is a []string. It is the simplest choice for small
files.
1,2,3,4,5 6,7,8,9,10
The file contains two records with five fields each. The fields are separated by commas and the last line ends with a newline.
package main
import (
"encoding/csv"
"fmt"
"log"
"os"
)
func main() {
f, err := os.Open("numbers.csv")
if err != nil {
log.Fatal(err)
}
defer f.Close()
r := csv.NewReader(f)
records, err := r.ReadAll()
if err != nil {
log.Fatal(err)
}
for _, record := range records {
fmt.Println(record, len(record))
}
}
The reader returns the fields as strings, without converting them to numbers. The printed length confirms that both records have five fields.
$ go run main.go [1 2 3 4 5] 5 [6 7 8 9 10] 5
Reading one record at a time
Read returns the next record and io.EOF at the end of
the file. This streaming form keeps only one record in memory and works for
files of any size.
package main
import (
"encoding/csv"
"fmt"
"io"
"log"
"os"
)
func main() {
f, err := os.Open("numbers.csv")
if err != nil {
log.Fatal(err)
}
defer f.Close()
r := csv.NewReader(f)
for {
record, err := r.Read()
if err == io.EOF {
break
}
if err != nil {
log.Fatal(err)
}
fmt.Println("record:", record)
}
}
The loop stops at io.EOF; any other error is a real problem and is
reported with log.Fatal. The records are the same as in the
previous example.
$ go run main.go record: [1 2 3 4 5] record: [6 7 8 9 10]
Reading into structs
Real programs convert the string fields to typed values. This example skips the
header row, parses the age with strconv.Atoi and builds a slice of
Person values.
name,age,city Jan,45,Bratislava Lucia,32,Kosice Peter,28,Zilina
The first record is a header, and the age column contains numbers that must be converted.
package main
import (
"encoding/csv"
"fmt"
"log"
"os"
"strconv"
)
type Person struct {
Name string
Age int
City string
}
func main() {
f, err := os.Open("people.csv")
if err != nil {
log.Fatal(err)
}
defer f.Close()
r := csv.NewReader(f)
records, err := r.ReadAll()
if err != nil {
log.Fatal(err)
}
var people []Person
for i, record := range records {
if i == 0 {
continue
}
age, err := strconv.Atoi(record[1])
if err != nil {
log.Fatal(err)
}
people = append(people, Person{
Name: record[0],
Age: age,
City: record[2],
})
}
fmt.Println(people)
fmt.Println(people[1].Name, people[1].Age)
}
The header is skipped by its index, and every later record becomes a struct.
Indexing a record out of range would panic, so a fixed column layout should be
validated with FieldsPerRecord or explicit length checks.
$ go run main.go
[{Jan 45 Bratislava} {Lucia 32 Kosice} {Peter 28 Zilina}]
Lucia 32
Writing records
csv.Writer writes records through a buffered writer. After the last
record, Flush pushes the remaining data to the file and
Error reports any write error.
package main
import (
"encoding/csv"
"fmt"
"log"
"os"
)
func main() {
f, err := os.Create("out.csv")
if err != nil {
log.Fatal(err)
}
defer f.Close()
w := csv.NewWriter(f)
records := [][]string{
{"name", "age", "city"},
{"Jan", "45", "Bratislava"},
{"Lucia", "32", "Kosice"},
}
for _, record := range records {
if err := w.Write(record); err != nil {
log.Fatal(err)
}
}
w.Flush()
if err := w.Error(); err != nil {
log.Fatal(err)
}
fmt.Println("wrote", len(records), "records")
}
The file starts with a header row and contains one line per record. The writer adds the newline after each record and quotes fields only when necessary.
$ go run main.go $ cat out.csv wrote 3 records name,age,city Jan,45,Bratislava Lucia,32,Kosice
Quoting and escaping
Fields that contain a comma, a quote or a line break are written inside double quotes. A double quote inside a field is doubled, which is the escaping rule of RFC 4180.
package main
import (
"encoding/csv"
"fmt"
"log"
"os"
)
func main() {
f, err := os.Create("quoted.csv")
if err != nil {
log.Fatal(err)
}
defer f.Close()
w := csv.NewWriter(f)
records := [][]string{
{"name", "note"},
{"Jan", "likes commas, semicolons; and quotes"},
{"Lucia", `she said "hello"`},
{"Peter", "first line\nsecond line"},
}
if err := w.WriteAll(records); err != nil {
log.Fatal(err)
}
fmt.Println("wrote", len(records), "records")
}
The second record is quoted because of the comma, the third because of the quotes, and the fourth spans two lines. The reader reverses the process and returns the original field values.
$ go run main.go $ cat quoted.csv wrote 4 records name,note Jan,"likes commas, semicolons; and quotes" Lucia,"she said ""hello""" Peter,"first line second line"
Another delimiter
The delimiter is configurable through the Comma field of the
reader. Setting it to a tab reads TSV (tab-separated values) data.
package main
import (
"encoding/csv"
"fmt"
"log"
"strings"
)
func main() {
data := "name\tage\nJan\t45\nLucia\t32\n"
r := csv.NewReader(strings.NewReader(data))
r.Comma = '\t'
records, err := r.ReadAll()
if err != nil {
log.Fatal(err)
}
for _, record := range records {
fmt.Println(record)
}
}
The data is read from a string instead of a file to keep the example short. The same setting works for any single rune, for example a semicolon.
$ go run main.go [name age] [Jan 45] [Lucia 32]
Comments in the file
The Comment field names a rune that starts a comment line. Comment
lines are skipped, which is handy for generated or annotated data files.
# generated data 1,2,3 4,5,6
The first line starts with a hash and is ignored by the reader.
package main
import (
"encoding/csv"
"fmt"
"log"
"os"
)
func main() {
f, err := os.Open("comments.csv")
if err != nil {
log.Fatal(err)
}
defer f.Close()
r := csv.NewReader(f)
r.Comment = '#'
records, err := r.ReadAll()
if err != nil {
log.Fatal(err)
}
fmt.Println(records)
}
Only the two data records are returned. The comment character must be a valid rune and must differ from the delimiter, and a comment cannot hide part of a multi-line field.
$ go run main.go [[1 2 3] [4 5 6]]
Checking the number of fields
By default every record must have the same number of fields as the first one. A shorter or longer record produces an error instead of a silently truncated row.
a,b,c 1,2
The second record has two fields while the header has three.
package main
import (
"encoding/csv"
"fmt"
"log"
"os"
)
func main() {
f, err := os.Open("broken.csv")
if err != nil {
log.Fatal(err)
}
defer f.Close()
r := csv.NewReader(f)
_, err = r.ReadAll()
if err != nil {
fmt.Println("error:", err)
}
}
The error names the line and the problem. Setting
FieldsPerRecord to a positive number enforces a fixed width, and
setting it to -1 disables the check.
$ go run main.go error: record on line 2: wrong number of fields
Lazy quotes
A bare quote in an unquoted field is a parse error by default. The
LazyQuotes option accepts such fields, which helps with files
produced by non-conforming tools.
package main
import (
"encoding/csv"
"fmt"
"strings"
)
func main() {
data := "name,note\nJan,bare \"quote\n"
r := csv.NewReader(strings.NewReader(data))
_, err := r.ReadAll()
fmt.Println("strict:", err)
r = csv.NewReader(strings.NewReader(data))
r.LazyQuotes = true
records, err := r.ReadAll()
fmt.Println("lazy:", records, err)
}
The strict reader rejects the file with the column of the offending quote. The lazy reader parses it and keeps the quote as part of the field value; the trade off is that malformed input is no longer detected.
$ go run main.go strict: parse error on line 2, column 10: bare " in non-quoted-field lazy: [[name note] [Jan bare "quote]] <nil>
Trimming leading space
Spaces are part of a field by default. TrimLeadingSpace removes
spaces before a field, which handles files padded for readability.
package main
import (
"encoding/csv"
"fmt"
"log"
"strings"
)
func main() {
data := "name, city\nJan, Bratislava\nLucia, Kosice\n"
r := csv.NewReader(strings.NewReader(data))
r.TrimLeadingSpace = true
records, err := r.ReadAll()
if err != nil {
log.Fatal(err)
}
fmt.Printf("%q\n", records)
}
The quoted output makes the difference visible: without the option the second column would start with a space. Trailing spaces are never removed.
$ go run main.go [["name" "city"] ["Jan" "Bratislava"] ["Lucia" "Kosice"]]
Reusing the record slice
ReuseRecord makes Read return the same slice on every
call. It reduces allocations, but a record kept from an earlier iteration is
overwritten by the next one.
package main
import (
"encoding/csv"
"fmt"
"strings"
)
func main() {
data := "1,2\n3,4\n"
r := csv.NewReader(strings.NewReader(data))
r.ReuseRecord = true
var first []string
for {
record, err := r.Read()
if err != nil {
break
}
if first == nil {
first = record
fmt.Println("first:", first)
continue
}
fmt.Println("first now:", first)
fmt.Println("current: ", record)
}
}
The saved first record shows the second line after the next read,
because both variables point to the same array. With
ReuseRecord enabled, copy a record before storing it.
$ go run main.go first: [1 2] first now: [3 4] current: [3 4]
Writer options
The writer has the same delimiter option as the reader, plus
UseCRLF for the Windows line ending. This example writes
semicolon-separated records with Unix line endings.
package main
import (
"encoding/csv"
"fmt"
"log"
"os"
)
func main() {
f, err := os.Create("semicolon.csv")
if err != nil {
log.Fatal(err)
}
defer f.Close()
w := csv.NewWriter(f)
w.Comma = ';'
w.UseCRLF = false
records := [][]string{
{"name", "age"},
{"Jan", "45"},
}
if err := w.WriteAll(records); err != nil {
log.Fatal(err)
}
fmt.Println("done")
}
The file uses semicolons between fields and \n after each record.
Setting UseCRLF to true would write \r\n instead.
$ go run main.go $ cat semicolon.csv done name;age Jan;45
Source
Go encoding/csv package - reference
RFC 4180 - Common Format and MIME Type for CSV Files
In this article we have shown how to read and write CSV in Go: reading all records or one at a time, converting fields into structs, writing and quoting records, changing the delimiter, skipping comments, validating the field count, handling malformed quotes, trimming spaces, reusing the record slice, and controlling the writer's line endings.
Author
List all Go tutorials.