1. Зачем encoding/csv, а не strings.Split
CSV выглядит настолько простым, что мозг новичка (и иногда даже опытного разработчика в пятницу вечером) предлагает: «да там же просто строки через \n, а поля через , — сейчас быстро сделаю strings.Split». Это прекрасная мысль… пока вы не встречаете запятую внутри кавычек, перевод строки внутри поля, или кавычку " в самом значении. CSV — формат с правилами, а не “текст с запятыми”.
В Go правильный путь почти всегда один: пакет encoding/csv. Он уже знает правила CSV, умеет корректно обрабатывать кавычки и экранирование, и самое главное — работает поверх io.Reader/io.Writer, а значит одинаково хорошо читает из файла, строки, сети, буфера (и даже из «потока, который вы ещё не придумали»).
Чтобы не быть голословными, вот мини-антипример. Он компилируется, выглядит логично, но ломается на реальном 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] (уехало!)
}
Да, кавычки в результате ещё и «прилипнут», а поле развалится на две части. Короче говоря: CSV парсим CSV-парсером — не потому что «так модно», а потому что мы хотим спать спокойно.
2. csv.Reader: чтение CSV из io.Reader
Когда вы впервые видите csv.NewReader(r), возникает ощущение: «а почему не csv.ReadFile("x.csv")?». И вот тут Go довольно последователен: почти всё I/O строится вокруг потоков. Это даёт гибкость: один и тот же код работает с файлами, строками, HTTP-ответами и тестовыми буферами. Вы как будто пишете «функцию-пылесос»: ей всё равно, откуда пыль, главное — чтобы была труба.
csv.Reader читает CSV как последовательность записей (records), где каждая запись — это []string (поля). Главные методы:
- Read() — прочитать одну запись.
- ReadAll() — прочитать все записи сразу.
Самый короткий пример: ReadAll()
ReadAll() удобен, когда CSV небольшой и вы точно не боитесь памяти. Для учебных задач и «экспорт/импорт задач на 200 строк» это нормально. Для CSV на 4 ГБ — уже нет (и ваш ноутбук начнёт подозревать вас в предательстве).
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]]
}
Обратите внимание: результат — это [][]string. То есть «таблица строк». Дальше ваша задача — превратить строки в ваши типы (например, int и bool). Но превращение и валидация — это отдельная логика; сегодня держим фокус именно на Reader/Writer.
Потоковый режим: Read() + цикл до io.EOF
Потоковое чтение — это когда вы читаете «по одной записи» в цикле. Это чуть более многословно, но зато устойчиво по памяти и хорошо подходит для больших файлов.
Ключевой момент: io.EOF — это нормальное завершение чтения, а не «ошибка формата». На EOF мы заканчиваем цикл.
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] потом record: [c d]
}
}
Этот шаблон «Read() → EOF → break» вы потом будете встречать много раз в Go. Он простой и предсказуемый, как табуретка.
Настройки csv.Reader: Comma, TrimLeadingSpace, FieldsPerRecord
Реальный CSV часто отличается от «идеального» из примеров. Поэтому у csv.Reader есть настройки. Их смысл важнее, чем запоминание названий.
Разделитель: Comma
Да, поле называется Comma, даже если вы используете ;. Логика тут простая: исторически CSV — “comma-separated values”, а потом люди решили «а давайте в некоторых странах запятая будет десятичным разделителем» — и понеслось.
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]
}
Пробелы: TrimLeadingSpace
Иногда данные выглядят так: 1, Buy milk, false. И пробел после запятой не является частью значения, просто кто-то «делал красиво». TrimLeadingSpace = true может помочь (но это именно политика: иногда пробел значим).
Количество полей: FieldsPerRecord
Очень полезная штука: когда вы ожидаете, что в каждой строке будет, скажем, 3 поля, можно сделать так, чтобы парсер ругался, если вдруг приехало 2 или 4. Это ранняя сигнализация «CSV поехал».
Идея такая: после того как вы прочитали заголовок (header), вы можете выставить FieldsPerRecord = len(header) — тогда любая кривая строка всплывёт сразу, а не где-нибудь в глубине вашего кода.
3. csv.Writer: запись CSV в io.Writer
Записывать CSV «ручной склейкой строк» — примерно как чинить часы молотком: иногда показывает время, но лучше не смотреть слишком внимательно. У CSV есть правила кавычек и экранирования. csv.Writer делает это за вас, и это главный смысл его существования.
Минимальная запись CSV в буфер
В этом примере мы пишем CSV в bytes.Buffer, а потом печатаем результат. Это удобно для тестов и для понимания, что вообще уезжает «наружу».
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
}
Тут есть важное правило (его лучше прямо мысленно выгравировать): после записи всегда делаем Flush() и проверяем w.Error().
Почему так? Потому что запись может буферизоваться и ошибка может проявиться не сразу. Это типичный паттерн для буферизированных writer’ов в Go: «пишем много раз → проверяем один раз на финализации». Похожую мысль вы можете встретить и в других местах стандартной библиотеки.
CSV сам экранирует кавычки и запятые
Проверим на поле, где есть кавычки и запятые. Вручную это собирать — гарантированно ошибиться хотя бы один раз в жизни. А csv.Writer спокойно справляется.
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"
}
Обратите внимание: кавычка " внутри значения превращается в "" — это стандартное CSV-экранирование. А всё поле берётся в кавычки целиком, потому что внутри есть и ", и ,.
Настройки csv.Writer: Comma и UseCRLF
У writer’а тоже есть настройки.
Comma — тот же смысл: какой разделитель использовать при записи.
UseCRLF — писать строки с \r\n вместо \n. Иногда это нужно для совместимости с некоторыми системами, которые ожидают «виндовый» перенос. Чаще всего в современных пайплайнах хватает \n, но полезно знать, что такая ручка есть.
Про Flush() и почему он не «необязательная косметика»
Очень частая мысль новичка: «Ну я же уже делал Write, значит оно записалось». В реальности многие writer’ы буферизуют данные: копят их в памяти, чтобы писать порциями. Это быстрее и удобнее для системы. Цена — необходимость финализации.
csv.Writer буферизует вывод, поэтому правильный протокол выглядит так: «делаем много Write → вызываем Flush → проверяем ошибку». Этот стиль похож на то, как в стандартной библиотеке устроены буферизированные writer’ы: ошибка может проявиться при финальном сбросе буфера.
Если Flush() забыть, вы можете получить файл, в котором «как будто чего-то не хватает», и это один из самых раздражающих багов: программа вроде бы “успешно отработала”, а данные исчезли.
4. Импорт и экспорт задач через CSV
Сейчас мы соберём всё в небольшой кусок кода. Представим, что у нас уже есть простое приложение задач (условный todo), и мы хотим экспортировать задачи в CSV и импортировать обратно.
Мы не делаем здесь «идеальный промышленный импорт» с отчётами и кучей проверок — это отдельная история. Сейчас цель проще: научиться подключать encoding/csv как транспорт, а не как головоломку.
Для начала определим модель задачи:
package main
type Task struct {
ID int
Title string
Done bool
}
Экспорт: WriteTasksCSV(w io.Writer, tasks []Task) error
Смысл функции: она не знает, куда пишет (в файл, в сеть, в буфер), ей дают io.Writer. Это очень по-гошному.
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
}
Здесь мы оборачиваем ошибки через %w, чтобы не терять причину и при этом добавлять контекст “где именно упало”.
И снова видим важный ритуал: Flush() → w.Error().
Импорт: ReadTasksCSV(r io.Reader) ([]Task, error)
Теперь читаем CSV и получаем []Task. Мы сделаем версию, которая ожидает заголовок id,title,done и дальше читает строки.
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 // заголовок мы пока не валидируем глубоко
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
}
Да, здесь есть уязвимость: мы не проверяем длину rec, и при кривой строке получим панику по индексу. В этой лекции я оставляю это как «наглядный скелет», а не как «промышленный импорт, который переживёт апокалипсис». Но даже в таком виде видно главное: CSV-парсинг делает csv.Reader, а преобразование типов и базовые проверки делаем мы.
Быстрый “проводной” пример: записали → прочитали обратно
Чтобы почувствовать, что всё реально работает, можно прогнать в памяти через 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}]
}
Здесь особенно приятно то, что кавычки и запятые внутри Title не ломают формат — потому что этим занимается csv.Writer и csv.Reader, а не ваша ручная строковая магия.
Поток данных: Reader/Writer и CSV
Иногда полезно остановиться и увидеть общую картинку. В Go часто проектируют код так, чтобы формат (CSV) был просто «насадкой» на поток чтения/записи. Вы меняете источник/приёмник, но не переписываете бизнес-логику.
flowchart TD
A["Источник данных<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["Приёмник<br/>file / buffer / stdout"]
Если читать это как историю: «у нас есть io.Reader, сверху надеваем csv.Reader, получаем записи, превращаем в Task». А на экспорт идём обратным путём.
5. Типичные ошибки при работе с encoding/csv
Ошибка №1: парсить CSV через strings.Split или построчный Scanner и “делить по запятым”.
Такой код сначала выглядит героически, потом встречает кавычки/запятые внутри полей, и героизм превращается в археологию багов. CSV нужно читать csv.Reader-ом и писать csv.Writer-ом, потому что они реализуют правила формата, а не “примерно похожее”.
Ошибка №2: забыть Flush() и не проверить w.Error().
Это классика: вы честно вызвали Write, а в файле пусто или обрезано. Причина простая: часть данных сидела в буфере и не была сброшена, или ошибка записи проявилась только на финализации. Привычка “Flush() + Error() всегда” делает экспорт намного надёжнее.
Ошибка №3: не обрабатывать io.EOF отдельно и считать его «ошибкой чтения».
В потоковом чтении io.EOF — это нормальный сигнал, что данные закончились. Если не выделить его в отдельную ветку и не сделать break, вы либо будете печатать “error: EOF”, либо вообще сломаете цикл.
Ошибка №4: сразу лезть в rec[0], rec[1], rec[2] без проверки длины записи.
На учебных данных это прощается, но в реальном импорте любая “кривая” строка может привести к панике из-за выхода за границы. Даже если вы пока не строите сложную валидацию, минимальная проверка “сколько полей пришло” экономит часы отладки.
Ошибка №5: терять контекст ошибок и возвращать просто err.
Когда чтение падает на 736-й записи, сообщение “invalid syntax” не вдохновляет. Лучше оборачивать ошибки через fmt.Errorf("...: %w", err), чтобы было понятно, на каком шаге и с каким значением проблема. Такой стиль не делает код «красивее ради красоты» — он делает поддержку возможной.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ