Automate the Boring Stuff with Go, Ch. 12: CSV and JSON
Part of Automate the Boring Stuff with Go, a beginner-friendly guide to programming in Go.
New in the 2026 edition.
Excel files and PDFs are what people hand you. CSV and JSON are what programs hand each other. CSV is a spreadsheet with all the formatting removed: one row per line, commas between the cells. JSON is how nearly every web API and configuration file speaks. Both are in Go’s standard library, and after chapter 11 you have already met JSON without being told.
The code is under ch12.
CSV
encoding/csv reads and writes rows as []string. That is the whole interface, and the two things it does for you are the two things that go wrong when people split on commas by hand: a comma inside quotes, and a quote inside quotes.
// read a CSV file with the standard library, then write one
package main
import (
"encoding/csv"
"fmt"
"os"
"strings"
)
func main() {
data := "name,city,age\nAda,London,36\nLinus,Helsinki,54\n\"Grace, Rear Admiral\",Arlington,85\n"
// csv.Reader handles quotes and commas inside quotes for you
r := csv.NewReader(strings.NewReader(data))
rows, err := r.ReadAll()
if err != nil {
fmt.Fprintln(os.Stderr, "error:", err)
os.Exit(1)
}
header, body := rows[0], rows[1:]
fmt.Println("columns:", header)
for _, row := range body {
fmt.Printf("%s lives in %s\n", row[0], row[1])
}
// csv.Writer quotes whatever needs quoting
w := csv.NewWriter(os.Stdout)
w.Write([]string{"name", "note"})
w.Write([]string{"Ada", "wrote the first program, in 1843"})
w.Flush()
if err := w.Error(); err != nil {
fmt.Fprintln(os.Stderr, "error:", err)
os.Exit(1)
}
}
Everything comes back as a string, like Excel in chapter 9. Numbers need strconv, dates need time.Parse (next chapter), and the error from each conversion is where you find out that row 4,812 says “n/a”.
JSON
encoding/json goes between Go values and JSON text. The usual way is a struct with tags:
// a struct in, JSON out, and back again
package main
import (
"encoding/json"
"fmt"
"os"
)
// The tags say what each field is called in JSON. Without a tag, the field name is used
// as written, capital letter and all. Fields that start with a lower-case letter are
// private and never make it into JSON.
type Book struct {
Title string `json:"title"`
Authors []string `json:"authors"`
Year int `json:"year"`
Pages int `json:"pages,omitempty"` // left out of the output when zero
internal string // unexported: ignored by encoding/json
}
func main() {
b := Book{Title: "The Go Programming Language", Authors: []string{"Donovan", "Kernighan"}, Year: 2015}
out, err := json.MarshalIndent(b, "", " ")
if err != nil {
fmt.Fprintln(os.Stderr, "error:", err)
os.Exit(1)
}
fmt.Println(string(out))
in := `{"title":"Automate the Boring Stuff with Go","authors":["Toul Cranmer"],"year":2026,"extra":"ignored"}`
var got Book
if err := json.Unmarshal([]byte(in), &got); err != nil {
fmt.Fprintln(os.Stderr, "error:", err)
os.Exit(1)
}
fmt.Printf("%+v\n", got)
}
The tag json:"title" says the field is called title in the JSON. omitempty leaves a field out when it is the zero value. A field that starts with a lower-case letter is invisible to the package, which is the single most common “why is my JSON empty” bug. Going the other way, Unmarshal fills the fields it recognises and silently ignores the rest, which is exactly what you want when an API adds a field next year.
JSON you did not design
Sometimes you do not know the shape in advance, or you only want one value out of a big document. Decode into any and walk it:
// JSON whose shape you do not know in advance: decode into any and look around
package main
import (
"encoding/json"
"fmt"
"os"
)
func main() {
raw := `{"name":"xkcd","comics":[{"num":1,"title":"Barrel"},{"num":2,"title":"Petit Trees"}],"active":true}`
var v any
if err := json.Unmarshal([]byte(raw), &v); err != nil {
fmt.Fprintln(os.Stderr, "error:", err)
os.Exit(1)
}
// objects become map[string]any, arrays []any, numbers float64, and you assert as you go
obj := v.(map[string]any)
fmt.Println("name:", obj["name"])
for _, c := range obj["comics"].([]any) {
comic := c.(map[string]any)
fmt.Printf("#%.0f %s\n", comic["num"], comic["title"])
}
if active, ok := obj["active"].(bool); ok && active {
fmt.Println("still publishing")
}
}
Objects become map[string]any, arrays become []any, and every number becomes a float64, which is why the comic number prints with %.0f. The type assertions, v.(map[string]any), are you telling Go what you expect to find; the two-value form active, ok := ... lets you check instead of crashing when you are wrong. This is fine for a quick script. For anything you will run twice, a struct is clearer.
Remove the header from CSV files
The first project is the one from the Python original: a folder of CSV files, each with a header row a downstream tool cannot cope with. Strip it from every file, without touching the originals.
// csv-remove-header strips the first row from every CSV file in a folder and writes the
// result to a headerRemoved/ sub-folder, leaving the originals alone. It is the job you
// get when a tool insists on a header row and the file you were given has one you do not
// want merged into the data.
//
// go run ./ch12/projects/csv-remove-header ./ch12/projects/csv-remove-header/sample
package main
import (
"encoding/csv"
"errors"
"fmt"
"os"
"path/filepath"
"strings"
)
// StripHeader reads src as CSV and writes every row but the first to dst. It returns the
// number of rows written.
func StripHeader(src, dst string) (int, error) {
in, err := os.Open(src)
if err != nil {
return 0, err
}
defer in.Close()
rows, err := csv.NewReader(in).ReadAll()
if err != nil {
return 0, fmt.Errorf("parse %s: %w", src, err)
}
if len(rows) == 0 {
return 0, nil
}
out, err := os.Create(dst)
if err != nil {
return 0, err
}
w := csv.NewWriter(out)
if err := w.WriteAll(rows[1:]); err != nil {
out.Close()
return 0, fmt.Errorf("write %s: %w", dst, err)
}
return len(rows) - 1, out.Close()
}
// ProcessFolder strips every *.csv directly inside folder into folder/headerRemoved.
func ProcessFolder(folder string) (map[string]int, error) {
outDir := filepath.Join(folder, "headerRemoved")
if err := os.MkdirAll(outDir, 0o755); err != nil {
return nil, err
}
matches, err := filepath.Glob(filepath.Join(folder, "*.csv"))
if err != nil {
return nil, err
}
done := map[string]int{}
for _, src := range matches {
name := filepath.Base(src)
if !strings.HasSuffix(strings.ToLower(name), ".csv") {
continue
}
n, err := StripHeader(src, filepath.Join(outDir, name))
if err != nil {
return done, err
}
done[name] = n
}
return done, nil
}
func run(args []string) error {
if len(args) != 1 {
return errors.New("usage: csv-remove-header <folder>")
}
done, err := ProcessFolder(args[0])
if err != nil {
return err
}
for name, n := range done {
fmt.Printf("%s: %d data row(s) kept\n", name, n)
}
fmt.Printf("wrote %d file(s) to %s\n", len(done), filepath.Join(args[0], "headerRemoved"))
return nil
}
func main() {
if err := run(os.Args[1:]); err != nil {
fmt.Fprintln(os.Stderr, "error:", err)
os.Exit(1)
}
}
$ go run ./ch12/projects/csv-remove-header ./ch12/projects/csv-remove-header/sample
april.csv: 1 data row(s) kept
march.csv: 3 data row(s) kept
wrote 2 file(s) to ch12/projects/csv-remove-header/sample/headerRemoved
Reading the whole file with ReadAll and writing with WriteAll keeps the code short, and the quoting survives the round trip: "book, used" comes out quoted again because the writer knows it must be.
CSV to typed JSON
The second project turns an expenses CSV into JSON with real numbers in it, plus a summary by category. It finds columns by header name, so the CSV can have its columns in any order.
// csv-to-json turns an expenses CSV into typed JSON: each row becomes a struct, the
// numbers become numbers, and a summary by category comes along for free.
//
// go run ./ch12/projects/csv-to-json ./ch12/projects/csv-to-json/expenses.csv
package main
import (
"encoding/csv"
"encoding/json"
"errors"
"fmt"
"io"
"os"
"strconv"
"strings"
)
// Expense is one row. The tags choose the JSON names.
type Expense struct {
Date string `json:"date"`
Category string `json:"category"`
Item string `json:"item"`
Amount float64 `json:"amount"`
}
// Report is what we write out.
type Report struct {
Expenses []Expense `json:"expenses"`
ByCategory map[string]float64 `json:"by_category"`
Total float64 `json:"total"`
}
// ReadExpenses parses CSV with the header date,category,item,amount in any column order.
func ReadExpenses(r io.Reader) ([]Expense, error) {
rows, err := csv.NewReader(r).ReadAll()
if err != nil {
return nil, err
}
if len(rows) == 0 {
return nil, errors.New("empty file")
}
col := map[string]int{}
for i, name := range rows[0] {
col[strings.ToLower(strings.TrimSpace(name))] = i
}
for _, need := range []string{"date", "category", "item", "amount"} {
if _, ok := col[need]; !ok {
return nil, fmt.Errorf("missing column %q", need)
}
}
var out []Expense
for n, row := range rows[1:] {
amount, err := strconv.ParseFloat(strings.TrimSpace(row[col["amount"]]), 64)
if err != nil {
return nil, fmt.Errorf("row %d: amount %q: %w", n+2, row[col["amount"]], err)
}
out = append(out, Expense{
Date: row[col["date"]],
Category: row[col["category"]],
Item: row[col["item"]],
Amount: amount,
})
}
return out, nil
}
// Summarize builds the report from the rows.
func Summarize(expenses []Expense) Report {
rep := Report{Expenses: expenses, ByCategory: map[string]float64{}}
for _, e := range expenses {
rep.ByCategory[e.Category] += e.Amount
rep.Total += e.Amount
}
return rep
}
func run(args []string) error {
if len(args) != 1 {
return errors.New("usage: csv-to-json <expenses.csv>")
}
f, err := os.Open(args[0])
if err != nil {
return err
}
defer f.Close()
expenses, err := ReadExpenses(f)
if err != nil {
return fmt.Errorf("%s: %w", args[0], err)
}
out := strings.TrimSuffix(args[0], ".csv") + ".json"
data, err := json.MarshalIndent(Summarize(expenses), "", " ")
if err != nil {
return err
}
if err := os.WriteFile(out, data, 0o644); err != nil {
return err
}
fmt.Printf("%d expense(s), total %.2f, wrote %s\n", len(expenses), Summarize(expenses).Total, out)
return nil
}
func main() {
if err := run(os.Args[1:]); err != nil {
fmt.Fprintln(os.Stderr, "error:", err)
os.Exit(1)
}
}
$ go run ./ch12/projects/csv-to-json ./ch12/projects/csv-to-json/expenses.csv
5 expense(s), total 61.25, wrote ./ch12/projects/csv-to-json/expenses.json
Look at the error from a bad amount: row 2: amount "lots": strconv.ParseFloat .... The row number and the offending text are in the message because the person fixing the spreadsheet needs both. The test file next to the project feeds in columns in a scrambled order and a bad amount, and checks for exactly that.
A tiny jq
The third project pulls one value out of a JSON document by a dotted path, or pretty-prints the whole thing. It reads a file or standard input, so it chains with curl and with the other programs in this book.
// json-get pulls one value out of a JSON document by path, the way you would with jq,
// and pretty-prints the whole document when you give it no path. It reads a file or
// standard input, so it chains with the other tools in this book.
//
// go run ./ch12/projects/json-get ./ch9/projects/census-by-county/census_summary.json
// curl -s https://xkcd.com/info.0.json | go run ./ch12/projects/json-get -path title
// go run ./ch12/projects/json-get -path comics.1.title ./ch12/projects/json-get/sample.json
package main
import (
"encoding/json"
"errors"
"flag"
"fmt"
"io"
"os"
"strconv"
"strings"
)
// Get walks v along a dotted path. Object keys are names; array elements are indexes.
func Get(v any, path string) (any, error) {
if path == "" {
return v, nil
}
for _, part := range strings.Split(path, ".") {
switch node := v.(type) {
case map[string]any:
next, ok := node[part]
if !ok {
return nil, fmt.Errorf("no key %q", part)
}
v = next
case []any:
i, err := strconv.Atoi(part)
if err != nil || i < 0 || i >= len(node) {
return nil, fmt.Errorf("no element %q in an array of %d", part, len(node))
}
v = node[i]
default:
return nil, fmt.Errorf("cannot descend into %T with %q", v, part)
}
}
return v, nil
}
// Render prints strings bare and everything else as indented JSON, which is what you
// want when the result goes straight into another command.
func Render(v any) (string, error) {
if s, ok := v.(string); ok {
return s, nil
}
out, err := json.MarshalIndent(v, "", " ")
return string(out), err
}
func run(args []string, stdin io.Reader) error {
flags := flag.NewFlagSet("json-get", flag.ContinueOnError)
path := flags.String("path", "", "dotted path, e.g. comics.1.title (empty = whole document)")
if err := flags.Parse(args); err != nil {
return err
}
var in io.Reader = stdin
switch flags.NArg() {
case 0:
case 1:
f, err := os.Open(flags.Arg(0))
if err != nil {
return err
}
defer f.Close()
in = f
default:
return errors.New("usage: json-get [-path a.b.0] [file.json]")
}
var v any
if err := json.NewDecoder(in).Decode(&v); err != nil {
return fmt.Errorf("parse JSON: %w", err)
}
got, err := Get(v, *path)
if err != nil {
return err
}
s, err := Render(got)
if err != nil {
return err
}
fmt.Println(s)
return nil
}
func main() {
if err := run(os.Args[1:], os.Stdin); err != nil {
fmt.Fprintln(os.Stderr, "error:", err)
os.Exit(1)
}
}
$ go run ./ch12/projects/json-get -path comics.1.title ./ch12/projects/json-get/sample.json
Petit Trees
$ curl -s https://xkcd.com/info.0.json | go run ./ch12/projects/json-get -path title
Get is the dynamic-JSON walk from the example, with error messages that say where the path stopped making sense. Render prints a string bare and everything else as JSON, so json-get -path title can feed straight into another command. The real jq does a thousand things more; the point is that the first ten lines of it are an afternoon in Go.
Conclusion
CSV in and out with encoding/csv, JSON in and out with struct tags, any for the cases you cannot plan for. Combined with chapter 9 you can now move data between spreadsheets, web APIs and other programs, which is most of what “integration” means in an office.