---
title: "JSON или console: выбор энкодера"
description: "zap JSON encoder против console-энкодера: одна запись в двух форматах, колонки и табуляция, EncoderConfig на превью и читаемость против парсабельности."
course: "Zap: структурное логирование на Go"
url: "https://golangmaster.ru/courses/zap/lessons/06-json-console/"
published: "2026-09-19"
updated: "2026-09-21"
minutes: 18
---

# JSON или console: выбор энкодера

*Курс «Zap: структурное логирование на Go», урок 6 из 25. ~18 минут. Обновлено 21.09.2026. Источник: https://golangmaster.ru/courses/zap/lessons/06-json-console/*

zap JSON encoder против console-энкодера — главный разворот логгера после уровней и полей: первый пишет машиночитаемый JSON, второй — колонки для человека. Разберём, как выглядит одна и та же запись в обоих форматах, что крутит EncoderConfig и почему прод живёт на JSON, а ноутбук разработчика — на консоли.

Одно и то же событие — order paid — на машине разработчика печатается колонками, а в проде уезжает строкой JSON. Настройки кода не менялись, вызов Info тот же, поля те же — поменялся энкодер: компонент, который превращает запись из полей в байты. В zap энкодеров встроено два, и выбор между ними — это выбор адресата: JSON-логи для машины и систем сбора, console-вывод для глаз. Ошибка выбора стоит дорого в обе стороны: JSON на ноутбуке изматывает глаза, консоль в проде ломает парсеры системы сбора — и половина полей превращается в невидимые. Хорошая новость: формат не вшит в приложение намертво. Энкодер — настройка, которую меняют одной строкой, и держат два сразу: каждый приёмник получает свой формат, о чём в конце урока. Песочница zap не исполняет сторонние пакеты, поэтому сравнивайте выводы локально — разница видна за один запуск.

### Энкодер: кто собирает запись

Запись лога в zap проходит два этапа: сначала формируется entry — время, уровень, сообщение, место вызова — и к нему прикладываются ваши поля; потом энкодер сериализует всё это в байты. Именно энкодер решает, будет ли время дробными секундами от эпохи, окажется ли уровень строчными или заглавными и куда денутся поля. Встроенных энкодера два: json — однострочный JSON-объект на запись, и console — колонки через табуляцию для терминала. Оба настраиваются через zapcore.EncoderConfig — набор ключей и правил сериализации, который мы ниже только покажем, а полностью разберём в уроке про конфиг и отдельно в [zapcore](/courses/zap/lessons/10-zapcore/), где энкодеры собираются руками. Важно понимать: пресеты NewProduction и NewDevelopment — это не два разных логгера, а два готовых набора настроек: уровень, энкодер и его конфиг. Поменяли энкодер — поменялся формат вывода при неизменных полях, уровнях и приёмниках.

### Одна запись в двух форматах

```go
package main

import (
        "go.uber.org/zap"
)

func main() {
        prod, _ := zap.NewProduction()
        defer prod.Sync()

        dev, _ := zap.NewDevelopment()
        defer dev.Sync()

        orderID := "A-1024"

        prod.Info("order paid", zap.String("order_id", orderID))
        dev.Info("order paid", zap.String("order_id", orderID))
}
```

Один и тот же вызов Info у двух логгеров с разными пресетами. Прод напечатает {"level":"info","ts":1712345678.12,"caller":"main/main.go:15","msg":"order paid","order_id":"A-1024"} — одна строка, время числом, уровень строчными. Development напечатает 2024-04-06T12:00:00.123+0300	INFO	main/main.go:16	order paid	{"order_id": "A-1024"} — колонки через табуляцию, время в ISO-формате, уровень заглавными. Поля одинаковы — различие даёт только энкодер.

### Как выглядит console encoder: колонки и табуляция

console-вывод устроен просто: время, уровень, имя логгера если есть, место в коде, сообщение — и JSON-объект с полями в хвосте, всё через табуляцию. Ключи из EncoderConfig — timeKey, levelKey и прочие — на колонки не влияют: console-энкодер печатает части записи по позициям, а ключи потратил бы только JSON. Поэтому в development-пресете ключи названы коротко — T, L, M — и это не влияет на вывод: колонки те же. Если часть записи не нужна, ей просто оставляют пустой ключ — колонка исчезает. Стек-трейс печатается отдельным блоком после строки: консольные записи остаются построчными, а трейс уезжает ниже — удобно в терминале, терпимо в файле. Разделитель меняется опцией ConsoleSeparator: таб по умолчанию, но можно поставить « | » для тех, кто читает логи в редакторе с узкой колонкой. Если логгер получил имя через Named, оно встаёт отдельной колонкой между уровнем и caller-ом — в JSON то же самое лежит под ключом logger. Мелочь, а глаз экономит.

### json против console по строкам записи

| Аспект | JSON-энкодер | console-энкодер |
| --- | --- | --- |
| Формат записи | одна строка JSON-объекта | колонки через табуляцию, поля JSON-объектом в хвосте |
| Время | дробные секунды от эпохи (Epoch) | ISO-8601 строкой (ISO8601) |
| Уровень | строчными: info, error | заглавными: INFO, ERROR |
| Кто читает | системы сбора, jq, скрипты | человек в терминале |
| Парсинг | любой JSON-парсер без регулярок | только по позиции колонок |
| Где живёт | production, файлы, централизованный сбор | локальная разработка, демо |

### JSON-логирование: что получаем в проде

JSON-логирование — это контракт: каждая запись самодостаточна и парсится без знания о сервисе, который её написал. Система сбора разворачивает объект в поля, и любое поле становится фильтром, графиком или алертом — ровно то, о чём был первый урок про структурные логи. jq читает поток как таблицу: select по уровню, group по коду ошибки, счётчики за минуту — всё без регулярок. Числа остаются числами: duration 0.045 сортируется и агрегируется, а не превращается в строку «45ms». Кавычки и переводы строк внутри значений экранируются — запись не развалится на две строки посреди поля, что для построчных парсеров вопроса жизни и смерти. Цена тоже есть: JSON многословнее консоли процентов на двадцать-тридцать на той же записи, и человеком читается хуже. Но прод — не место для чтения глазами: там логи фильтруются запросами, и многословность окупается за неделю. Последнее преимущество скромное, но приятное: JSON-логи версионируются схемой — добавили поле, обновили схему в сборщике, и старые записи продолжают читаться по-старому без единой правки парсеров.

### Экранирование и многострочность: где форматы расходятся

Разница между форматами обнажается на неудобных значениях. Возьмите стек-трейс из ошибки: это сотни строк одним куском. JSON-энкодер экранирует управляющие символы — перевод строки становится \n внутри одной строки, и запись остаётся однострочной: парсер извлечёт трейс и покажет его с переносами в интерфейсе. console-энкодер трейс печатает отдельным блоком после колонок: в терминале это красиво, в файле записи перестают быть построчными, и наивный парсер склеит пятьдесят строк трейса в чужую запись. То же с кавычками и табуляцией внутри значений: JSON гарантирует целостность, колонки — нет. Отсюда практическое правило для смешанных систем: в файл, который будет парситься, — только JSON; console живёт либо в терминале, либо в файле, который читают глазами. И проверьте это на своей инфраструктуре: скормите сборщику обе записи из урока, одну — с трейсом, и посмотрите, что он сделал с каждой. Десять минут теста экономят неделю разбора, почему половина записей помечена битой.

### Читаемость против парсабельности: моё правило

Правило, которое у меня прижилось: console — везде, где логи читает человек в момент написания кода; JSON — везде, где логи читает машина или другой человек позже. Локальная машина — консоль. Тесты — console, чтобы падение прогона показывало читаемый контекст. Прод — JSON, без вариантов. Спорные места: staging, на котором бывает и то и другое — демо для продукта требует колонок, а сбор логов уже настроен; здесь выручает мультиплекс из следующего раздела — один логгер, два приёмника, каждый со своим форматом. Контейнеры — отдельный вопрос: docker logs отдаёт сырой stdout, и на локальном контейнере консольный вывод приятнее; но если stdout контейнера уезжает в сборщик, внутри должен быть JSON, иначе сборщик получает колонки и страдает. Спор о том, что удобнее, я закрываю вопросом: кто будет читать эти логи через месяц — вы сейчас или запрос к системе сбора? Для первого — консоль, для второго — JSON, и это почти всегда решает спор без обид. Отдельный кейс — миграция со старого текстового формата: включите JSON у нового логгера заранее, на тестовом стенде, и дайте сборщику месяц на параллельный приём. Формат — самая дешёвая часть переезда на zap: поля переносятся один в один, а старые парсеры текста просто выключаются.

### Смешиваем энкодеры: console на экран, JSON в файл

```go
package main

import (
        "os"

        "go.uber.org/zap"
        "go.uber.org/zap/zapcore"
)

func main() {
        encCfg := zap.NewProductionEncoderConfig()

        consoleCore := zapcore.NewCore(
                zapcore.NewConsoleEncoder(encCfg),
                zapcore.AddSync(os.Stderr),
                zapcore.InfoLevel,
        )
        jsonCore := zapcore.NewCore(
                zapcore.NewJSONEncoder(encCfg),
                zapcore.AddSync(os.Stdout),
                zapcore.InfoLevel,
        )

        logger := zap.New(zapcore.NewTee(consoleCore, jsonCore))
        defer logger.Sync()

        logger.Info("order paid", zap.String("order_id", "A-1024"))
}
```

Два ядра — по одному на приёмник: console-энкодер пишет в stderr, JSON — в stdout; zapcore.NewTee склеивает их в один логгер, и каждый вызов Info уходит в оба. На экране — колонки, в файле или пайпе — JSON. Уровень задаётся в NewCore у каждого ядра, так что их можно развести и по серьёзности. Это уже сборка логгера руками: полный разбор ядра, энкодера и приёмника — в уроке про [zapcore](/courses/zap/lessons/10-zapcore/), здесь важна идея — формат решается на приёмнике, а не на логгере.

### EncoderConfig на превью: что крутится

zapcore.EncoderConfig — набор настроек, из которых энкодер собирает запись. Ключи решают, какие имена получат части записи: messageKey — ключ сообщения в JSON (в проде msg), timeKey (ts), levelKey (level), callerKey (caller), stacktraceKey (stacktrace). Правила сериализации решают, как эти части выглядят: encodeTime — время эпохой или ISO-строкой (EpochTimeEncoder против ISO8601TimeEncoder), encodeLevel — строчными или заглавными (LowercaseLevelEncoder против CapitalLevelEncoder), encodeDuration — длительность числом секунд или строкой «45ms», encodeCaller — полный путь или короткий файл:строка. Пустой ключ — часть записи пропускается: не хотите caller в JSON — оставьте callerKey пустым. Полный обход полей EncoderConfig с примерами — в уроке про [zap.Config](/courses/zap/lessons/07-zap-config/); пресеты, кстати, это просто предзаполненные EncoderConfig плюс уровень и приёмник. Меняя эти настройки, вы меняете словарь логов — а он, как мы обсуждали в уроке про поля, контракт на годы, поэтому правки конфига не делаются между делом.

> **jq вместо прокрутки.** Пока логи JSON, чтение из терминала решается одной командой: jq '.msg' app.log — только сообщения; jq 'select(.level=="error")' — одни ошибки; jq -c 'select(.order_id=="A-1024")' — события одного заказа. Это не замена системе сбора, а ежедневный инструмент разработчика до неё.

> **Console-энкодер в проде ломает сбор.** Консольная запись рассчитана на колонки фиксированной позиции, а многострочные сообщения и поля с переносами её разваливают: парсер системы сбора склеит или потеряет хвост. JSON самодостаточен: экранирование гарантирует одну строку на запись. Поэтому правило «JSON в проде» — не вкус, а требование трубопровода.

### Проверка: уровень и энкодер

**Вопрос:** В каком выводе уровень печатается заглавными буквами?

- В JSON-энкодере с production-конфигом
- ✅ В console-энкодере с development-конфигом
- В обоих — это свойство метода Info
- Нигде — zap всегда пишет строчными

За вид уровня отвечает encodeLevel в конфиге энкодера: development-пресет берёт CapitalLevelEncoder — INFO, ERROR; production — LowercaseLevelEncoder — info, error. Это настройка, а не свойство метода.

### Проверка: поля в console-выводе

**Вопрос:** Куда деваются поля записи в console-энкодере?

- Печатаются отдельными колонками — по полю на колонку
- Пропадают — console не умеет поля
- ✅ Печатаются JSON-объектом в конце строки
- Печатаются парами ключ=значение через запятую

console-энкодер печатает запись колонками, а все поля сериализует встроенным JSON-энкодером в хвосте: {"order_id": "A-1024"}. Поля не теряются — человеко-читаемость страдает только у самого объекта.

### Что в первой колонке у development-пресета?

```go
dev, _ := zap.NewDevelopment()
dev.Info("service started")
```

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

- 1712345678.123 — дробные секунды от эпохи
- ✅ 2024-04-06T12:00:00.123+0300 — ISO-строка с зоной
- Уровень INFO — время печатается в конце строки
- caller — время не печатается вовсе

Development-конфиг энкодера ставит ISO8601TimeEncoder: время строкой с датой, миллисекундами и зоной. Прод-пресет берёт EpochTimeEncoder — дробные секунды, дешевле сериализовать и хранить.

### Сколько строк напечатает tee-логгер?

```go
logger := zap.New(zapcore.NewTee(consoleCore, jsonCore))
logger.Info("order paid", zap.String("order_id", "A-1024"))
```

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

- Одну — берётся первый сработавший приёмник
- ✅ Две: колонки в stderr и JSON в stdout
- Ни одной — Tee нужен logger.Sync() перед записью
- Одну строку с двумя форматами сразу

NewTee раздаёт запись всем ядрам, и каждое пишет своим энкодером в свой приёмник. Один вызов — две записи: колонки для глаз и JSON для трубы. Уровни у ядер могут отличаться — тогда записи разойдутся и по серьёзности.

### Соберите второе ядро

Допишите JSON-ядро tee-сборки: энкодер, приёмник и склейка двух ядер.

```go
encCfg := zap.NewProductionEncoderConfig()

consoleCore := zapcore.NewCore(
        zapcore.NewConsoleEncoder(encCfg),
        zapcore.AddSync(os.Stderr),
        zapcore.InfoLevel,
)

jsonCore := zapcore.NewCore(
        zapcore.New___Encoder(encCfg),
        zapcore.AddSync(os.___),
        zapcore.InfoLevel,
)

logger := zap.New(zapcore.New___(consoleCore, jsonCore))
```

Подсказка: Второй энкодер — JSON; второй приёмник — os.Stdout; два ядра склеивает zapcore.NewTee.

Правильные ответы в пропусках: `JSON`, `Stdout`, `Tee`

### Соберите настройку двух энкодеров

Расставьте строки сборки: конфиг, два ядра, склейка и запись.

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

1. `encCfg := zap.NewProductionEncoderConfig()`
2. `consoleCore := zapcore.NewCore(zapcore.NewConsoleEncoder(encCfg), zapcore.AddSync(os.Stderr), zapcore.DebugLevel)`
3. `jsonCore := zapcore.NewCore(zapcore.NewJSONEncoder(encCfg), zapcore.AddSync(os.Stdout), zapcore.InfoLevel)`
4. `logger := zap.New(zapcore.NewTee(consoleCore, jsonCore))`
5. `defer logger.Sync()`
6. `logger.Info("both sinks", zap.Int("port", 8080))`

### Карточки: энкодеры zap

- **JSON-энкодер** — Пишет одну строку JSON-объекта на запись: время эпохой, уровень строчными, поля в общем объекте. Формат для систем сбора и продакшена.
- **console-энкодер** — Колонки через табуляцию: ISO-время, уровень заглавными, caller, сообщение, поля JSON-объектом в хвосте. Формат для терминала.
- **EncoderConfig** — Набор настроек энкодера: ключи записи (messageKey, timeKey...) и правила сериализации (encodeTime, encodeLevel, encodeDuration). Пустой ключ — часть пропускается.
- **zapcore.NewTee** — Склеивает несколько ядер в одно: запись уходит во все ядра, каждое пишет своим энкодером в свой приёмник.
- **ConsoleSeparator** — Настройка разделителя колонок console-энкодера. По умолчанию — табуляция.

### Что дальше по курсу

Формат выбран — следующий вопрос, куда физически уезжают записи: приёмники, файлы и мультиплексирование — тема восьмого урока. А конфигурация целиком — уровень, энкодер, приёмники, стартовые поля в одной структуре — разбирается в уроке про [zap.Config](/courses/zap/lessons/07-zap-config/). Карта курса — на [его странице](/courses/zap/): после энкодеров начинается инженерия вывода — файлы, ротация и сэмплирование.

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

- Энкодер сериализует запись в байты: json — однострочный объект, console — колонки для терминала.
- console печатает ISO-время и уровень заглавными; json — эпоху и строчные; решает это EncoderConfig, а не метод.
- Поля в console-выводе не теряются — они печатаются JSON-объектом в конце строки.
- JSON-логирование даёт парсинг без регулярок, фильтры по любому полю и числа вместо строк.
- Мой дефолт: console там, где читает человек сейчас, JSON там, где читает машина или человек потом.
- zapcore.NewTee + два ядра: console на экран, JSON в файл — один логгер, два формата.

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

Соберите программу из урока с двумя пресетами и одной записью с тремя полями — сравните строки глазами. Затем соберите tee-вариант: console в stderr, JSON в stdout, и запустите его с перенаправлением 2>dev.log 1>prod.log — убедитесь, что файлы содержат разные форматы одной записи. В конце примените jq к prod.log: найдите все записи с уровнем info и посчитайте их количество.

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

### Можно ли писать JSON и локально?

Можно, и иногда нужно: если stdout контейнера сразу уходит в сборщик, локальный вывод должен совпадать с продовым. Для чистого чтения из терминала удобнее console, а JSON можно прогонять через jq.

### Чем console-энкодер лучше plain-текста из fmt.Println?

Запись всё равно собирается из полей: время, уровень и caller печатаются колонками автоматически, а поля не конкатенируются, а сериализуются. От структурного логирования консоль отличается только форматом, не содержимым.

### Что будет, если собрать JSON-энкодер с development-конфигом?

Получится валидный JSON с ключами T, L, M вместо ts, level, msg и временем ISO-строкой — development-конфиг рассчитан на колонки, а ключи там короткие. Выглядит сюрпризом, поэтому при смене Encoding проверяйте EncoderConfig.
