encoding/json и теги структур#
Модуль: Core Go · Уровень: Middle+/Senior
TL;DR#
encoding/json отображает структуры Go в JSON через рефлексию, ориентируясь на экспортируемые поля и теги вида json:"name,omitempty". При разборе неизвестные поля молча игнорируются (если не включён DisallowUnknownFields), а отсутствующее поле оставляет в структуре нулевое значение — отличить «не пришло», «пришло null» и «пришёл ноль» можно только указателем, json.RawMessage или отдельным флагом. omitempty опускает только «пустые» значения базовых типов, nil-указатели, пустые слайсы/мапы/строки, но не пустые структуры и не time.Time — для этого в Go 1.24 появился omitzero. Числа при разборе в any становятся float64 (лечится json.Decoder.UseNumber), []byte кодируется в base64, time.Time — в RFC 3339. Кастомное поведение задаётся MarshalJSON/UnmarshalJSON или encoding.TextMarshaler.
Простыми словами#
encoding/json превращает структуры Go в JSON и обратно, а управляется это тегами — строчками в обратных кавычках рядом с полями.
type User struct {
ID int `json:"id"`
Name string `json:"name"`
Email string `json:"email,omitempty"` // пропустить, если пусто
passwordH string // не экспортируется — не попадёт в JSON вообще
Internal string `json:"-"` // явно исключено
}Два правила, которые надо усвоить сразу. Первое: сериализуются только поля с большой буквы. Поле с маленькой буквы пакет json просто не видит — не по злому умыслу, а потому что для него оно недоступно. Второе: при разборе лишние поля молча игнорируются, а недостающие оставляют в структуре нулевое значение. Никаких ошибок не будет.
Отсюда вытекает главная практическая головная боль: отличить «поле не прислали», «прислали null» и «прислали 0» по обычному int невозможно — во всех трёх случаях там окажется ноль. Для операций частичного обновления это критично, и лечится указателем (*int): nil значит «не прислали», а указатель на ноль — «прислали ноль».
Если ты с другого языка. Аннотаций и настраиваемых мапперов уровня Jackson здесь нет — есть теги и, при необходимости, свои методы. Регистр имеет значение: приватное поле не сериализуется никак, обойти это рефлексией штатными средствами нельзя. Зато при разборе Go по умолчанию нечувствителен к регистру ключей, что иногда удивляет в обратную сторону.
Словарь этой статьи:
- struct tag — строка метаданных рядом с полем, которую читают библиотеки через рефлексию;
omitempty— опустить поле, если значение «пустое»;json.RawMessage— отложенный кусок JSON, который разберут позже;- маршалинг / анмаршалинг — кодирование в JSON и разбор из JSON.
Теория#
Правила отображения#
| Go | JSON |
|---|---|
struct | объект (только экспортируемые поля) |
map[string]T | объект (ключи сортируются) |
[]T | массив; nil-слайс → null, пустой → [] |
[]byte | строка в base64 |
time.Time | строка RFC 3339 |
int, float64 | число |
| указатель | значение или null |
any при разборе | float64, string, bool, []any, map[string]any, nil |
Два пункта из этой таблицы стабильно всплывают в багах: nil-слайс превращается в null, а не в [] (фронтенд к такому обычно не готов), и любое число, разобранное в any, становится float64 — на больших int64 это теряет точность.
// nil vs пустой слайс
var a []int
b := []int{}
json.Marshal(a) // null
json.Marshal(b) // []
// числа в any
var v any
json.Unmarshal([]byte(`{"id": 12345678901234567890}`), &v) // float64, точность потеряна
// лечение
d := json.NewDecoder(r)
d.UseNumber() // числа станут json.Number — строкой, которую можно разобрать точноТеги: полный синтаксис#
type Order struct {
ID string `json:"id"` // переименование
Total int `json:"total,omitempty"` // опустить, если 0
Note string `json:"-"` // никогда не сериализовать
Dash string `json:"-,"` // поле с именем "-" (редкий трюк)
Count int `json:"count,string"` // число как строка в JSON
Meta any `json:",omitempty"` // имя по умолчанию (Meta), но с omitempty
}Опция ,string полезна при работе с языками, где большие целые числа не переживают JSON (JavaScript теряет точность после 2^53).
omitempty и его границы#
omitempty опускает поле, если значение — «пустое»: false, 0, "", nil-указатель/интерфейс, пустой слайс, мапа или массив.
Чего он не делает:
type Response struct {
User User `json:"user,omitempty"` // структура НИКОГДА не опускается
When time.Time `json:"when,omitempty"` // time.Time — структура, тоже не опускается
}Пустая структура — не «пустое значение» с точки зрения пакета, поэтому в JSON уедет "user":{} и "when":"0001-01-01T00:00:00Z". Классические обходы: указатель на структуру (*User) или собственный MarshalJSON.
В Go 1.24 добавили опцию omitzero, которая проверяет именно нулевое значение типа и умеет спрашивать у типа метод IsZero():
type Response struct {
When time.Time `json:"when,omitzero"` // теперь пропускается, если время нулевое
}Отсутствие, null и ноль#
Самая частая архитектурная проблема при частичном обновлении (PATCH):
type UpdateUser struct {
Name *string `json:"name"` // nil = не прислали, &"" = прислали пустую строку
Email *string `json:"email"`
}
if u.Name != nil {
// поле пришло — обновляем, даже если пустое
}Указатель различает два состояния из трёх («не пришло» и «пришло значение»), но не отличает null от отсутствия — в обоих случаях будет nil. Если нужны все три, берут json.RawMessage или структуру-обёртку с флагом:
type Optional[T any] struct {
Value T
Set bool // поле присутствовало в JSON
Null bool // и было равно null
}Свои правила кодирования#
type Money struct {
Cents int64
}
func (m Money) MarshalJSON() ([]byte, error) {
return json.Marshal(fmt.Sprintf("%d.%02d", m.Cents/100, m.Cents%100))
}
func (m *Money) UnmarshalJSON(b []byte) error {
var s string
if err := json.Unmarshal(b, &s); err != nil {
return err
}
// ...разбор строки в центы
return nil
}Два правила: MarshalJSON объявляют на значении (иначе он не сработает для не-указателей), UnmarshalJSON — обязательно на указателе (иначе будет менять копию).
Для простых типов часто достаточно encoding.TextMarshaler/TextUnmarshaler — их дополнительно используют для ключей мапы и другие пакеты (encoding/xml, драйверы БД).
Ловушка бесконечной рекурсии:
func (u User) MarshalJSON() ([]byte, error) {
return json.Marshal(u) // рекурсия до переполнения стека!
}
// правильно — через тип-псевдоним без методов:
func (u User) MarshalJSON() ([]byte, error) {
type alias User // у alias нет метода MarshalJSON
return json.Marshal(alias(u))
}Строгий разбор#
По умолчанию разбор всеядный: неизвестные поля игнорируются, регистр ключей не важен ("name" попадёт в поле с тегом "Name"). Для API это часто плохо — опечатка клиента остаётся незамеченной.
d := json.NewDecoder(r.Body)
d.DisallowUnknownFields()
if err := d.Decode(&req); err != nil {
// теперь лишнее поле — это ошибка
}Для HTTP-хендлеров это же место, где ограничивают размер тела:
r.Body = http.MaxBytesReader(w, r.Body, 1<<20) // 1 МББез ограничения любой клиент может прислать гигабайтный JSON и выесть память сервера.
Потоковая обработка#
json.Marshal собирает весь результат в память. Для больших ответов и для последовательности объектов есть Encoder/Decoder:
enc := json.NewEncoder(w)
for _, item := range items {
enc.Encode(item) // пишет прямо в ответ, по объекту за раз
}
dec := json.NewDecoder(r)
for dec.More() {
var item Item
dec.Decode(&item) // разбирает поток, не держа всё в памяти
}Encoder.Encode добавляет перевод строки после каждого объекта — это ровно формат JSON Lines, удобный для логов и потоковых API.
Производительность#
encoding/json работает через рефлексию, но кэширует разбор структуры типа: план кодирования строится один раз на тип и переиспользуется. Тем не менее это заметно медленнее ручного кода.
Практические ориентиры:
- переиспользуй
Encoder/DecoderвместоMarshal/Unmarshalв горячих путях, чтобы не создавать промежуточные буферы; json.RawMessageоткладывает разбор части документа — полезно, когда нужен только один ключ из большого тела;- если профиль показывает, что сериализация стала узким местом, есть альтернативы:
bytedance/sonic(JIT),goccy/go-json, кодогенераторы вродеeasyjson. Но сначала — измерить; - в Go 1.25 появился экспериментальный
encoding/json/v2(включается черезGOEXPERIMENT=jsonv2) с более строгим и более быстрым API. В прод его пока не тащат, но знать о его существовании полезно.
Подводные камни / gotchas#
- Неэкспортируемые поля не сериализуются. Никогда, ни с каким тегом.
nil-слайс даётnull. Инициализируй[]T{}, если контракт обещает массив.- Числа в
any— этоfloat64. Большиеint64теряют точность; лечитсяUseNumber()или конкретным типом. omitemptyне работает для структур иtime.Time. Нужен указатель илиomitzero(Go 1.24+).- Разбор нечувствителен к регистру ключей.
{"NAME":"x"}попадёт в поле с тегомjson:"name". - Ошибка в теге не заметна.
json:"name, omitempty"(с пробелом) — тег с именемnameи опцией" omitempty", которая не сработает.go vetэто ловит. MarshalJSONна указателе не вызовется, если сериализуешь значение. Объявляй на значении.- Рекурсия в
MarshalJSONпри вызовеjson.Marshalна том же типе. - Тело запроса без ограничения размера — способ уронить сервис по памяти.
json.Marshalэкранирует HTML (<,>,&→<…) по умолчанию. Отключается черезEncoder.SetEscapeHTML(false).
Вопросы на собеседовании#
В: Как отличить «поле не прислали» от «прислали нулевое значение»?
О: Через указатель: nil означает отсутствие, ненулевой указатель — присланное значение, в том числе нулевое. Если нужно отличать ещё и явный null, берут json.RawMessage или обёртку с флагами присутствия.
В: Почему поле не попало в JSON?
О: Три типовые причины: поле неэкспортируемое, стоит тег json:"-", или сработал omitempty на пустом значении.
В: Что происходит с неизвестными полями при разборе?
О: По умолчанию они игнорируются. Чтобы получать ошибку, нужен Decoder.DisallowUnknownFields().
В: Почему omitempty не сработал на time.Time?
О: Потому что это структура, а omitempty не считает пустую структуру «пустым значением». Решения: указатель *time.Time, свой MarshalJSON или опция omitzero в Go 1.24+.
В: Во что превращается число при разборе в any и почему это опасно?
О: В float64. Целые числа больше 2^53 теряют точность, поэтому для идентификаторов это недопустимо. Лечится разбором в конкретный тип или Decoder.UseNumber().
В: Как переопределить сериализацию своего типа?
О: Реализовать MarshalJSON() ([]byte, error) на значении и UnmarshalJSON([]byte) error на указателе. Для простых типов часто достаточно encoding.TextMarshaler, который заодно работает для ключей мапы и в других пакетах.
В: Чем Decoder лучше Unmarshal в HTTP-хендлере?
О: Он читает из потока, не требуя держать всё тело в памяти, позволяет включить строгий режим и обработать последовательность объектов. Дополнительно тело стоит ограничить http.MaxBytesReader.
В: Как encoding/json работает под капотом и почему он не самый быстрый?
О: Через рефлексию: пакет обходит поля типа, читает теги и строит план кодирования, кэшируя его по типу. Рефлексия не инлайнится и аллоцирует, отсюда отставание от кодогенерации и JIT-реализаций.
На что копают на senior+#
- Проектирование API поверх ограничений пакета: как выражается частичное обновление, что отдавать вместо
nullв массивах, как версионировать контракт. - Понимание источника поведения: всё завязано на рефлексию и теги, отсюда и требование экспортируемости, и стоимость.
- Безопасность разбора: ограничение размера тела, строгий режим, отсутствие доверия к клиентскому вводу.
- Знание отличий
omitemptyиomitzeroи умение объяснить, почему потребовалась вторая опция. - Follow-up: «Как сериализовать интерфейс и разобрать обратно?» (нужен дискриминатор типа и ручной разбор через
RawMessage), «Почему ключи мапы сортируются?» (детерминированный вывод), «Когда переходить на кодогенерацию?».