JavaRush /Courses /Go SELF /encoding/csv — reading and writing via Reader/Writer

encoding/csv — reading and writing via Reader/Writer

Go SELF
Level 47, Lesson 1
Available

1. Why encoding/csv instead of strings.Split

CSV looks so simple that a beginner’s brain (and sometimes even an experienced developer on a Friday evening) suggests: “it’s just lines separated by \n, and fields separated by , — I’ll just do strings.Split real quick.” It’s a wonderful thought… until you meet a comma inside quotes, a newline inside a field, or a quote " inside the value itself. CSV is a format with rules, not “text with commas.”

In Go, the right path is almost always the same: the encoding/csv package. It already knows the CSV rules, can correctly handle quotes and escaping, and most importantly, it works on top of io.Reader/io.Writer, which means it reads equally well from a file, a string, the network, a buffer (and even from “a stream you haven’t invented yet”).

To avoid being vague, here’s a mini anti-example. It compiles, looks logical, but breaks on real-world CSV:

package main

import (
	"fmt"
	"strings"
)

func main() {
	line := `1,"Buy milk, please",false`
	parts := strings.Split(line, ",")
	fmt.Println(parts) // [1 "Buy milk  please" false]  (shifted!)
}

Yes, the quotes will also “stick” in the result, and the field will split into two parts. In short: parse CSV with a CSV parser—not because “it’s trendy,” but because we want to sleep well.

2. csv.Reader: reading CSV from io.Reader

When you first see csv.NewReader(r), you might think: “why not csv.ReadFile("x.csv")?” This is where Go is quite consistent: almost all I/O is built around streams. This gives flexibility: the same code works with files, strings, HTTP responses, and test buffers. It’s like writing a “vacuum function”: it doesn’t care where the dust comes from—only that there’s a hose.

csv.Reader reads CSV as a sequence of records, where each record is a []string (fields). Main methods:

  • Read() — read one record.
  • ReadAll() — read all records at once.

The shortest example: ReadAll()

ReadAll() is convenient when the CSV is small and you’re not afraid of memory usage. For training tasks and “task export/import with 200 lines,” that’s fine. For a 4 GB CSV—no (and your laptop will start suspecting you of betrayal).

package main

import (
	"encoding/csv"
	"fmt"
	"strings"
)

func main() {
	data := "id,title,done\n1,Buy milk,false\n2,Learn Go,true\n"

	r := csv.NewReader(strings.NewReader(data))
	records, err := r.ReadAll()
	if err != nil {
		fmt.Println("read all:", err)
		return
	}

	fmt.Println(records) // [[id title done] [1 Buy milk false] [2 Learn Go true]]
}

Note: the result is [][]string. That is, a “table of strings.” Next, your job is to convert strings into your types (for example, int and bool). But conversion and validation are separate logic; today we keep the focus specifically on Reader/Writer.

Streaming mode: Read() + loop until io.EOF

Streaming reads mean you read “one record at a time” in a loop. It’s a bit more verbose, but memory-friendly and works well for large files.

Key point: io.EOF is a normal end-of-read, not a “format error.” On EOF we end the loop.

package main

import (
	"encoding/csv"
	"fmt"
	"io"
	"strings"
)

func main() {
	data := "a,b\nc,d\n"
	r := csv.NewReader(strings.NewReader(data))

	for {
		rec, err := r.Read()
		if err == io.EOF {
			break
		}
		if err != nil {
			fmt.Println("read:", err)
			return
		}
		fmt.Println("record:", rec) // record: [a b] then record: [c d]
	}
}

You’ll see this “Read() → EOF → break” pattern many times in Go. It’s simple and predictable, like a stool.

csv.Reader settings: Comma, TrimLeadingSpace, FieldsPerRecord

Real-world CSV often differs from the “ideal” one in examples. That’s why csv.Reader has settings. Understanding what they mean is more important than memorizing names.

Delimiter: Comma

Yes, the field is called Comma even if you use ;. The logic is simple: historically CSV means “comma-separated values,” and then people decided “let’s make comma a decimal separator in some countries”—and off we went.

package main

import (
	"encoding/csv"
	"fmt"
	"strings"
)

func main() {
	data := "id;title;done\n1;Buy milk;false\n"
	r := csv.NewReader(strings.NewReader(data))
	r.Comma = ';'

	rec, _ := r.Read()
	fmt.Println(rec) // [id title done]
}

Spaces: TrimLeadingSpace

Sometimes data looks like this: 1, Buy milk, false. And the space after the comma isn’t part of the value—someone just “made it pretty.” TrimLeadingSpace = true can help (but this is a policy: sometimes the space is meaningful).

Number of fields: FieldsPerRecord

A very useful thing: when you expect each line to have, say, 3 fields, you can make the parser complain if it suddenly gets 2 or 4. It’s an early warning that “the CSV went off the rails.”

The idea is: after you read the header, you can set FieldsPerRecord = len(header)—then any crooked line will surface immediately, not somewhere deep in your code.

3. csv.Writer: writing CSV to io.Writer

Writing CSV by “manually concatenating strings” is like fixing a watch with a hammer: sometimes it shows the time, but you’d better not look too closely. CSV has quoting and escaping rules. csv.Writer does this for you, and that’s the main point of its existence.

Minimal CSV write to a buffer

In this example, we write CSV into a bytes.Buffer, then print the result. This is convenient for tests and for understanding what actually goes “out.”

package main

import (
	"bytes"
	"encoding/csv"
	"fmt"
)

func main() {
	var buf bytes.Buffer
	w := csv.NewWriter(&buf)

	_ = w.Write([]string{"id", "title", "done"})
	_ = w.Write([]string{"1", "Buy milk", "false"})

	w.Flush()
	if err := w.Error(); err != nil {
		fmt.Println("csv error:", err)
		return
	}

	fmt.Print(buf.String())
	// id,title,done
	// 1,Buy milk,false
}

There’s an important rule here (it’s worth mentally engraving it): after writing, always call Flush() and check w.Error().

Why? Because writing may be buffered and an error might not appear immediately. This is a typical pattern for buffered writers in Go: “write many times → check once on finalization.” You can see a similar idea elsewhere in the standard library.

CSV escapes quotes and commas automatically

Let’s test a field that contains quotes and commas. Assembling this manually is guaranteed to make you slip up at least once in your life. But csv.Writer handles it calmly.

package main

import (
	"bytes"
	"encoding/csv"
	"fmt"
)

func main() {
	var buf bytes.Buffer
	w := csv.NewWriter(&buf)

	_ = w.Write([]string{"note"})
	_ = w.Write([]string{`He said "hi", then left`})

	w.Flush()
	_ = w.Error()

	fmt.Print(buf.String())
	// note
	// "He said ""hi"", then left"
}

Note: the quote " inside the value becomes ""—this is the standard CSV escaping. And the whole field is wrapped in quotes, because inside it there are both " and ,.

csv.Writer settings: Comma and UseCRLF

The writer has settings too.

Comma — same meaning: which delimiter to use when writing.

UseCRLF — write lines with \r\n instead of \n. Sometimes this is needed for compatibility with systems that expect Windows-style newlines. Most modern pipelines are fine with \n, but it’s useful to know this knob exists.

About Flush() and why it’s not “optional cosmetics”

A very common beginner thought: “I already called Write, so it must be written.” In reality, many writers buffer data: they accumulate it in memory to write in chunks. This is faster and friendlier to the system. The cost is finalization.

csv.Writer buffers output, so the correct protocol looks like: “do many Write calls → call Flush → check the error.” This style is similar to how buffered writers in the standard library work: an error may show up at the final buffer flush.

If you forget Flush(), you may end up with a file where “it looks like something is missing,” and this is one of the most annoying bugs: the program seems to have “run successfully,” but the data disappeared.

4. Importing and exporting tasks via CSV

Now we’ll put everything together into a small piece of code. Imagine we already have a simple tasks app (a conditional todo), and we want to export tasks to CSV and import them back.

We are not doing an “ideal production-grade import” with reports and tons of checks—that’s a separate story. The goal here is simpler: learn to use encoding/csv as a transport, not as a puzzle.

First, define the task model:

package main

type Task struct {
	ID    int
	Title string
	Done  bool
}

Export: WriteTasksCSV(w io.Writer, tasks []Task) error

The idea of the function: it doesn’t know where it writes (to a file, to the network, to a buffer)—it gets an io.Writer. Very Go-like.

package main

import (
	"encoding/csv"
	"fmt"
	"io"
	"strconv"
)

func WriteTasksCSV(out io.Writer, tasks []Task) error {
	w := csv.NewWriter(out)

	if err := w.Write([]string{"id", "title", "done"}); err != nil {
		return fmt.Errorf("write header: %w", err)
	}

	for _, t := range tasks {
		rec := []string{
			strconv.Itoa(t.ID),
			t.Title,
			strconv.FormatBool(t.Done),
		}
		if err := w.Write(rec); err != nil {
			return fmt.Errorf("write task id=%d: %w", t.ID, err)
		}
	}

	w.Flush()
	if err := w.Error(); err != nil {
		return fmt.Errorf("flush csv: %w", err)
	}
	return nil
}

Here we wrap errors with %w so we don’t lose the root cause while still adding context about “where exactly it failed.”

And again we see the important ritual: Flush() → w.Error().

Import: ReadTasksCSV(r io.Reader) ([]Task, error)

Now we read CSV and get []Task. We’ll make a version that expects a header id,title,done and then reads the rows.

package main

import (
	"encoding/csv"
	"fmt"
	"io"
	"strconv"
)

func ReadTasksCSV(in io.Reader) ([]Task, error) {
	r := csv.NewReader(in)

	header, err := r.Read()
	if err != nil {
		return nil, fmt.Errorf("read header: %w", err)
	}
	_ = header // we are not deeply validating the header yet

	var tasks []Task
	for {
		rec, err := r.Read()
		if err == io.EOF {
			break
		}
		if err != nil {
			return nil, fmt.Errorf("read record: %w", err)
		}

		id, err := strconv.Atoi(rec[0])
		if err != nil {
			return nil, fmt.Errorf("parse id %q: %w", rec[0], err)
		}
		done, err := strconv.ParseBool(rec[2])
		if err != nil {
			return nil, fmt.Errorf("parse done %q: %w", rec[2], err)
		}

		tasks = append(tasks, Task{ID: id, Title: rec[1], Done: done})
	}
	return tasks, nil
}

Yes, there is a vulnerability here: we don’t check the length of rec, and on a malformed row we’ll get an index panic. In this lecture I leave it as a “clear skeleton,” not as a “production import that survives the apocalypse.” But even in this form the main point is visible: CSV parsing is done by csv.Reader, while type conversion and basic checks are on us.

Quick “wired” example: wrote → read back

To feel that everything really works, you can run it in memory via bytes.Buffer.

package main

import (
	"bytes"
	"fmt"
)

func main() {
	tasks := []Task{
		{ID: 1, Title: `Buy milk, please`, Done: false},
		{ID: 2, Title: `Learn "Go"`, Done: true},
	}

	var buf bytes.Buffer
	_ = WriteTasksCSV(&buf, tasks)

	loaded, _ := ReadTasksCSV(&buf)
	fmt.Println(loaded)
	// [{1 Buy milk, please false} {2 Learn "Go" true}]
}

What’s especially nice here is that quotes and commas inside Title don’t break the format—because csv.Writer and csv.Reader handle that, not your manual string magic.

Data flow: Reader/Writer and CSV

Sometimes it’s useful to pause and see the big picture. In Go, code is often designed so that the format (CSV) is just a “nozzle” on top of a read/write stream. You change the source/sink, but you don’t rewrite the business logic.

flowchart TD
    A["Data source<br/>file / string / network"] --> B["io.Reader"]
    B --> C["csv.NewReader"]
    C --> D["[]string records"]
    D --> E["parse -> Task"]
    E --> F["[]Task"]

    F --> G["format -> []string"]
    G --> H["csv.NewWriter"]
    H --> I["io.Writer"]
    I --> J["Sink<br/>file / buffer / stdout"]

If you read it as a story: “we have an io.Reader, put a csv.Reader on top, get records, convert to Task.” And for export, we go the other way around.

5. Typical mistakes when working with encoding/csv

Mistake #1: parsing CSV with strings.Split or a line-by-line Scanner and “splitting by commas.”
This code looks heroic at first, then it meets quotes/commas inside fields, and heroism turns into bug archaeology. CSV should be read with csv.Reader and written with csv.Writer, because they implement the format rules, not “something roughly similar.”

Mistake #2: forgetting Flush() and not checking w.Error().
A classic: you honestly called Write, but the file is empty or truncated. The reason is simple: some data was sitting in the buffer and wasn’t flushed, or the write error only surfaced on finalization. The habit of “Flush() + Error() always” makes exports much more reliable.

Mistake #3: not handling io.EOF separately and treating it as a “read error.”
In streaming reads, io.EOF is a normal signal that data has ended. If you don’t put it in a separate branch and don’t do break, you’ll either print “error: EOF” or break the loop entirely.

Mistake #4: immediately accessing rec[0], rec[1], rec[2] without checking the record length.
With training data you can get away with it, but in real imports any malformed row can lead to a panic due to an out-of-bounds index. Even if you’re not building complex validation yet, a minimal “how many fields did we get” check saves hours of debugging.

Mistake #5: losing error context and returning just err.
When reading fails on the 736th record, the message “invalid syntax” is not inspiring. It’s better to wrap errors with fmt.Errorf("...: %w", err) so it’s clear at which step and with which value the problem occurred. This style doesn’t make the code “prettier for the sake of pretty”—it makes maintenance possible.

1
Task
Go SELF, level 47, lesson 1
Locked
Checklist test
Checklist test
1
Task
Go SELF, level 47, lesson 1
Locked
Streaming directory
Streaming directory
1
Task
Go SELF, level 47, lesson 1
Locked
Export a note
Export a note
1
Task
Go SELF, level 47, lesson 1
Locked
Products round-trip
Products round-trip
Comments
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION