Структурированное логирование в Go: slog, zerolog и zap

(последнее обновление от )

log.Println("user logged in:", userID) работает ровно до того момента, когда сервис уезжает в прод. Логов становится миллионы строк в день, и начинается боль. Вы хотите найти все события одного пользователя или отфильтровать по уровню ошибки, а у вас плоский текст, который приходится разбирать регулярками. Структурированное логирование решает эту проблему: каждая запись не строка, а набор пар ключ-значение, который машина читает без угадывания. В этой статье разберу три способа логировать структурно в Go: стандартный log/slog, zerolog и zap. Покажу рабочий код каждого, сравню по аллокациям и удобству, дам ориентир, что брать под вашу задачу.

Если вы только осваиваете язык, сначала имеет смысл пройти базовый маршрут из «Go для начинающих: дорожная карта». Здесь я считаю, что вы уже писали на Go и упирались в логи.

Почему структурное, а не строковое

Разница между строковым и структурированным логом видна сразу, как только вы пытаетесь что-то с логом сделать, кроме как прочитать глазами.

Вот типичная строковая запись:

2026/06/21 09:25:37 order 4821 for user 137 failed: payment declined

Прочитать её человеку легко. А теперь попробуйте машинно достать все заказы пользователя 137. Придётся писать регулярку, которая ломается, как только кто-то поменяет формат сообщения. Идентификатор пользователя здесь не данные, а кусок текста.

Структурная запись та же по смыслу, но устроена иначе:

{"time":"2026-06-21T09:25:37+03:00","level":"ERROR","msg":"order failed","order_id":4821,"user_id":137,"reason":"payment declined"}

Теперь user_id это поле. Loki, Elasticsearch, CloudWatch и любой другой сборщик логов индексируют его и дают фильтровать запросом user_id=137 AND level=ERROR. Сообщение msg стало стабильным («order failed»), а переменные части уехали в отдельные поля. Это и есть суть подхода: текст для человека отделён от данных для машины.

Из этого вытекают три практических свойства, которые дают все структурные логгеры:

Дальше посмотрим, как это выглядит в каждой из трёх библиотек.

log/slog: стандартная библиотека

С Go 1.21 структурное логирование въехало в стандартную библиотеку под именем log/slog. Это важная веха: до неё каждый проект тащил стороннюю зависимость, а теперь для большинства случаев ничего ставить не нужно.

Минимальный пример:

package main

import (
    "log/slog"
    "os"
)

func main() {
    // JSONHandler пишет записи построчным JSON в stdout.
    logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))

    logger.Info("order failed",
        "order_id", 4821,
        "user_id", 137,
        "reason", "payment declined",
    )
}
$ go run main.go
{"time":"2026-06-21T09:25:37.12+03:00","level":"INFO","msg":"order failed","order_id":4821,"user_id":137,"reason":"payment declined"}

Атрибуты передаются как чередующиеся пары ключ-значение прямо в метод. Удобно, но у такого API есть ловушка: легко сбить чётность и передать ключ без значения. Поэтому для горячего кода есть строго типизированный вариант через slog.Int, slog.String и так далее, который заодно быстрее, потому что не гоняет значения через any:

logger.Info("order failed",
    slog.Int("order_id", 4821),
    slog.Int("user_id", 137),
    slog.String("reason", "payment declined"),
)

Handler решает, как и куда писать

Ключевая абстракция slog это Handler. Логгер только собирает запись (Record), а форматирование и вывод полностью на хендлере. В стандартной библиотеке их два: TextHandler (пары key=value, удобно читать локально) и JSONHandler (для прода). Типовая практика это выбирать хендлер по окружению:

func newLogger() *slog.Logger {
    opts := &slog.HandlerOptions{Level: slog.LevelDebug}

    var h slog.Handler
    if os.Getenv("ENV") == "production" {
        h = slog.NewJSONHandler(os.Stdout, opts)
    } else {
        h = slog.NewTextHandler(os.Stdout, opts)
    }
    return slog.New(h)
}

Интерфейс Handler открытый, поэтому экосистема быстро обросла сторонними хендлерами: цветной вывод для разработки, отправка в разные бэкенды, обёртки над zap и zerolog. Вы пишете код против стандартного *slog.Logger, а бэкенд меняете подменой хендлера. Это сильная сторона: API в стандартной библиотеке, реализация сменная.

Контекстные поля и группы

Метод With возвращает логгер с уже прицепленными атрибутами. Это основной способ протащить request_id через всю обработку запроса, не повторяя его в каждом вызове:

// Один раз на запрос — дальше поля едут сами.
reqLogger := logger.With(
    slog.String("request_id", reqID),
    slog.String("method", r.Method),
)

reqLogger.Info("request started")
// ... обработка ...
reqLogger.Info("request finished", slog.Int("status", 200))

Обе записи получат request_id и method. Связанные поля можно сгруппировать через slog.Group, тогда в JSON они лягут вложенным объектом:

logger.Info("request finished",
    slog.Group("http",
        slog.Int("status", 200),
        slog.Duration("latency", 42*time.Millisecond),
    ),
)
// → "http":{"status":200,"latency":42000000}

Связь с context.Context

У методов есть варианты с суффиксом Context: InfoContext, ErrorContext и так далее. Они принимают context.Context первым аргументом и передают его хендлеру. Это нужно, чтобы хендлер мог достать значения из контекста, например тот же request_id или трейс-идентификатор, и добавить их в каждую запись автоматически:

logger.InfoContext(ctx, "user authenticated", slog.Int("user_id", 137))

Сам по себе slog поля из контекста не достаёт, для этого нужен хендлер, который это умеет, или своя обёртка. Но точка интеграции в API заложена. Про устройство контекста, передачу значений и типичные ошибки я подробно писал в отдельной статье про context.Context.

Логирование ошибок

Отдельная привычка, которая окупается: класть ошибку в поле, а не вклеивать в текст сообщения. Тогда msg остаётся стабильным, а ошибку видно отдельным полем.

if err != nil {
    logger.Error("failed to save order", slog.Any("error", err))
    return err
}

Здесь хорошо ложится связка с обёрнутыми ошибками через %w. О том, как правильно строить цепочки ошибок, чтобы в логе была видна вся история, я писал в разборе идиоматичной обработки ошибок в Go.

zerolog: ставка на ноль аллокаций

github.com/rs/zerolog появился задолго до slog и сделал ставку на одну идею: писать JSON-лог вообще без лишних аллокаций. Достигается это цепочечным API, где каждое поле сразу дописывается в байтовый буфер, а не складывается в промежуточный слайс.

package main

import (
    "os"

    "github.com/rs/zerolog"
)

func main() {
    logger := zerolog.New(os.Stdout).With().Timestamp().Logger()

    logger.Error().
        Int("order_id", 4821).
        Int("user_id", 137).
        Str("reason", "payment declined").
        Msg("order failed")
}
$ go run main.go
{"level":"error","order_id":4821,"user_id":137,"reason":"payment declined","time":"2026-06-21T09:25:37+03:00","message":"order failed"}

Логика чтения такая: logger.Error() открывает событие нужного уровня, дальше цепочкой добавляются типизированные поля, а Msg(...) завершает запись и отправляет её в вывод. Пока не вызван Msg или Send, запись не материализуется, поэтому отключённый уровень почти ничего не стоит.

Контекстные поля задаются через With(), который возвращает новый логгер:

reqLogger := logger.With().
    Str("request_id", reqID).
    Logger()

reqLogger.Info().Msg("request started")

Сильные стороны zerolog это скорость, минимум аллокаций и аккуратный читаемый JSON из коробки. Цена видна в API. Типизированная цепочка с обязательным Msg в конце непривычна. Легко забыть Msg — и запись просто не появится в логе. Линтеры это ловят, но осадок остаётся. Ещё zerolog ориентирован на JSON, человекочитаемый цветной вывод есть через ConsoleWriter, но он заметно медленнее и предназначен для локальной разработки.

zap: производительность и экосистема от Uber

go.uber.org/zap это логгер от Uber, который годами был стандартом де-факто для нагруженных Go-сервисов. Он предлагает два API: быстрый строго типизированный Logger и удобный, но чуть более медленный SugaredLogger.

Типизированный вариант похож на slog с типами полей:

package main

import "go.uber.org/zap"

func main() {
    logger, _ := zap.NewProduction()
    defer logger.Sync() // сбросить буфер перед выходом

    logger.Error("order failed",
        zap.Int("order_id", 4821),
        zap.Int("user_id", 137),
        zap.String("reason", "payment declined"),
    )
}

zap.NewProduction() сразу даёт JSON-вывод в stderr, уровень Info и набор разумных дефолтов. Для разработки есть zap.NewDevelopment() с человекочитаемым форматом. Конструктор Field (zap.Int, zap.String и прочие) это аналог типизированных атрибутов slog, и работает он так же быстро, без прохода значений через any.

SugaredLogger жертвует частью скорости ради эргономики и принимает пары ключ-значение как slog по умолчанию:

sugar := logger.Sugar()
sugar.Errorw("order failed",
    "order_id", 4821,
    "user_id", 137,
    "reason", "payment declined",
)

Один важный момент, про который забывают: defer logger.Sync(). Zap буферизует вывод, и без Sync последние записи могут не успеть записаться при завершении программы. Это особенно критично при остановке сервиса, поэтому Sync логгера логично встроить в процедуру корректного завершения. Как организовать остановку сервиса аккуратно, с дренажом и flush, я разбирал в статье про graceful shutdown в Go.

Главный аргумент за zap сегодня это не столько скорость (slog с типизированными атрибутами уже близок), сколько зрелая экосистема: интеграции с веб-фреймворками, ротация через lumberjack, готовые конфиги, годами выверенные в проде. Если ваш стек уже стоит на zap, мигрировать никуда не нужно.

Что с производительностью

Производительность это первый аргумент в спорах про логгеры. И обычно он переоценён. Дам ориентиры, а не точные цифры: числа зависят от версий, железа и набора полей. Мерить надо на своей нагрузке.

Расклад по аллокациям примерно такой. zerolog и типизированный zap.Logger спроектированы под ноль аллокаций на запись в горячем пути и в синтетических бенчмарках идут вровень в лидерах. slog с типизированными атрибутами (slog.Int, slog.String) тоже очень быстр и аллоцирует мало. slog и zap.SugaredLogger с нетипизированными парами ключ-значение медленнее, потому что значения проходят через any и могут вызывать упаковку (boxing) в кучу. Порядок предпочтения по скорости: типизированный API всегда быстрее парного, в любой из библиотек.

Но вот что важно держать в голове. Если ваш сервис пишет тысячи логов в секунду в горячем цикле, разница между логгерами может стать заметной, и тогда есть смысл в zero-alloc решении. Если же логирование это сотни записей в секунду (а так у абсолютного большинства сервисов), разница в наносекундах на запись теряется на фоне любого сетевого вызова или запроса к базе. Оптимизировать логгер ради синтетического бенчмарка, который не отражает вашу нагрузку, это классическая преждевременная оптимизация.

Поэтому правильный путь, как всегда, измерить на реальном профиле. Как ставить бенчмарки аккуратно, со статистикой, а не на глаз, я писал в статье про бенчмарки и оптимизацию в Go.

Куда писать логи

Отдельный вопрос, не зависящий от выбора библиотеки: куда направлять вывод. Частая ошибка новичков это открывать файл прямо из приложения и писать в него, заодно прикручивая ротацию по размеру. В контейнерах и под systemd так делать не нужно.

Современный подход (его формулирует методология 12-factor) это писать логи в stdout/stderr и не думать о хранении. Дальше за вас всё делает среда: Docker и Kubernetes подхватывают поток, systemd складывает его в journald, а сборщик логов отправляет в Loki или Elasticsearch. Приложение не знает и не должно знать, где в итоге окажется запись. Поэтому во всех примерах выше вывод идёт в os.Stdout или os.Stderr, и это не упрощение для статьи, а рабочий дефолт.

Ротация файлов через lumberjack остаётся актуальной только там, где сервис пишет прямо в файл на хосте без оркестратора. Если у вас Kubernetes, ротацией занимается он, а не ваш Go-код.

Сравнение

Критерийslogzerologzap
ЗависимостьСтандартная библиотека (Go 1.21+)Внешняя (rs/zerolog)Внешняя (uber-go/zap)
APIПары и типизированные атрибутыЦепочка с Msg() в концеТипизированный Logger + SugaredLogger
АллокацииМало (типизированно), больше с парами~ноль~ноль (Logger), больше у Sugared
Формат из коробкиText и JSONJSON (+ ConsoleWriter)JSON (+ Development-режим)
Сменный бэкендДа, через HandlerНетНет
ЭкосистемаРастёт, стандарт по умолчаниюЗрелаяСамая богатая

Что выбирать

Мой ориентир на 2026 год простой.

Берите slog по умолчанию для новых проектов. Это стандартная библиотека, не тащит зависимость, по скорости достаточен для абсолютного большинства сервисов, а абстракция Handler даёт редкую возможность сменить бэкенд логирования, не переписывая код приложения. Когда сомневаетесь, это правильный выбор по умолчанию.

Берите zap, если вы уже в его экосистеме или вам нужна самая богатая обвязка из коробки: проверенные интеграции, ротация, годами обкатанные в проде конфиги. Мигрировать рабочий сервис с zap на slog ради чистоты обычно не стоит усилий.

Берите zerolog, если вам нравится его цепочечный API и важны минимальные аллокации в действительно горячем пути логирования. Это нишевый, но обоснованный выбор для сервисов, которые пишут очень много логов и где вы померили, что это узкое место.

Чего точно не стоит делать, это смешивать несколько логгеров в одном сервисе или выбирать библиотеку по строчке в синтетическом бенчмарке. Согласованность важнее микросекунд: один логгер, один формат, одни соглашения по именам полей по всему сервису.

Итог

Структурированное логирование это переход от строк для человека к полям для машины, и в современном Go он почти бесплатен. Стандартный log/slog закрывает потребности большинства проектов: типизированные атрибуты для скорости, With для контекстных полей, Handler для сменного бэкенда и точка интеграции с context.Context. zerolog и zap остаются сильными альтернативами там, где важны нулевые аллокации или зрелая экосистема, но точкой отсчёта для нового кода я теперь беру slog.

Главное в логировании это не выбор библиотеки, а дисциплина: стабильные сообщения, переменные части в полях, ошибки отдельным полем, единые имена ключей по сервису. Логгер только инструмент, а пользу приносит то, как вы им пользуетесь.

Дальше по теме стоит посмотреть на обработку ошибок, чтобы в логах была видна вся цепочка сбоя, и на graceful shutdown, чтобы при остановке сервиса не потерять последние записи.


Теги: