---
title: "GORM и zap: логи базы данных"
description: "gorm logger zap: мост gorm logger.Interface на zap — Trace, SlowThreshold 200 мс, IgnoreRecordNotFoundError и почему SQL-параметры в логах опасны."
course: "Zap: структурное логирование на Go"
url: "https://golangmaster.ru/courses/zap/lessons/16-gorm-zap/"
published: "2026-09-19"
updated: "2026-09-21"
minutes: 18
---

# GORM и zap: логи базы данных

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

Мост gorm logger zap — это своя реализация интерфейса gorm logger.Interface поверх zap: ошибки SQL уходят в Error, медленные запросы — в Warn, обычный трейс — в Info. Разберём контракт из четырёх методов, порог SlowThreshold, обход ErrRecordNotFound, source location и риск, из-за которого SQL со значениями в логах держат под замком.

ORM в проекте — и у него свой голос. Дефолтный логгер GORM пишет текстом через стандартный log: цветные строки в stdout, мимо JSON-конвейера, без request_id и без полей. Молчит он по умолчанию почти всегда — уровень Warn: обычные запросы не показывает, пишет только ошибки и медленные запросы дольше двухсот миллисекунд. В итоге логирование gorm-слоя живёт отдельной жизнью: приложение пишет структурно, база — текстом, и связать «этот медленный SELECT был в этом запросе» можно только по времени вручную. Лекарство — мост: интерфейс logger.Interface у GORM маленький, четыре метода, и реализовать его на *zap.Logger — полчаса работы, которые соединяют оба потока в один. Здесь соберём мост целиком и разберём решения, которые в нём спрятаны.

### Gorm logger zap: контракт logger.Interface

Пакет gorm.io/gorm/logger описывает контракт: LogMode(level) Interface переключает минимальный уровень и возвращает логгера, Info, Warn и Error получают строку формата с аргументами для редких событий GORM — миграций и внутренних предупреждений, а Trace — сердце моста. Его вызывают после каждого SQL-вызова с тремя аргументами: контекст запроса, время начала и функция fc(), которая возвращает готовую SQL-строку и число затронутых строк, плюс err. Константы уровня свои: Silent=1, Error=2, Warn=3, Info=4 — выше, тем больше SQL попадает в лог. Уровни не совпадают с zap, и мост честно переводит: Error GORM — Error zap, Warn — Warn, трейс — Info или Debug по вашему выбору. Логирование gorm-слоя начинается с решения, куда направить обычные запросы: у нас они пойдут в Debug — иначе под Info утонет весь трафик приложения.

### Каркас моста: структура и простые методы

```go
import (
        gormlogger "gorm.io/gorm/logger"
)

type ZapGormLogger struct {
        ZapLogger                 *zap.Logger
        LogLevel                  gormlogger.LogLevel
        SlowThreshold             time.Duration
        IgnoreRecordNotFoundError bool
}

func (l ZapGormLogger) LogMode(level gormlogger.LogLevel) gormlogger.Interface {
        l.LogLevel = level
        return l
}

func (l ZapGormLogger) Info(ctx context.Context, format string, args ...any) {
        l.ZapLogger.Sugar().Infof(format, args...)
}

func (l ZapGormLogger) Warn(ctx context.Context, format string, args ...any) {
        l.ZapLogger.Sugar().Warnf(format, args...)
}

func (l ZapGormLogger) Error(ctx context.Context, format string, args ...any) {
        l.ZapLogger.Sugar().Errorf(format, args...)
}
```

Мост — обычная структура, реализующая интерфейс. LogMode возвращает копию с новым уровнем: так сделан и логгер GORM, значение-приёмник здесь безопасен. Info, Warn и Error у GORM приходят редкими пачками текста — их честнее всего отправлять через SugaredLogger, не выдумывая поля ради строк без структуры.

### Trace: три исхода одного вызова

Вся логика моста живёт в Trace, и исходов три. Первый — err не nil: запрос упал, уходим в Error с полем error, длительностью, числом строк и SQL — но с одной оговоркой, которой посвящён отдельный раздел ниже. Второй — ошибок нет, но время превысило SlowThreshold: медленный запрос уходит в Warn с полем elapsed рядом с порогом, и именно эти записи кормят дашборд производительности. Третий — обычный запрос: Info или Debug с длительностью и числом строк. Важная мелочь: fc() стоит времени — он собирает финальную строку SQL с подставленными значениями, поэтому его вызывают только когда уровень позволяет писать, а не безусловно. Экономия мелкая на запрос, но запросов тысячи, и в мостах, где fc() зовётся всегда, она видна в бенчмарках.

### Реализация Trace

```go
func (l ZapGormLogger) Trace(ctx context.Context, begin time.Time, fc func() (string, int64), err error) {
        if l.LogLevel <= gormlogger.Silent {
                return
        }

        elapsed := time.Since(begin)

        switch {
        case err != nil && l.LogLevel >= gormlogger.Error &&
                (!errors.Is(err, gorm.ErrRecordNotFound) || !l.IgnoreRecordNotFoundError):
                sql, rows := fc()
                l.ZapLogger.Error("sql failed",
                        zap.Error(err), zap.Duration("elapsed", elapsed),
                        zap.Int64("rows", rows), zap.String("sql", sql),
                )
        case l.SlowThreshold != 0 && elapsed > l.SlowThreshold && l.LogLevel >= gormlogger.Warn:
                sql, rows := fc()
                l.ZapLogger.Warn("slow sql",
                        zap.Duration("elapsed", elapsed), zap.Duration("threshold", l.SlowThreshold),
                        zap.Int64("rows", rows), zap.String("sql", sql),
                )
        case l.LogLevel >= gormlogger.Info:
                sql, rows := fc()
                l.ZapLogger.Debug("sql",
                        zap.Duration("elapsed", elapsed),
                        zap.Int64("rows", rows), zap.String("sql", sql),
                )
        }
}
```

Порядок веток — приоритет записи: ошибка важнее медлительности, медлительность важнее трейса. fc() вызывается внутри каждой ветки — строка SQL собирается только когда запись состоится. gorm.ErrRecordNotFound проверяется через errors.Is: обёрнутые ошибки всё равно распознаются.

### SlowThreshold 200 мс: порог медленных

SlowThreshold — главный рабочий параметр моста, и дефолт у GORM осознанный: двести миллисекунд. Это примерно граница, за которой интерактивный ответ уже чувствуется пользователем: быстрее — норма, дольше — повод посмотреть план запроса и индексы. Каждое превышение становится Warn-записью с elapsed и SQL — прямым материалом для алерта «медленный SQL растёт». Менять порог стоит не глобально, а по месту: отчётные запросы по своей природе медленные, и рубить их порогом в двести миллисекунд — заспамить Warn самим собой. У GORM для этого db.Session(&gorm.Session{Logger: мостСПорогом}) — отдельная сессия со своим мостом и, например, секундным порогом. Такую настройку честнее делать не ради одной функции, а через репозиторий отчётов: у медленного кода свой порог, у интерактивного — свой.

> **Типичная ошибка: мост без порога.** SlowThreshold — time.Duration, и структурный литерал без этого поля оставляет ноль. Ветка медленных выключается целиком условием l.SlowThreshold != 0: сервис молчит о запросах по секунде, Error при этом не пишется — ошибки формально нет. Порог — обязательный параметр моста наравне с IgnoreRecordNotFoundError: собирается ли мост руками, из конфига или из окружения, значение должно приходить явно.

### Настройки моста и их эффект

| Поле | Рабочее значение | Эффект |
| --- | --- | --- |
| LogLevel | gormlogger.Warn | тихий режим: ошибки и медленные; Info — всё SQL |
| SlowThreshold | 200 * time.Millisecond | порог Warn для медленных запросов |
| IgnoreRecordNotFoundError | true | NotFound не превращается в Error-алерт |
| ZapLogger | logger.Named("gorm") | все записи с полем logger: gorm |
| Session{Logger: ...} | свой мост на сессию | отчётные запросы живут с секундным порогом |

### Проверка: уровень медленного запроса

**Вопрос:** LogLevel — Warn, SlowThreshold — 200 мс, ошибок нет. Запрос выполнялся 350 мс. Что запишет мост?

- Ничего: ошибок не было
- Info: обычный трейс запроса
- ✅ Warn «slow sql» с elapsed 350мс и порогом 200мс
- Error: превышен порог производительности

Ветка медленных срабатывает раньше трейса: elapsed 350 мс больше порога, а уровень Warn разрешён. Ошибки не было — до Error дело не дошло. На Info-трейс запись не тянет: она уже заняла уровень выше.

### IgnoreRecordNotFoundError: NotFound — не авария

db.First(&u, id) на несуществующем ключе возвращает gorm.ErrRecordNotFound — и это нормальный поток приложения: проверка наличия записи, а не сбой. Дефолтный конфиг моста поднимает флаг IgnoreRecordNotFoundError, и NotFound не превращается в Error-запись: без него каждый 404-подобный сценарий заводил бы алерт, и через неделю алерты отключили бы целиком. Запись при этом не исчезает совсем — на уровне Info она остаётся как обычный трейс. Игнорировать NotFound стоит не всегда: если для вашей логики отсутствие записи — исключение, которое ломает инварианты, а не ожидаемая ветка, оставьте его ошибкой и поднимите флаг только в интерактивных сервисах. Как всегда с уровнями: правило задаёт бизнес-смысл, а не привычка.

### Проверка: NotFound

**Вопрос:** IgnoreRecordNotFoundError = true. Что запишет мост для First() по несуществующему ID при LogLevel = Info?

- Error с полем error: record not found
- Ничего: запись полностью подавляется
- ✅ Обычный трейс на Info без поля error: NotFound не считается ошибкой
- Warn: запись найдена не была

Флаг убирает только Error-исключительность NotFound: запрос прошёл штатно, и на Info попадёт обычный трейс с длительностью и SQL. Полное подавление даёт LogLevel Silent, а Warn для NotFound в мосте не существует.

### Контекст: SQL-записи с request_id

Trace принимает контекст, и мост может достать из него request_id посредника — но только если контекст вообще доехал до GORM. Вызов db.WithContext(r.Context()) прокидывает контекст запроса в слой базы, и в Trace он приходит тем же самым: мост вытаскивает идентификатор и добавляет полем к каждой SQL-записи. Без WithContext контекст пустой — background, и связка рвётся на первом же шаге: access-лог говорит, что запрос был, а SQL-записей с этим request_id нет. Практика, которая работает: репозитории принимают ctx первым аргументом и не зовут GORM без WithContext — правило закрепляется код-ревью, а не призывами. Проверка на два grep: один запрос, поиск request_id в access-записи и в SQL-записи — оба должны найтись.

### Source location: откуда вызван SQL

У моста GORM есть опция SourceLocation: с ней в запись добавляется файл и строка вызова — не моста, а места, где код позвал .Find или .Save. Разница с zap.AddCaller принципиальная: caller zap показал бы внутренности GORM, где лог реально писался, и был бы бесполезен. GORM решает это через utils.FileWithLineNum — обход стека до первого кадра вне orm. Опция отвечает на вопрос, который без неё разбирается глазами: один и тот же SELECT выполняется из пяти мест, и какое из них медленное? Цена — обход стека на каждый SQL-вызов, на фоне самого запроса она теряется, но включайте осознанно: в горячих циклах с тысячами мелких запросов разница видна. Рабочая схема: SourceLocation включена всегда, а выключают её по замеру, а не по вере.

### Подключение моста к gorm.Open

```go
db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{
        Logger: ZapGormLogger{
                ZapLogger: logger.Named("gorm"),
                LogLevel:  gormlogger.Warn,
                SlowThreshold: 200 * time.Millisecond,
                IgnoreRecordNotFoundError: true,
        },
})
if err != nil {
        panic(err)
}
```

Мост подаётся как обычное поле конфига GORM. Named("gorm") даёт записям поле logger: gorm — их видно в системе сбора отдельным потоком. Для разового включения подробностей по цепочке есть db.Debug(): он поднимает уровень до Info только у своего вызова, не трогая глобальные настройки.

### Почему SQL-параметры в логах — плохая идея

Вот место, где мост становится оружием. fc() возвращает SQL уже с интерполированными значениями — так устроен дефолтный вывод GORM: вместо плейсхолдеров $1 в записи стоит готовый запрос. Значит, в лог с каждым трейсом уходят значения аргументов: почта пользователя, номер карты, токен из WHERE, поле с персональными данными из INSERT. Логи читают шире, чем базу: дежурные, системы сбора, бэкапы логов — и регуляторные требования к ним жёстче. Мой рабочий компромисс, за который меня ругали: в интерактивных сервисах SQL пишем на Debug и маскируем значения, в системных интеграциях — пишем как есть, но канал с ограниченным доступом и коротким сроком хранения. Универсального ответа нет, но осознанное решение должно быть: «просто логировать SQL» — это решение писать персональные данные всем, у кого есть доступ к логам.

> **INSERT с паролем — не гипотеза.** Худший реальный случай моста: запрос INSERT или UPDATE, где значение поля — пароль, токен или сессионная cookie. Такие записи попадают в Error-вывод при сбое запроса, то есть в момент, когда дампят больше всего. Маскируйте известные поля на уровне моста или логируйте вместо SQL его подпись: имя таблицы и число строк.

### Что запишет молчащий мост?

```go
bridge := ZapGormLogger{
        ZapLogger: logger.Named("gorm"),
        LogLevel:  gormlogger.Silent,
        SlowThreshold: 200 * time.Millisecond,
}

db, _ := gorm.Open(postgres.Open(dsn), &gorm.Config{Logger: bridge})

db.Raw("SELECT pg_sleep(1)").Scan(&result)
```

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

- Error: запрос выполнялся дольше секунды
- Warn «slow sql»: elapsed больше порога
- ✅ Ничего: Silent отключает все записи, включая медленные
- Info: Raw-запросы логируются всегда

Первая же проверка в Trace — выход при LogLevel <= Silent. Порог SlowThreshold не важен: он проверяется только на уровне Warn и выше. Полное молчание — легальный режим для нагрузочных тестов и шумных миграций.

### Соберите Trace

Восстановите каркас Trace: длительность, SQL, запись ошибки.

```go
elapsed := time.___(begin)

sql, rows := ___()

if err != nil {
        l.ZapLogger.___("sql failed", zap.Error(err), zap.String("sql", sql))
}
```

Подсказка: Время считают через Since, строку SQL отдаёт fc, ошибку пишут на уровне Error.

Правильные ответы в пропусках: `Since`, `fc()`, `Error`

### Соберите ветвление Trace

Расставьте строки Trace по порядку: от длительности до веток записи.

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

1. `elapsed := time.Since(begin)`
2. `switch {`
3. `case err != nil:`
4. `log.Error("sql failed", zap.Error(err))`
5. `case elapsed > l.SlowThreshold:`
6. `log.Warn("slow sql", zap.Duration("elapsed", elapsed))`

### Карточки: GORM и zap

- **logger.Interface** — Контракт GORM: LogMode, Info, Warn, Error и Trace(ctx, begin, fc, err) — всё SQL-логирование живёт в Trace.
- **fc() в Trace** — Ленивая функция: возвращает SQL-строку с подставленными значениями и число затронутых строк. Вызывают, только если запись состоится.
- **SlowThreshold** — Порог медленного запроса, дефолт 200 мс: превышение — Warn с elapsed. Для отчётов — своя сессия с большим порогом.
- **gorm.ErrRecordNotFound** — Нормальный поток «нет записи», не авария: IgnoreRecordNotFoundError оставляет его трейсом вместо Error.
- **SourceLocation** — Файл и строка вызова .Find через utils.FileWithLineNum: отличает пять мест одного медленного SELECT. Цена — обход стека.
- **db.Debug()** — Разовое включение Info для цепочки вызовов: видно SQL конкретного места без смены глобального уровня.

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

Мост встраивается в конвейер из уроков про [HTTP-middleware](/courses/zap/lessons/15-http-middleware/) и request id: SQL-записи с request_id соединяются с access-логом в одну хронологию. Цену логирования каждого запроса в цифрах считают в материале про [производительность zap](/courses/zap/lessons/19-proizvoditelnost/), а полю error из ветки ошибки место в уроке про [zap.Error и стек-трейсы](/courses/zap/lessons/18-oshibki-steki/). Подключение самой GORM и DSN разбирались в уроке про [подключение к базе](/courses/gorm/lessons/02-podklyuchenie/) курса GORM; карта остальных тем — на [странице курса](/courses/zap/).

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

- Мост — реализация logger.Interface: LogMode, Info, Warn, Error и сердцевина Trace.
- Trace разбирает три исхода: ошибка — Error, превышение порога — Warn, обычный запрос — Info или Debug.
- SlowThreshold 200 мс — дефолт GORM; для отчётных запросов порог поднимают отдельной сессией.
- IgnoreRecordNotFoundError оставляет NotFound обычным трейсом: это поток приложения, а не авария.
- fc() возвращает SQL с подставленными значениями: персональные данные уходят в лог вместе с запросом.
- SourceLocation показывает место вызова GORM: caller zap без неё бесполезен.
- db.Debug() — разовая лупа: Info на одну цепочку вызовов.

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

Реализуйте ZapGormLogger целиком и подключите к локальной базе: Named("gorm"), LogLevel Warn, порог 200 мс, IgnoreRecordNotFoundError true. Проверьте три сценария по JSON-выводу: упавший запрос на несуществующей таблице — Error, запрос с pg_sleep(0.3) — Warn с elapsed, First() по отсутствующему ID — тишина. Затем поднимите LogLevel до Info и найдите в записях поле logger со значением gorm. После этого вызовите тот же репозиторий без WithContext и найдите отсутствие request_id в SQL-записях — связка рвётся ровно на этом шаге.

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

### Почему бы не использовать logger.Default с перенаправлением вывода?

logger.Default пишет через стандартный log в заданный io.Writer — текстом, без полей. Его можно направить в файл или буфер, но в JSON-конвейер он поля не принесёт. Мост нужен именно ради структуры: elapsed, rows, sql как отдельные значения.

### Куда направлять обычные SQL-запросы — в Info или Debug?

В Debug: полный трейс каждого запроса полезен при разборе и вреден в постоянном потоке. Info оставьте для окружений, где SQL-лог ведут намеренно — локальная разработка, воспроизведение бага под флагом.

### Как связать SQL-записи с конкретным HTTP-запросом?

Через контекст: если Trace принимает ctx с request_id от middleware, мост может достать идентификатор и добавить полем. GORM передаёт контекст с WithContext в вызовах — прокинуть его в мост можно вытащив логгер из request context.
