---
title: "Сохраняем результат: JSON и CSV"
description: "Сохранение данных парсера на Go: json.MarshalIndent и JSONL, csv.Writer с автоквотированием, Comma для Excel и формат отчёта, который переживёт месяц."
course: "Colly: парсинг и веб-скрапинг на Go"
url: "https://golangmaster.ru/courses/colly/lessons/19-json-csv-vyvod/"
published: "2026-09-19"
updated: "2026-09-21"
minutes: 18
---

# Сохраняем результат: JSON и CSV

*Курс «Colly: парсинг и веб-скрапинг на Go», урок 19 из 25. ~18 минут. Обновлено 21.09.2026. Источник: https://golangmaster.ru/courses/colly/lessons/19-json-csv-vyvod/*

Сохранение данных парсера — этап, на котором отлаженные селекторы превращаются в файл, который откроется и через месяц, и в таблицу, которую примет аналитик. Печать в stdout проверяет селекторы, но не хранит: программа завершилась — и всё собранное исчезло. Разберём три штатных формата вывода на Go — JSON через json.MarshalIndent и json.NewEncoder, построчный JSONL для больших выгрузок и CSV через csv.Writer — плюс правила отчёта, который переживёт месяц на диске.

Типовой вечер: скрапер наконец печатает в терминал названия и цены — одна карточка за другой, всё правильно. А потом программа заканчивается, и от результата остаются только строки в истории терминала. Вывод в консоль отвечает на вопрос «работают ли селекторы», но не на вопрос «где данные»: у отчёта должен быть файл, у файла — формат, который переживёт и месяц на диске, и передачу в другую программу. Стандартная библиотека Go закрывает весь набор: encoding/json даёт читаемые дампы и потоковую запись, encoding/csv — таблицы для табличного редактора, а построчный JSONL стоит между ними. Выбор формата — это выбор читателя: JSON для программ, CSV для людей в Excel, JSONL для потока записей, который не помещается в память целиком. Хороший скрапер часто пишет в два формата сразу: машинный для пайплайна и человеческий для заказчика — оба собираются из одних и тех же структур в памяти, так что вторая выгрузка почти бесплатна. Файлы держите в отдельном каталоге рядом с кодом — out/, exports/, data/ — и не забывайте про .gitignore: данные, собранные с чужого сайта, в репозитории живут плохо. Ниже — механика каждого варианта на одном и том же каталоге, плюс гигиена, которая отличает выгрузку от одноразового дампа.

### Аккумулирование и json.MarshalIndent

Данные собираются так, как разбирали в уроке про [структуры для спарсенного](/courses/colly/lessons/07-struktury-dannyh/): колбэк OnHTML создаёт структуру Product и добавляет её в общий слайс, а запись в файл — один раз в конце main, когда Visit отработал и слайс наполнен. json.MarshalIndent(v, "", "  ") превращает слайс в []byte с отступами — такой файл читается глазами, открывается в редакторе и красиво смотрится в diff при отладке схемы. Ошибка маршалинга проверяется честно: Marshal не справляется с каналами, функциями и циклическими ссылками, но модель скрапера из строк и чисел в эту категорию не попадает — если только в структуру не попало поле-функция от какой-нибудь рефлексии. os.WriteFile кладёт байты на диск одним вызовом; права 0644 означают rw-r--r-- — владелец пишет, остальные читают. Имя файла — не мелочь: одна и та же выгрузка каждый день в один products.json затирает вчерашнюю, а вам потом захочется сравнить. Дата в имени решает это за секунду. Третий вариант — json.Marshal без отступов: компактный документ для пайплайна, где читает его программа, а не человек. Для сценария «собрал — сохранил — вышел» MarshalIndent — самый быстрый путь от слайса к файлу, и именно с него стоит начинать каждый новый скрапер.

### Одна выгрузка в конце обхода

```go
package main

import (
        "encoding/json"
        "log"
        "os"

        "github.com/gocolly/colly/v2"
)

type Product struct {
        Name string `json:"name"`
        URL  string `json:"url"`
}

func main() {
        var products []Product

        c := colly.NewCollector()

        c.OnHTML(".product_pod", func(e *colly.HTMLElement) {
                p := Product{
                        Name: e.ChildText("h3 a"),
                        URL:  e.Request.AbsoluteURL(e.ChildAttr("h3 a", "href")),
                }
                products = append(products, p)
        })

        if err := c.Visit("https://shop.example.com/catalog/"); err != nil {
                log.Fatal(err)
        }

        data, err := json.MarshalIndent(products, "", "  ")
        if err != nil {
                log.Fatal(err)
        }
        if err := os.WriteFile("products.json", data, 0644); err != nil {
                log.Fatal(err)
        }
}
```

Слайс products наполняется в колбэках, файл пишется один раз после обхода — не на каждую карточку. Теги json задают имена полей в файле: name и url вместо Name и URL. MarshalIndent с отступом в два пробела делает дамп читаемым; ошибка каждого шага проверяется, потому что тихий ноль после неудавшейся записи — самый дорогой баг отчёта: узнаете о нём по пустому файлу через месяц.

### json.NewEncoder: поток без промежуточного буфера

MarshalIndent собирает весь документ в памяти как []byte — для отчёта это норма, для больших объёмов расточительно. Второй путь — json.NewEncoder(w).Encode(v): кодировщик пишет прямо в io.Writer, без промежуточного слайса байтов. Два отличия, о которых полезно знать заранее. Encode завершает документ переводом строки: файл, собранный энкодером, заканчивается \n, а маршал-дамп — нет, и на этой черте, забегая вперёд, держится весь JSONL. Encoder работает с любым писателем — os.File, os.Stdout, сетевым соединением, буфером в тесте — поэтому один и тот же код выгрузки живёт и в скрапере, и в его тестах. Отступы включаются отдельным вызовом SetIndent("", "  ") — без него JSON выйдет компактным, в одну строку, что для пайплайна даже дешевле: меньше байт, тот же смысл. Ещё одна настройка, о которой узнают по странному выводу: по умолчанию энкодер экранирует символы HTML — угловые скобки и амперсанд в тексте товара превратятся в \u003c и \u003e; отключается вызовом SetEscapeHTML(false), если выгрузка читается человеком. Практическое правило такое: одна выгрузка в конец программы — что угодно из двух, разница вкусовая; данные, которые текут мимо уже открытого писателя, — энкодер, потому что ему не нужен весь документ сразу.

### JSONL: по записи на строку

Когда записей десятки тысяч, единый JSON-документ становится обузой: его надо целиком держать в памяти, целиком перечитывать, и докинуть в него запись означает пересобрать весь файл заново. Альтернатива — JSONL, он же NDJSON: один JSON-объект на строку. Файл открывается один раз с флагами os.O_APPEND|os.O_CREATE|os.O_WRONLY и правами 0o644 — дописываем без затирания; создаётся json.NewEncoder(f), и на каждую запись вызывается Encode(item): энкодер сам ставит \n в конец, и ровно эта черта превращает поток объектов в валидный JSONL. Такой файл можно читать построчно любым инструментом — grep, wc -l, awk — и докидывать записи в существующий выгрузкой другого дня, не пересобирая старое: файл с вчерашними строками и сегодня — это корректный JSONL, а не полусборка. Читающая программа разбирает его тем же энкодером наоборот: bufio.Scanner построчно плюс json.Unmarshal на каждую строку — память расходуется на одну запись, а не на весь файл. Цена формата — вложенность: объект с многострочной структурой внутри сломает построчную модель, поэтому в JSONL пишут плоские записи, а глубину оставляют единому JSON. Для действительно больших выгрузок файл разделяют по дате в имени — prices-2025-03-01.jsonl — и при желании жмут gzip-ом: построчный формат прекрасно переживает архивацию, а строка-дата в имени превращает ротацию в вопрос переименования.

### Дозапись JSONL по ходу обхода

```go
f, err := os.OpenFile("products.jsonl",
        os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644)
if err != nil {
        log.Fatal(err)
}
defer f.Close()

enc := json.NewEncoder(f)

c.OnHTML(".product_pod", func(e *colly.HTMLElement) {
        p := Product{
                Name: e.ChildText("h3 a"),
                URL:  e.Request.AbsoluteURL(e.ChildAttr("h3 a", "href")),
        }
        if err := enc.Encode(p); err != nil {
                log.Println("не записал:", err)
        }
})
```

Дескриптор открывается один раз до обхода и закрывается через defer — не на каждую строку. O_APPEND двигает позицию в конец перед каждой записью, O_CREATE создаёт файл, если его нет; 0o644 — права rw-r--r--. Ошибка Encode логируется в колбэке: битая запись не роняет обход, а попадает в журнал для разбора — данные дороже пунктуальности.

> **Открыл-закрыл десять тысяч раз.** Живой антипаттерн: OpenFile и Close внутри колбэка, на каждую карточку. Тысяча карточек — тысяча открытий, системные вызовы съедают время, а при падении процесса между записью и закрытием хвост строк пропадает: ОС додержит недописанное, но файл без Close при аварийном завершении оставляет ровно то, что успело уйти на диск, — и это не всегда всё. Ещё коварнее вариант с csv.Writer без Flush: записи лежат в его внутреннем буфере, и без Flush с последующим Close на диске оказывается не всё — особенно обидно, когда упало на девятьсот девяносто девятой строке. Гигиена одна на все форматы: дескриптор открывается один раз, закрывается defer-ом, буферизованные писатели завершаются Flush, накопленная ошибка проверяется перед выходом. Проверьте свой скрапер: если OpenFile встречается внутри OnHTML — это он.

### csv.Writer: таблица для табличного редактора

CSV — формат для читателя-человека: Excel, Google Sheets, да просто глаза. Пакет encoding/csv: csv.NewWriter(out) оборачивает писателя буфером, w.Write([]string{...}) кладёт строку-запись, w.Flush() выталкивает буфер на диск, err := w.Error() возвращает первую накопившуюся ошибку записи. Три нюанса. Flush обязателен: внутри NewWriter сидит буфер, и без него файл останется пустым или обрезанным ровно на середине. Write возвращает error, но ошибки он накапливает до Flush — надёжнее проверять w.Error() после него. И квотирование автоматическое: поле с запятой, кавычкой или переводом строки само обернётся кавычками, а кавычки внутри удвоятся — руками экранировать ничего не нужно, и в этом главное отличие пакета от склейки строк через strings.Join, которая ломается на первом же названии с запятой. Если Excel показывает кракозябры, допишите в начало файла BOM — три байта EF BB BF, подсказку о кодировке UTF-8. И последнее: числа пишите с точкой, а не с запятой, — ячейка «89,90» в русской локали превратится в дату или текст, и таблица перестанет считаться. У читателя есть и своя страховка: по умолчанию он ждёт в каждой строке столько колонок, сколько в первой, и возвращает ошибку на смещённой записи — тихо съехавших колонок в правильном CSV не бывает.

### CSV-отчёт с разделителем под Excel

```go
f, err := os.Create("products.csv")
if err != nil {
        log.Fatal(err)
}
defer f.Close()

w := csv.NewWriter(f)
w.Comma = ';'

w.Write([]string{"name", "url", "scraped_at"})

now := time.Now().UTC().Format(time.RFC3339)
c.OnHTML(".product_pod", func(e *colly.HTMLElement) {
        w.Write([]string{
                e.ChildText("h3 a"),
                e.Request.AbsoluteURL(e.ChildAttr("h3 a", "href")),
                now,
        })
})

if err := c.Visit("https://shop.example.com/catalog/"); err != nil {
        log.Fatal(err)
}
w.Flush()
if err := w.Error(); err != nil {
        log.Fatal(err)
}
```

w.Comma = ';' меняет разделитель на точку с запятой — это не украшение: Excel с русской локалью разбивает по ';' и не понимает ',', потому что запятая у него занята десятичной частью чисел. Заголовок пишется до обхода, метка времени одна на выгрузку и в формате RFC3339 с UTC. Финал обязателен: без Flush хвост остаётся в буфере, без w.Error() файл молчит о проблеме.

### Читаем обратно: вторая программа

Выгрузка хороша настолько, насколько легко читается обратно. JSONL разбирается построчно: bufio.Scanner поверх файла плюс json.Unmarshal на каждую строку — память занимает одна запись, и файл на миллион строк обрабатывается так же спокойно, как на десять. Единый JSON-документ читается целиком: os.ReadFile плюс json.Unmarshal в []Product — просто, но память пропорциональна файлу. CSV возвращается пакетом же encoding/csv: csv.NewReader(f), затем r.ReadAll() даёт [][]string, где первая строка — заголовок; для больших файлов вместо ReadAll берут r.Read() в цикле — построчно, без всего файла в памяти. Типов в CSV нет: всё текст, и число с запятой в качестве десятичного разделителя придётся чистить strconv-ом — ещё один довод писать числа с точкой и хранить их в отдельной колонке. Из этого следует рабочий вывод: данные отдаются в том формате, в котором их будут есть, — не заставляйте аналитика распаковывать ваш любимый бинарник. И проверяйте обратную сторону до боевого запуска: собранный файл читается второй программой, заголовки сходятся с ожидаемыми, даты парсятся — выгрузка, которую нельзя прочитать обратно, это просто способ потерять данные с лишним шагом.

### Форматы: кому какой

| Формат | Читатель | Сильная сторона | Слабая сторона |
| --- | --- | --- | --- |
| JSON (MarshalIndent) | программы, человек при отладке | типы и вложенность сохраняются | весь документ в памяти; читается только целиком |
| JSONL / NDJSON | потоковые скрипты, вторая программа | дозапись без пересборки, построчная обработка | табличный редактор напрямую не откроет |
| CSV | Excel, Sheets, отчётные письма | открывается везде, есть заголовки | типы теряются: всё текст, числа приходится чистить |

### Отчёт, который переживёт месяц

- стабильные заголовки: имена колонок и полей не меняются между выгрузками — на них сцеплены скрипты и сводные таблицы
- даты в ISO: time.Now().UTC().Format(time.RFC3339) — сортируются лексикографически и читаются в любой локали
- версия схемы в имени файла: products-v2-2025-03-01.json — правка структуры не ломает старых читателей тихо
- числа с точкой, валюта отдельной колонкой: «1 299,90 руб.» в одной ячейке не суммируется ни в одной таблице
- одна выгрузка — один смысл: смешанный файл «товары плюс логи» не откроет никто
- ошибки записи проверяются до выхода: пустой файл с нулём в статусе — худший вид отчёта
- обратное чтение протестировано: собранный файл открывается второй программой, заголовки и даты сходятся

> **Песочница не запустит.** Файловая запись и сеть в браузерном интерпретаторе недоступны — как и весь сетевой код курса, примеры урока выполняются локальным Go. Соберите модуль, прогоните скрапер по учебной витрине books.toscrape.com и откройте полученные файлы: JSON — в редакторе, CSV — в табличном приложении.

### Проверка: два способа записать JSON

**Вопрос:** Чем json.NewEncoder(f).Encode(v) отличается от связки json.MarshalIndent плюс os.WriteFile?

- Ничем: оба пишут байт в байт одинаковый файл
- ✅ Энкодер пишет в io.Writer потоково и добавляет \n в конце документа
- Энкодер автоматически расставляет отступы
- MarshalIndent не умеет писать в файл

Encoder пишет прямо в писатель без промежуточного []byte и завершает документ переводом строки — на этой черте держится JSONL. Отступы у него выключены по умолчанию: красивый JSON включает SetIndent. Для одной выгрузки разница невелика, для потока записей — принципиальная.

### Проверка: автоквотирование

**Вопрос:** Поле товара — Молоко "Дар" 1,5%. Что сделает csv.Writer при w.Write с таким полем?

- Сломает строку: запятая разрежет поле на две ячейки
- ✅ Обернёт поле в кавычки автоматически, кавычки внутри удвоит
- Вернёт ошибку: поле невалидно для CSV
- Пропустит поле и запишет пустую ячейку

Квотирование — работа пакета: поле с разделителем, кавычкой или переносом строки оборачивается кавычками, внутренние кавычки удваиваются, и запись остаётся корректным CSV. Именно поэтому писать CSV руками через strings.Join не нужно: первый же товар с запятой в названии рассыплет ручной файл.

### Что окажется в файле?

```go
w := csv.NewWriter(f)

w.Write([]string{"product", "price"})
w.Write([]string{"Молоко \"Дар\" 1,5%", "89,90"})

w.Flush()
```

**Что выведет программа?**

- Молоко "Дар" 1,5%,89,90
- ✅ "Молоко ""Дар"" 1,5%",89,90
- Молоко,Дар 1,5%,89,90
- Ошибка Flush: поле с кавычками невалидно

Поле с кавычкой и запятой квотируется целиком: обёртка в кавычки плюс удвоение внутренних. Разделитель-запятая внутри поля остаётся невредимым для парсера, а читатель вернёт исходную строку: квоты — транспортная упаковка, а не часть данных. Проверьте себя: без Flush вторая строка вообще не попала бы в файл.

### Восстановите дозапись JSONL

Допишите открытие файла с флагами дозаписи, энкодер и запись структуры.

```go
f, err := os.___("products.jsonl",
        os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644)
if err != nil {
        log.Fatal(err)
}
defer f.___()

enc := json.New___(f)

c.OnHTML(".product_pod", func(e *colly.HTMLElement) {
        enc.___(product)
})
```

Подсказка: Файл открывает os.OpenFile с флагами дозаписи; закрывает f.Close(); энкодер создаёт json.NewEncoder; одну запись пишет Encode.

Правильные ответы в пропусках: `OpenFile`, `Close`, `Encoder`, `Encode`

### Финал CSV-отчёта

Расставьте шаги записи отчёта от создания writer до закрытия файла. Проверки сокращены до существенных.

Правильный порядок строк:

1. `w := csv.NewWriter(f)`
2. `w.Write([]string{"product", "price"})`
3. `w.Write([]string{"Хлеб белый", "42.50"})`
4. `w.Flush()`
5. `if err := w.Error(); err != nil { log.Fatal(err) }`
6. `f.Close()`

### Карточки: словарь выгрузки

- **json.MarshalIndent** — Функция encoding/json: превращает значение в []byte с отступами. Ошибку маршалинга и записи проверяют на каждом шаге.
- **json.NewEncoder** — Потоковый энкодер в io.Writer: Encode пишет JSON и \n в конце; SetIndent включает отступы. На нём держится JSONL.
- **JSONL / NDJSON** — Один JSON-объект на строку: дозапись через O_APPEND, построчное чтение, без всего файла в памяти.
- **csv.NewWriter** — Оборачивает писателя буфером: Write кладёт запись []string, Flush выталкивает буфер, w.Error() возвращает накопленную ошибку.
- **csv.Comma** — Поле-разделитель writer-а; w.Comma = ';' — вариант для Excel с русской локалью, где ',' занята десятичной частью числа.
- **time.RFC3339** — Формат 2006-01-02T15:04:05Z07:00: сортируется лексикографически. Для метки выгрузки — time.Now().UTC().Format(time.RFC3339).

### Что дальше

Файлы хороши, пока вопросы звучат как «выгрузи каталог». Как только появляется «сколько товар стоил первого числа», файлы начинают складываться в папки, а папки — в беспорядок. Ответ — база: следующий урок складывает спарсенное в SQLite через database/sql — с upsert вместо дублей и append-only историей цен вместо разовых дамп-файлов; смежный разбор — в уроке про [сохранение в базу](/courses/colly/lessons/20-sql-storage/). Сырьё для неё — ровно те структуры, что вы собирали здесь. А когда выгрузка станет частью пайплайна, ей понадобятся тесты: фикстуры и локальный тест-сервер разбираются в уроке про [тестирование скрапера](/courses/colly/lessons/23-testirovanie-skrepera/). Карта маршрута курса — на странице [Colly](/courses/colly/).

## Коротко: что запомнить

- Файл у отчёта обязателен: вывод в stdout проверяет селекторы, но не сохраняет результат.
- json.MarshalIndent даёт читаемый дамп слайса; os.WriteFile кладёт его на диск с правами 0644.
- json.NewEncoder пишет потоково в io.Writer и добавляет \n — на нём стоит построчный JSONL.
- JSONL — запись на строку: дозапись через O_APPEND, построчное чтение, без всего файла в памяти.
- csv.Writer квотирует поля автоматически; финал обязателен: Flush, затем w.Error().
- csv.Comma = ';' — для Excel с русской локалью; даты — RFC3339 в UTC; версия схемы — в имени файла.
- Дескриптор открывается один раз и закрывается defer-ом: OpenFile в колбэке на каждую строку — антипаттерн с потерей хвоста.

## Домашнее задание

Соберите скрапер, который сохраняет каталог двумя способами: слайс продуктов в products.json через MarshalIndent и те же записи в products.jsonl через Encoder с O_APPEND. Откройте оба файла, сравните размер и читаемость, затем добавьте CSV-выгрузку с w.Comma = ';' и откройте её в табличном редакторе. Финал: впишите в имя файла дату и версию схемы и объясните, что изменится у читателей старых выгрузок.

## Вопросы и ответы по уроку

### JSON или CSV для выгрузки каталога?

Зависит от читателя. JSON сохраняет типы и вложенность и удобен для программ; CSV открывается везде, где сидит человек с таблицей, но превращает всё в текст. Практичный вариант — писать оба: JSON для пайплайна, CSV для еженедельного письма заказчику. Оба собираются из одних структур, так что поддержки двух форматов это почти не стоит.

### Почему после w.Write файл пустой или обрезанный?

csv.Writer буферизует записи, и без w.Flush() хвост остаётся в буфере — иногда весь файл. Финальная последовательность всегда одна: Flush, затем err := w.Error(), затем Close. То же правило для os.File — Close в defer-е, иначе при падении процесса последние данные теряются насовсем.

### Как докинуть записи в уже готовый JSON-файл?

Единый JSON придётся перечитать, дополнить слайс и переписать файл целиком — без пересборки не обойтись. Если дозапись нужна по ходу дела, формат меняют на JSONL: os.OpenFile с O_APPEND и json.NewEncoder позволяют добавлять записи в существующий файл, не трогая старые строки.
