---
title: "Структурированные поля: zap.String и друзья"
description: "Поля zap: zap.String, zap.Int, zap.Duration, zap.Any, zap.Errors и zap.Namespace — типизированные поля против форматных строк, цена Any и имена полей."
course: "Zap: структурное логирование на Go"
url: "https://golangmaster.ru/courses/zap/lessons/04-strukturnye-polya/"
published: "2026-09-19"
updated: "2026-09-21"
minutes: 18
---

# Структурированные поля: zap.String и друзья

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

zap.String и zap.Int — конструкторы полей zap: маленькие структуры, которые превращают запись лога из фразы в документ. Поля zap — то, ради чего эта библиотека вообще появилась: тип известен в момент компиляции, рефлексия не нужна, сериализация летит. В этом уроке — вся семья конструкторов, цена универсального zap.Any и соглашение об именах.

### Поле против форматной строки

До структурных логов сообщение собирали строкой: fmt.Sprintf("order %s for user %d failed", orderID, userID). Форматная строка удобна ровно один раз — в момент написания. Дальше начинается цена: чтобы найти все заказы пользователя, нужен парсер, который знает, что после order идёт идентификатор, а user стоит до слова failed. Любая правка формулировки — «order %s of user %d was not processed» — ломает парсер и все дашборды на нём. Числа превращаются в текст и обратно: это лишняя работа и потеря типов — elapsed стал «45ms» и сортировать его по-настоящему нельзя. Поле zap устроено иначе: zap.String("order_id", orderID) — маленькая структура с ключом, номером типа и значением. Тип известен в момент компиляции, рефлексия не нужна, значение сериализуется напрямую в буфер. Запись собирается из полей как из деталей конструктора, и любое поле становится фильтром в системе сбора логов — без единой регулярки. Заметьте: Sprintf выполняется всегда, даже когда лог отсечён уровнем, — а типизированное поле на отключённом уровне не тратит ничего.

### zap.String и друзья: конструкторы полей

Конструкторы полей zap живут в самом пакете и названы по типу значения: zap.String для строк, zap.Int для int, zap.Bool, zap.Float64 и соседи по разрядности, zap.Duration для time.Duration, zap.Time для time.Time. Каждый возвращает zap.Field — структуру с именем ключа, номером типа и значением. Сериализация идёт напрямую: int упаковывается в JSON-число, Duration — в дробные секунды, Bool — в true или false. Отсюда скорость: на типизированном поле нет интерфейсов, рефлексии и лишних аллокаций — конструктор почти ничего не стоит, и именно это делает zap быстрым на горячем пути. Ниже — карта основных конструкторов; полная семья в документации пакета исчисляется десятками, но девяносто процентов жизни — восемь-десять штук из таблицы. Запомните принцип, а не список: имя конструктора = тип значения, и вы никогда не ошибётесь.

### Основные конструкторы полей zap

| Конструктор | Значение | В production-JSON |
| --- | --- | --- |
| zap.String("order_id", id) | string | "order_id":"A-1024" |
| zap.Int("port", p) | int | "port":8080 |
| zap.Bool("hit", b) | bool | "hit":false |
| zap.Float64("ratio", r) | float64 | "ratio":0.87 |
| zap.Duration("elapsed", d) | time.Duration | "elapsed":0.045 — секунды дробью |
| zap.Time("created_at", t) | time.Time | "created_at":1712345678.9 — эпоха |
| zap.Error(err) | error | "error":"connection refused" |
| zap.Strings("tags", s) | []string | "tags":["new","paid"] |

### Типичная ошибка: числа в zap.String

Самая живучая ошибка переходного периода — zap.String для чисел: zap.String("amount", "1490") вместо zap.Int. В JSON она почти незаметна: "amount":"1490" против "amount":1490 — разница в кавычках, и глаз её пропускает. Дальше начинается цена: в системе сбора поле становится строкой, сортируется как текст — 10 оказывается меньше 9, — фильтр amount > 1000 не работает, а график суммы заказов строится только через дорогой разбор на стороне запроса. То же с длительностью: "elapsed":"45ms" — красивая строка, из которой перцентили не посчитать; zap.Duration даёт число секунд, по которому p99 строится мгновенно. Что будет, если ошибку не поймать вовремя: тип поля система сбора фиксирует по первым записям, и поздний переезд со строки на число превращается в миграцию схемы с переиндексацией и перенастройкой дашбордов — не та правка, которую делают между делами. Ловится всё на ревью по одному взгляду на кавычки в примере записи, а локально — прогоните пример и посмотрите JSON глазами: тип каждого значения виден сразу. Правило то же, что и с Any: тип есть — берите типизированный конструктор; число в строке — это не формат, а потеря данных, которые вы ещё вчера обещали дашбордам.

### Редкие конструкторы, которые выручат

Кроме базовой восьмёрки в пакете zap живут конструкторы для особых случаев. zap.Binary(key, []byte) упакует байты в base64-строку — удобно для хэшей и идентификаторов протоколов. zap.Reflect(key, value) сериализует что угодно через рефлексию — это и есть механизм zap.Any для незнакомых типов. zap.Named(key, field) переименовывает готовое поле: взяли из библиотечной обёртки zap.Int с ключом n, назвали по-своему. zap.Group("user", zap.String("id", "42"), zap.String("role", "admin")) — современная альтернатива Namespace: поля группируются в объект на месте, без «открытия» и «закрытия». Пользуйтесь ими точечно: экзотика в логах — как соль в выпечке, её должно быть видно чуть-чуть. В девяноста процентах записей вам понадобится String, Int, Duration и Error — остальное вы вспомните, когда оно реально понадобится.

### Запись со всеми базовыми типами

```go
package main

import (
        "time"

        "go.uber.org/zap"
)

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

        start := time.Now()

        logger.Info("order processed",
                zap.String("order_id", "A-1024"),
                zap.Int("amount", 1490),
                zap.Bool("paid", true),
                zap.Float64("weight", 2.5),
                zap.Duration("elapsed", time.Since(start)),
                zap.Time("created_at", time.Now()),
        )
}
```

Шесть полей — шесть типов, и все сериализуются без рефлексии. В production-JSON запись выглядит так: {"level":"info","ts":...,"caller":"main/main.go:16","msg":"order processed","order_id":"A-1024","amount":1490,"paid":true,"weight":2.5,"elapsed":0.046,"created_at":1712345678.9}. Заметьте: Duration и Time превратились в числа — формат значения решает энкодер, и его можно поменять, о чём разговор в уроке про энкодеры. Запускайте локально: песочница zap не исполняет.

### zap.Any: универсальный, но дорогой

Когда значение приходит с типом any — из карты, из конфига, из чужой библиотеки — есть zap.Any: конструктор, который принимает что угодно и на лету разбирается, что это. Цена: внутри — интерфейс, а для незнакомых типов — рефлексия через zap.Reflect. Аллокации на горячем пути, против которых мы боролись весь курс, возвращаются: по замерам из репозитория типизированные поля на порядки дешевле Any с произвольной структурой. Моё правило: Any — для холодного кода, который выполняется раз при старте, например залогировать конфиг, и для значений, у которых тип всё равно не вытащить. На каждый запрос — типизированные конструкторы. Если поле встречается часто, не ленитесь: один раз вытащите тип — и получите и скорость, и честный JSON вместо сериализации через рефлексию, которая к тому же может напечатать не то, что вы ожидали. А если значение приходит раз в жизни процесса — смело берите Any и не делайте себе работу.

### Ошибки: zap.Error и zap.Errors

Ошибка — настолько частое поле, что у неё свой конструктор: zap.Error(err) кладёт значение под ключ error строкой — так, как её возвращает err.Error(). Внутри нет магии: конструктор понимает интерфейс error и сериализует текст. Если оборачиваете ошибку через fmt.Errorf с %w, в лог попадёт текст всей цепочки — как обычно и нужно при разборе. Для пачки ошибок есть zap.Errors: срез ошибок превратится в JSON-массив строк — удобно для батчей, где упало несколько элементов сразу. Отдельная история — стек-трейсы: на Error и выше production-пресет добавит их сам, а заставить стек на любом уровне можно опцией AddStacktrace; тонкости выбора, где стек уместен, а где он шум, разберём в уроке про ошибки и стеки. Практика же проста: любая запись уровня Warn и Error несёт zap.Error — без него расследование начинается с гадания, что именно не так.

### Ошибка, пачка ошибок и вложенность

```go
package main

import (
        "errors"

        "go.uber.org/zap"
)

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

        err := errors.New("connection refused")
        logger.Warn("db unavailable", zap.Error(err))

        batch := []error{
                errors.New("timeout"),
                errors.New("quota exceeded"),
        }
        logger.Error("batch failed",
                zap.Int("total", 120),
                zap.Errors("failures", batch),
                zap.Namespace("http"),
                zap.Int("status", 500),
        )
}
```

zap.Error напечатает "error":"connection refused", а zap.Errors соберёт массив: "failures":["timeout","quota exceeded"]. zap.Namespace устроен иначе: он не несёт значения, а открывает вложенный объект — все поля после него лягут внутрь: "http":{"status":500}. Так группируют связанные поля, чтобы не плодить имена вида http_status и http_method.

### Вложенность и массивы

Namespace — самый простой способ группировки: после zap.Namespace("http") каждый следующий конструктор пишет в открытый объект, пока другой Namespace его не сменить. Массивы — своя семья конструкторов: zap.Strings для среза строк, zap.Ints для чисел, zap.Errors для ошибок — JSON-массив вместо цикла с конкатенацией. Вложенность — вещь полезная, но с дозировкой: каждый уровень объекта — это точка в пути фильтра, и http.status в системе сбора — уже другой ключ, чем просто status; глубоко вложенный JSON люди читать перестают. Я держу правило: один уровень вложенности на источник — http, db, user; глубже — только если без этого поле путается с тёзкой из другого источника. Плоская запись с продуманными именами почти всегда удобнее трёхэтажных объектов, а миграции таких записей между системами сбора проходят без сюрпризов. Массивы без нужды тоже не плодите: список из двух элементов читается лучше, чем объект-оглавление с полем count.

### Имена полей: договоритесь заранее

Ключ поля — контракт на годы: по нему строятся дашборды, алерты и запросы в системе сбора, и переименовать его дороже, чем выбрать правильно. Практика индустрии: snake_case и нижний регистр — user_id, а не userId или UserID; короткие, но не огрызки: elapsed_ms понятнее, чем e или dur; единицы измерения — в имени: elapsed_ms, size_bytes, иначе поле живёт своей неопределённой жизнью и портит графики. Стабильность важнее красоты: если поле называется order_id, не заводите рядом orderID и orderId в соседнем сервисе — для систем сбора это три разных ключа, и каждый потребует своего правила разбора. Словарь полей стоит держать в одном месте — хотя бы в markdown рядом с кодом: какие поля обязательны для запросов, как называются идентификаторы, где единицы. Пятнадцать минут договорённости на старте проекта экономят недели разборов чужих графиков. Переименование — отдельная боль: если ключ всё-таки пришлось поменять, системы сбора требуют перенастройки правил, дашбордов и алертов — закладывайте переименование в план релиза, а не делайте между делами.

### Проверка: выбор конструктора

**Вопрос:** Каким конструктором записать значение типа time.Duration?

- zap.Time — это тоже про время
- ✅ zap.Duration — в JSON оно станет дробными секундами
- zap.Float64 с ручным делением на миллиард
- zap.Any — пусть сам разбирается

zap.Duration знает тип и сериализует длительность в секунды дробью. zap.Time — для моментов времени, zap.Float64 — просто число без семантики длительности.

### Проверка: цена zap.Any

**Вопрос:** Чем плох zap.Any на горячем пути?

- Ничем — он быстрее типизированных конструкторов
- ✅ Интерфейс и рефлексия: лишние аллокации на каждой записи
- Он не поддерживает строки и числа
- Он игнорирует уровень логгера

Any принимает значение типа any и для незнакомых типов идёт в рефлексию. Для частых записей — типизированные конструкторы; Any оставляют холодным путям вроде логирования конфига при старте.

### Как лягут поля с Namespace?

```go
logger.Info("request done",
        zap.Namespace("http"),
        zap.Int("status", 500),
        zap.String("method", "POST"),
)
```

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

- "status":500,"method":"POST" — плоско
- "http":[500,"POST"] — массивом
- ✅ "http":{"status":500,"method":"POST"}
- "namespace":"http" и поля плоско

Namespace открывает вложенный объект, и все поля после него пишутся внутрь http. Плоско поля легли бы без Namespace — это и есть разница.

### Что окажется под ключом failures?

```go
errs := []error{errors.New("timeout"), errors.New("no space")}
logger.Error("batch failed", zap.Errors("failures", errs))
```

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

- "failures":"timeout, no space" — строкой
- ✅ "failures":["timeout","no space"] — массивом строк
- "failures":2 — количеством
- Ключ не появится — Errors работает только с Warn

zap.Errors сериализует срез ошибок в JSON-массив строк — каждый элемент как err.Error(). Никаких конкатенаций и ручных циклов не нужно.

### Заполните пропуски в записи с полями

Соберите запись: сброс буфера, длительность, флаг и ошибка — каждая своим конструктором.

```go
package main

import (
        "errors"
        "time"

        "go.uber.org/zap"
)

func main() {
        logger, _ := zap.NewProduction()
        defer logger.___()

        err := errors.New("timeout")

        logger.Warn("slow call",
                zap.___("elapsed", 90*time.Millisecond),
                zap.___("retried", true),
                zap.___(err),
        )
}
```

Подсказка: Буфер сбрасывает Sync; для time.Duration есть zap.Duration; флаг — zap.Bool; конструктор ошибки принимает err без имени ключа.

Правильные ответы в пропусках: `Sync`, `Duration`, `Bool`, `Error`

### Соберите запись с полями

Расставьте строки программы в правильном порядке — от пакета до записи с длительностью.

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

1. `package main`
2. `import (`
3. `  "time"`
4. `  "go.uber.org/zap"`
5. `)`
6. `func main() {`
7. `  logger, _ := zap.NewProduction()`
8. `  defer logger.Sync()`
9. `  logger.Info("order processed",`
10. `    zap.String("order_id", "A-1024"),`
11. `    zap.Duration("elapsed", 45*time.Millisecond))`
12. `}`

### Карточки: конструкторы полей

- **zap.String** — Конструктор строкового поля: zap.String("order_id", id). Тип известен при компиляции — сериализация без рефлексии.
- **zap.Duration** — Поле для time.Duration; в production-JSON — дробные секунды: 0.045.
- **zap.Any** — Универсальный конструктор для any: внутри интерфейс и рефлексия для незнакомых типов. Дорогой — только для холодных путей.
- **zap.Error** — Кладёт ошибку под ключ error строкой. Обязательное поле для записей уровня Warn и Error.
- **zap.Errors** — Срез ошибок в JSON-массив строк: "failures":["timeout","quota exceeded"].
- **zap.Namespace** — Открывает вложенный объект: все поля после него пишутся внутрь — "http":{"status":500}.

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

Поля и уровни — вся дневная работа с логгером; дальше начинается инженерия. [SugaredLogger](/courses/zap/lessons/05-sugaredlogger/) упрощает запись: Infow с парами ключ-значение без конструкторов — удобство за небольшую плату в скорости. [Энкодеры](/courses/zap/lessons/06-json-console/) решают, как поля станут строкой: почему elapsed — число, а время — эпоха, и как получить человеко-читаемый формат. [Приёмники вывода](/courses/zap/lessons/08-vyvody-vhodnye/) отправляют записи в stdout, файл или оба сразу. А чтобы увидеть всё в связке с базой: медленный SQL-запрос с полями query, latency и rows — так мы будем логировать GORM в шестнадцатом уроке; самому ORM посвящён [отдельный курс](/courses/gorm/).

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

- Поле zap — типизированная структура Field: ключ, номер типа, значение; сериализация без рефлексии.
- Базовые конструкторы: String, Int, Bool, Float64, Duration, Time, Error.
- zap.Any принимает что угодно, но платит интерфейсом и рефлексией — только для холодных путей.
- zap.Errors — срез ошибок в JSON-массив; zap.Namespace открывает вложенный объект.
- Имена полей: snake_case, единицы в имени (elapsed_ms), стабильность во всех сервисах.
- Формат значения решает энкодер: Duration и Time в production-JSON превращаются в числа.

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

Возьмите функцию, которая делает работу, или имитируйте её time.Sleep. Залогируйте завершение вызовом Info с полями четырёх типов: строка, число, Bool и Duration через time.Since(start). Затем сгруппируйте два поля в Namespace и проверьте JSON глазами. В конце придумайте три имени полей в snake_case с единицами измерения для своего проекта.

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

### Почему zap.String быстрее fmt.Sprintf?

Конструктор поля хранит тип в структуре Field, и сериализация идёт напрямую в буфер без рефлексии. Sprintf каждый раз парсит форматную строку и строит промежуточную строку с аллокациями.

### Когда уместен zap.Any?

Для холодных путей: логирование конфига при старте, значения с неизвестным заранее типом. На горячем пути Any означает интерфейс и рефлексию — лишние аллокации на каждой записи.

### В каком стиле называть поля zap?

snake_case и нижний регистр: user_id, elapsed_ms. Единицы — в имени, имена — стабильные во всех сервисах: для систем сбора user_id и userId — два разных ключа.
