Deep Engineering
Средний·Опубликовано·65 МИН

JSON в Go: теги, omitempty и что encoding/json делает молча

Собеседование по encoding/json (v1) идёт лестницей: какие поля попадают в JSON — что считается пустым для omitempty — почему ключ NAME заполнил поле name — куда делся лишний ключ — почему id из базы приехал на единицу меньше — как написать свой MarshalJSON и не уронить процесс. Прогон отвечает на каждый шаг: пустая структура и нулевое time.Time проходят сквозь omitempty; Unmarshal не смотрит на регистр и молча пропускает лишнее; 9007199254740993 в any становится 9007199254740992; MarshalJSON на указателе не вызывается у значения без адреса; а в замере на go1.24.7, linux/amd64, Marshal структуры оказался дороже ручной сборки той же строки меньше чем вдвое — дорогой стороной был Unmarshal.

Полное техническое изложение

TL;DR

encoding/json (v1 — пакет, который импортируют как "encoding/json") превращает значение Go в текст JSON и обратно, а что куда попадёт, решает по правилам, которые читает из типа. Ключ — имя экспортируемого поля или тег json:"…"; неэкспортируемые поля пакет пропускает (кроме встроенных структур — их экспортируемые поля поднимаются наверх). Marshal обходит поля структуры, Unmarshal ищет поле для каждого ключа входа. Как пакет читает теги через reflect, разобрано в уроке о рефлексии; здесь — что он с ними делает.

Главное следствие: почти всё, что идёт не так, идёт молча. Пустая структура и нулевое time.Time проходят сквозь omitempty — {"v":"0001-01-01T00:00:00Z"}. Ключ "NAME" заполняет поле с тегом name, лишний ключ "role" пропадает без ошибки, а поле, которого нет во входе, сохраняет старое значение. Ошибку пакет возвращает только там, где сделать вид, что всё в порядке, уже нельзя: NaN, канал, неверный тип значения, число, которое не помещается в поле int64.

Дальше — то, что отличает понимание от пересказа. Число в any становится float64, и 9007199254740993 в прогоне превратилось в 9007199254740992 — по дороге туда и обратно, без ошибки. Это потеря точности, а не ошибка диапазона: то же число больше MaxInt64 в поле int64 даёт UnmarshalTypeError. Спасают Decoder.UseNumber, поле int64 или тег ,string. nil-срез — это null, пустой — []. MarshalJSON на указателе не вызывается у значения без адреса: json.Marshal(inv) дал {"total":1234}, а json.Marshal(&inv) — {"total":"12.34"}. Вызвать json.Marshal(e) внутри своего MarshalJSON — переполнение стека и падение процесса. Цену урок измерял в одной конфигурации — go1.24.7, linux/amd64, два ядра: там Marshal структуры из пяти полей оказался дороже ручной сборки той же строки меньше чем вдвое, а Unmarshal — в разы дороже Marshal. На своей машине и версии это нужно мерить заново: в Go 1.27, по release notes, encoding/json работает уже на другой реализации.

Порог входа
Перед уроком достаточно понимать
  • как объявить структуру и что такое экспортируемое имя — урок о структурах;
  • что тег поля — строка в типе, которую библиотеки читают через reflect, — урок о рефлексии;
  • что такое any (пустой интерфейс) и что значит «тип реализует интерфейс» — подробно в уроке об интерфейсах;
  • что метод с получателем-указателем есть не у каждого значения — подробно в уроке о получателе метода; для Механизма 5 пригодится и встраивание.
Заранее знать не нужно
  • что считается пустым для omitempty и чем от него отличается omitzero (есть с Go 1.24);
  • регистр ключей, лишние ключи, DisallowUnknownFields,UseNumber, тег ,string;
  • свой MarshalJSON, рекурсия в нём и тип-двойник; строгий разбор тела HTTP-запроса — полный пример; encoding/json/v2 (эксперимент в go1.25, отдельный пакет в Go 1.27).

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

Типичная лестница такая:

  1. «Как сделать, чтобы поле называлось в JSON иначе?» — разминка: тег json:"name".
  2. «Что делает omitempty?» — и сразу следом: «а пустая структура? а time.Time{}?». Здесь отвечают по памяти и ошибаются.
  3. «Что будет, если в JSON лишнее поле? А если ключ в другом регистре?» — проверяют, знает ли кандидат, что Unmarshal молчит.
  4. «Почему id приехал не тот?» — числа в interface{}.
  5. «Чем nil-срез отличается от пустого в JSON?» — контракт API с фронтендом.
  6. «Как написать свой MarshalJSON?» — и где в нём рекурсия.

Стержень у лестницы один: encoding/json по умолчанию выбирает «продолжить», а не «сообщить». Пропало поле, не нашёлся ключ, округлилось число при выборе float64 — разбор идёт дальше. Но это не значит, что ошибок нет вовсе: значение не того типа или целое, которое не помещается в поле, дают ошибку, и её нельзя игнорировать. Каждый механизм ниже — один из случаев, где видна граница между тем и другим.

На чём проверено: прогоны — go1.24.7 и go1.25.1, linux/amd64; цитаты документации сверены по go doc и release notes 14.10.2026. Всё, что урок называет поведением encoding/json, относится к v1 — пакету "encoding/json". На эту дату вышли Go 1.26 и 1.27; урок на них не запускался, а что изменилось в Go 1.27 по release notes, — в «Истории версий».

База: поле становится ключом, тег меняет правило

Документация Marshal формулирует правило одной фразой:

Each exported struct field becomes a member of the object, using the field name as the object key, unless the field is omitted for one of the reasons given below.

перевод

Каждое экспортируемое поле структуры становится членом объекта, и ключом служит имя поля, если только поле не пропущено по одной из причин, перечисленных ниже.

— Пакет encoding/json — Marshal
контракт языкаПравила выбора полей, omitempty, разбора ключей и чисел записаны в документации encoding/json; прогон bench/gojson/internals.go их иллюстрирует, а не устанавливает.

Одна и та же структура без тегов и с ними.

Фрагмент bench/gojson/internals.go — объявления типов:

GO
type Address struct {
    City string `json:"city"`
}
 
type Account struct {
    ID       int
    Name     string
    Email    string
    Tags     []string
    Address  Address
    password string
}
 
type AccountTagged struct {
    ID       int      `json:"id"`
    Name     string   `json:"name"`
    Email    string   `json:"email,omitempty"`
    Tags     []string `json:"tags,omitempty"`
    Address  Address  `json:"address,omitempty"`
    Password string   `json:"-"`
}

Вывод bench/gojson/internals.go, раздел 1:

1) ОДНА СТРУКТУРА БЕЗ ТЕГОВ И С ТЕГАМИ
──────────────────────────────────────
  без тегов:
    {"ID":7,"Name":"Ann","Email":"","Tags":null,"Address":{"city":""}}
  с тегами name, omitempty и "-":
    {"id":7,"name":"Ann","address":{"city":""}}

Без тегов ключ — имя поля Go с заглавной буквы, и выходят все экспортируемые поля, пустые тоже. Неэкспортируемого password нет — пакет ходит по полям через reflect и такие поля пропускает; почему, разобрано в уроке о рефлексии. Исключение одно — неэкспортируемая встроенная структура: сама она не видна, но её экспортируемые поля поднимаются наверх.

Вывод, раздел 1 (продолжение):

  неэкспортируемое встроенное поле:
    Outer{inner{X: 1}, 2}              -> {"X":1,"Y":2}

Там же — как тег читается через StructTag.Get и что значат json:"" и json:"-"; повторять не будем.

С тегами ключи стали строчными, пустые Email и Tags ушли, Password с тегом "-" не вышел. А пустой Address с тем же omitempty остался — это первый вопрос лестницы, на котором спотыкаются, и Механизм 1 о нём.

Этого достаточно для ответа уровня Junior: в JSON выходят экспортируемые поля; ключ — имя поля или имя из тега json:"…"; omitempty пропускает пустое значение, "-" — поле всегда. Остальное — уточнения о том, что значит «пустое» и что происходит на обратном пути.

Механизм 1: omitempty — «пустое» по правилам пакета, а не по смыслу

Что такое «пустое значение», документация перечисляет полностью:

The "omitempty" option specifies that the field should be omitted from the encoding if the field has an empty value, defined as false, 0, a nil pointer, a nil interface value, and any array, slice, map, or string of length zero.

перевод

Опция omitempty означает, что поле пропускается, если его значение пустое; пустым считаются false, 0, nil-указатель, nil-интерфейс и любые массив, срез, карта или строка нулевой длины.

— Пакет encoding/json — Marshal

В списке нет структур — и это не упущение документации. Проверка по полю каждого вида, рядом — опция omitzero из той же документации. Она есть начиная с Go 1.24 — так сказано в его release notes; на более старом Go тег omitzero поле не пропустит.

Вывод, раздел 2:

2) OMITEMPTY И OMITZERO: ЧТО СЧИТАЕТСЯ ПУСТЫМ
─────────────────────────────────────────────
  значение поля            с omitempty                          с omitzero
  false                    {}                                   {}
  0                        {}                                   {}
  ""                       {}                                   {}
  (*int)(nil)              {}                                   {}
  &zero (указатель на 0)   {"v":0}                              {"v":0}
  any(nil)                 {}                                   {}
  []int(nil)               {}                                   {}
  []int{}                  {}                                   {"v":[]}
  map[string]int{}         {}                                   {"v":{}}
  [2]int{}                 {"v":[0,0]}                          {}
  struct{}{}               {"v":{}}                             {}
  Address{}                {"v":{"city":""}}                    {}
  time.Time{}              {"v":"0001-01-01T00:00:00Z"}         {}

{} в таблице — поле пропущено. Четыре строки здесь стоит запомнить.

time.Time{} с omitempty выходит в JSON как "0001-01-01T00:00:00Z". time.Time — структура, а структура для omitempty пустой не бывает. Отсюда «дата первого года» в ответах API: поле CreatedAt с omitempty, которое никто не заполнил.

[2]int{} не пуст: длина массива — 2, и правило «нулевой длины» его не касается, хотя оба элемента нули.

Указатель на ноль не пуст: omitempty смотрит на сам указатель, а он не nil. Именно этим и пользуются, когда нужно отличить «поле не задано» от «поле равно нулю», — подробнее в «Дальше спросят».

omitzero устроен иначе. Он спрашивает у значения метод IsZero(), а если его нет — сравнивает с нулевым значением типа. Поэтому он пропускает структуры и time.Time{} (у time.Time есть IsZero), но оставляет пустой, но не nil срез []int{} — это не нулевое значение среза. Если нужны оба смысла сразу, опции указывают вместе; release notes Go 1.24 описывают и это: If both omitempty and omitzero are specified, the field will be omitted if the value is either empty or zero (or both) (Если указаны и omitempty, и omitzero, поле пропускается, если значение пустое, нулевое или и то и другое).

деталь реализации · Go 1.24Номера строк — encoding/json/encode.go из go1.24.7.

В исходнике правило — одна функция isEmptyValue (encoding/json/encode.go:318–330 в go1.24.7): массив, карта, срез и строка проверяются по Len() == 0, числа, bool, указатель и интерфейс — по IsZero(), а для всего остального, включая структуры, она возвращает false. Обе опции проверяются одним условием при обходе полей (encode.go:715–716).

Механизм 2: Unmarshal сливает и молчит

Обратный путь устроен по тому же принципу «продолжить, а не сообщить», и документация говорит это прямо:

Unmarshal matches incoming object keys to the keys used by Marshal (either the struct field name or its tag), preferring an exact match but also accepting a case-insensitive match. By default, object keys which don't have a corresponding struct field are ignored.

перевод

Unmarshal сопоставляет ключи входного объекта с ключами, которые использует Marshal (имя поля или его тег), предпочитая точное совпадение, но принимая и совпадение без учёта регистра. По умолчанию ключи, которым не соответствует поле структуры, игнорируются.

— Пакет encoding/json — Unmarshal

Поле Name с тегом json:"name".

Вывод, раздел 3:

3) UNMARSHAL: РЕГИСТР КЛЮЧЕЙ И ЛИШНИЕ КЛЮЧИ
───────────────────────────────────────────
  {"name":"ann"}                     -> Name="ann" err=<nil>
  {"Name":"ann"}                     -> Name="ann" err=<nil>
  {"NAME":"ann"}                     -> Name="ann" err=<nil>
  {"nAmE":"ann"}                     -> Name="ann" err=<nil>
  Unmarshal в Strict{Name}:
    {"name":"ann","role":"user","is_admin":true}
    -> Name="ann" err=<nil>
  Decoder с DisallowUnknownFields, тот же вход:
    -> Name="ann" err=json: unknown field "role"

Тег name не делает сопоставление строгим: все четыре написания заполнили поле. В исходнике это два шага подряд — сначала поиск по точному имени, потом по «сложенному» без учёта регистра (encoding/json/decode.go:694–697 в go1.24.7). Лишние role и is_admin пропали без следа.

Строгий режим есть, но только у Decoder, не у функции Unmarshal:

DisallowUnknownFields causes the Decoder to return an error when the destination is a struct and the input contains object keys which do not match any non-ignored, exported fields in the destination.

перевод

DisallowUnknownFields заставляет Decoder вернуть ошибку, если приёмник — структура, а во входе есть ключи, которым не соответствует ни одно неигнорируемое экспортируемое поле.

— Пакет encoding/json — Decoder.DisallowUnknownFields

Ошибка в прогоне — json: unknown field "role", по первому лишнему ключу. А Name при этом уже заполнен: проверка не отменяет разбор, она добавляет к нему ошибку.

Второе, что Unmarshal делает молча, — сливает вход с тем, что уже лежит в структуре.

Вывод, раздел 4:

4) UNMARSHAL В ЗАПОЛНЕННУЮ СТРУКТУРУ
────────────────────────────────────
  было:   {ID:1 Name:Ann Email:ann@example.com Tags:[a b c] Meta:map[visits:3]}
  вход:   {"name":"Bob","tags":["z"],"meta":{"likes":5},"email":null}
  стало:  {ID:1 Name:Bob Email:ann@example.com Tags:[z] Meta:map[likes:5 visits:3]}
  err=<nil>

Четыре разных исхода в одной строке. id во входе нет — ID остался 1. email: null для поля string не изменил ничего: документация так и пишет — unmarshaling a JSON null into any other Go type has no effect on the value and produces no error (null JSON, разобранный в любой другой тип Go, никак не меняет значение и не даёт ошибки). Срез tags укорочен до нуля и заполнен заново — [z] вместо [a b c]. А карта meta дополнена: старый ключ visits остался рядом с новым likes.

Со срезом есть тонкость. Документация описывает разбор в срез так: resets the slice length to zero and then appends each element to the slice (обнуляет длину среза, а затем дописывает в него каждый элемент). Массив под срезом остаётся прежним, и элементы-структуры сливаются со старыми так же, как сама структура.

Вывод, раздел 4 (продолжение):

  было:   {Items:[{A:1 B:2} {A:3 B:4}] Tags:[a b c]}
  вход:   {"items":[{"a":5}],"tags":["z"]}
  стало:  {Items:[{A:5 B:2}] Tags:[z]}
  err=<nil>
  старый срез old после разбора: [z b c]

B:2 в новом элементе — от старого {1 2}: во входе был только a. А old, который до разбора лежал в поле Tags, теперь показывает [z b c] — z записан в его массив.

Практическое следствие: структуру, в которую уже что-то разобрано, нельзя переиспользовать для следующего сообщения — поля, которых в нём не будет, сохранят значения из прошлого. Для каждого входа берут новую переменную.

И последнее — ошибка типа не останавливает разбор.

Вывод, раздел 4 (окончание):

  тип не тот:  {"id":"7","name":"Bob"}
    -> ID=0 Name="Bob"
    err=json: cannot unmarshal string into Go struct field Profile.id of type int

ID пропущен, Name заполнен, ошибка возвращена. Документация обещает меньше, чем показал прогон: it's not guaranteed that all the remaining fields following the problematic one will be unmarshaled into the target object (не гарантируется, что все оставшиеся поля после проблемного будут разобраны в целевой объект). Код, который смотрит на поля, не проверив err, получит наполовину заполненную структуру — и какую именно половину, заранее не известно.

Механизм 3: числа в any — float64, точность и ошибка диапазона

Когда приёмник — interface{} или map[string]any, типа поля нет, и пакет выбирает его сам. Для чисел выбор один — float64, for JSON numbers (float64 — для чисел JSON). У float64 53 значащих бита, и целые больше 2^53 представимы не все.

Вывод, раздел 5:

5) ЧИСЛА В any: FLOAT64 И ПОТЕРЯ ТОЧНОСТИ
─────────────────────────────────────────
  вход                      {"id":9007199254740993}
  в map[string]any:         9.007199254740992e+15 (float64)
  int64(f)                  9007199254740992
  json.Marshal обратно      {"id":9007199254740992}

9007199254740993 — это 2^53 + 1. Точно в float64 его не записать, и разбор округлил его до 2^53 — без ошибки: strconv.ParseFloat так и устроен (convertNumber, encoding/json/decode.go:833–842 в go1.24.7). И по дороге обратно JSON уже несёт другой id. Сервис-посредник, который разбирает запрос в map[string]any и пересылает дальше, портит идентификаторы, ничего не сообщив.

Три способа этого избежать.

Вывод, раздел 5 (продолжение):

  с UseNumber:              9007199254740993 (json.Number)
  n.Int64()                 9007199254740993
  в struct{ ID int64 }:     9007199254740993
  2^53 =                    9007199254740992
  с тегом ",string":        {"id":"9007199254740993"}
  и обратно:                9007199254740993 err=<nil>

Decoder.UseNumber кладёт в интерфейс json.Number — строку с исходной записью числа, и Int64() возвращает его точно. Поле с типом int64 решает задачу без настроек: когда тип известен, пакет разбирает число в него напрямую. Тег ,string пишет число строкой — {"id":"9007199254740993"}. Это не для Go, а для другой стороны; документация называет случай прямо: This extra level of encoding is sometimes used when communicating with JavaScript programs (этот дополнительный уровень кодирования иногда нужен при обмене с программами на JavaScript). Разборщик на той стороне, который читает числа в float64, испортит id больше 2^53 точно так же, как any в Go.

Важно не путать потерю точности с ошибкой. Округление происходит, когда пакет сам выбрал float64 — в any. Если тип поля задан и число в него не помещается, Unmarshal возвращает ошибку.

Вывод, раздел 11:

11) ПОТЕРЯ ТОЧНОСТИ ИЛИ ОШИБКА: ЧИСЛО ВНЕ int64
───────────────────────────────────────────────
  вход: {"id":9223372036854775808}  (2^63, на единицу больше MaxInt64)
  в struct{ ID int64 }:  ID=0
    err=json: cannot unmarshal number 9223372036854775808 into Go struct field Row.id of type int64
  в map[string]any:      9.223372036854776e+18 err=<nil>

Документация называет этот случай прямо: if a JSON number overflows the target type, Unmarshal skips that field and completes the unmarshaling as best it can (если число JSON переполняет целевой тип, Unmarshal пропускает это поле и доводит разбор до конца, как может), и возвращает UnmarshalTypeError. Поле ID осталось нулём — без проверки err этот ноль уйдёт дальше как настоящий id.

У ,string есть обратная сторона — он требует строку и при чтении.

Вывод, раздел 5 (окончание):

  ",string", а пришло число: err=json: invalid use of ,string struct tag, trying to unmarshal unquoted value into int64

Механизм 4: формы значений, которые пакет выбирает сам

Есть решения, которые пакет принимает за вас и о которых не спрашивает.

Вывод, раздел 6:

6) ФОРМЫ ЗНАЧЕНИЙ
─────────────────
  []string(nil)                  -> null
  []string{}                     -> []
  map[string]int(nil)            -> null
  []byte("hi")                   -> "aGk="
  []uint8{1, 2, 3}               -> "AQID"
  map{zeta alpha Mid beta}       -> {"Mid":3,"alpha":2,"beta":4,"zeta":1}
  map[int]string{10:"a", 9:"b"}  -> {"10":"a","9":"b"}
  "<b>Tom & Jerry</b>"           -> "\u003cb\u003eTom \u0026 Jerry\u003c/b\u003e"
  Encoder, SetEscapeHTML(false)  -> "<b>Tom & Jerry</b>"
  math.NaN()                     -> ошибка: json: unsupported value: NaN
  math.Inf(1)                    -> ошибка: json: unsupported value: +Inf
  struct{ V float64 }{NaN}       -> ошибка: json: unsupported value: NaN
  make(chan int)                 -> ошибка: json: unsupported type: chan int

nil-срез — null, пустой — []. Для Go это почти одно и то же, для клиента — нет: ждал массив, получил null. Срез, который мог остаться nil, — var items []Item без единого append — уходит как null. Если контракт API обещает массив, срез создают явно: items := []Item{} или make([]Item, 0).

[]byte — строка base64, а не массив чисел: []uint8{1, 2, 3} — это "AQID". Документация называет это исключением из правила для срезов: except that []byte encodes as a base64-encoded string, and a nil slice encodes as the null JSON value (кроме []byte — он кодируется строкой base64, — а nil-срез кодируется как null).

Ключи карты отсортированы — и сортировка побайтная: "Mid" раньше "alpha", потому что заглавные буквы в ASCII идут раньше строчных, а целые ключи 10 и 9 превращены в строки и сравнены как строки — "10" раньше "9" (slices.SortFunc со strings.Compare, encode.go:762–776). Поэтому вывод Marshal для карты детерминирован, и его можно сравнивать в тестах.

<, > и & экранированы как \u003c, \u003e, \u0026 — чтобы JSON можно было вставить в HTML: документация объясняет это тем, что JSON должен быть safe to embed inside HTML <script> tags (безопасен для вставки внутрь тегов script в HTML). Строка после разбора та же, а байты другие — тот, кто сравнивает тексты или подписывает их, увидит разницу. Выключается только у Encoder: SetEscapeHTML(false).

Ошибка — только там, где JSON не может выразить значение. NaN и ±Inf в JSON не существует, и Marshal возвращает UnsupportedValueError (encode.go:540–543); каналы и функции — UnsupportedTypeError. Одно NaN в глубине структуры — и весь ответ не собран.

Механизм 5: свой MarshalJSON — адрес и рекурсия

Тип управляет своим JSON, если реализует json.Marshaler — метод MarshalJSON() ([]byte, error). Ловушек здесь две.

Первая — получатель-указатель. Cents объявляет MarshalJSON на *Cents и пишет сумму строкой "12.34". Знак и модуль форматируются отдельно — почему, сразу после блока прогона.

Фрагмент bench/gojson/internals.go:

GO
type Cents int64
 
func (c *Cents) MarshalJSON() ([]byte, error) {
    n := int64(*c)
    sign, u := "", uint64(n)
    if n < 0 {
        sign, u = "-", -u
    }
    return []byte(fmt.Sprintf(`"%s%d.%02d"`, sign, u/100, u%100)), nil
}
 
type Invoice struct {
    Total Cents `json:"total"`
}

Вывод, раздел 7:

7) MARSHALJSON НА УКАЗАТЕЛЕ: КОГДА ОН НЕ ВЫЗЫВАЕТСЯ
───────────────────────────────────────────────────
  json.Marshal(c)                -> 1234
  json.Marshal(&c)               -> "12.34"
  json.Marshal(inv)              -> {"total":1234}
  json.Marshal(&inv)             -> {"total":"12.34"}
  []Invoice{inv}                 -> [{"total":"12.34"}]
  map[string]Invoice{"a": inv}   -> {"a":{"total":1234}}

Один и тот же Invoice дал два разных JSON — и ни одной ошибки. Метод с получателем *Cents есть только у того, у чего можно взять адрес. inv, переданный по значению, лежит в интерфейсе копией без адреса — метода нет, и поле кодируется как обычное число. Элемент среза адресуем, значение в карте — нет. В исходнике это отдельный кодировщик, который на каждом значении спрашивает CanAddr() и выбирает путь (condAddrEncoder, encode.go:396 и 913–918). Правило то же, что в уроке о получателе метода; здесь оно просто не даёт ошибки компиляции, потому что Marshal принимает any.

Про формулу внутри метода. Короче было бы n/100 и n%100, но в Go частное усекается к нулю, и остаток несёт знак делимого — в таблице спецификации -5 % 3 равно -2. Для отрицательных сумм такая формула печатает минус дважды.

Вывод, раздел 10:

10) CENTS: НАИВНАЯ ФОРМУЛА И ОТРИЦАТЕЛЬНЫЕ СУММЫ
────────────────────────────────────────────────
  значение               n/100, n%100               знак и модуль отдельно
  0                      "0.00"                     "0.00"
  1                      "0.01"                     "0.01"
  99                     "0.99"                     "0.99"
  100                    "1.00"                     "1.00"
  -1                     "0.-1"                     "-0.01"
  -100                   "-1.00"                    "-1.00"
  -123                   "-1.-23"                   "-1.23"
  9223372036854775807    "92233720368547758.07"     "92233720368547758.07"
  -9223372036854775808   "-92233720368547758.-8"    "-92233720368547758.08"

JSON при этом синтаксически верный — неверна сумма внутри строки. Модуль берётся в uint64: там -u не переполняется, и формула верна даже для math.MinInt64, где -n в int64 переполнилось бы.

Отсюда совет: MarshalJSON объявляют на значении, если ему не нужно менять получатель. UnmarshalJSON объявляют на указателе — иначе он пишет в копию.

Вторая ловушка — рекурсия. Частая задача: «выдать обычный JSON, но добавить одно поле». Первое, что пишут.

Фрагмент — намеренно неверный; полный файл — bench/gojson/cases/recursion/main.go:

GO
func (e Event) MarshalJSON() ([]byte, error) {
    b, err := json.Marshal(e)
    ...
}

json.Marshal(e) находит у e тот же MarshalJSON и вызывает его. Это не паника, а переполнение стека — фатальная ошибка рантайма. В подопытной программе в main стоит defer с recover, и он не сработал.

Вывод bench/gojson/recursion.sh, раздел 1:

1) ВНУТРИ MARSHALJSON ВЫЗВАН json.Marshal(e)
────────────────────────────────────────────
  runtime: goroutine stack exceeds 1048576-byte limit
  fatal error: stack overflow
  recover в main не сработал: строки «recover поймал» в выводе нет
  код выхода: 2
  один виток цикла в трассе стека горутины 1 (сверху — вызванный):
    encoding/json.Marshal
    main.Event.MarshalJSON
    main.(*Event).MarshalJSON
    encoding/json.marshalerEncoder
    encoding/json.condAddrEncoder.encode
    encoding/json.condAddrEncoder.encode-fm
    encoding/json.(*encodeState).reflectValue
    encoding/json.(*encodeState).marshal
    encoding/json.Marshal

Предел стека в подопытной программе снижен до 1 МБ, чтобы падение наступало быстро. Обычный предел на 64-битной платформе — 1 ГБ (runtime/proc.go:157–158 в go1.24.7), но рекурсия бесконечна и упрётся в любой. Падает весь процесс, а не один запрос.

Лечится это типом-двойником: внутри метода объявляют новый тип с теми же полями и кодируют его. Спецификация гарантирует, что методы самого Event новый тип не наследует, — но только их:

It does not inherit any methods bound to the given type, but the method set of an interface type or of elements of a composite type remains unchanged.

перевод

Он не наследует методов, привязанных к исходному типу, но набор методов интерфейсного типа или элементов составного типа остаётся прежним.

— Спецификация Go — Type definitions

Фрагмент bench/gojson/internals.go — исправление:

GO
func (e Event) MarshalJSON() ([]byte, error) {
    type event Event
    return json.Marshal(struct {
        event
        At   int64  `json:"at"`
        Kind string `json:"kind"`
    }{event(e), e.At.Unix(), "event:" + e.Kind})
}

Вывод bench/gojson/internals.go, раздел 8:

8) MARSHALJSON БЕЗ РЕКУРСИИ: ТИП-ДВОЙНИК
────────────────────────────────────────
  json.Marshal(ev)         -> {"id":5,"at":1790856000,"kind":"event:login"}
  json.Marshal(plain(ev))  -> {"id":5,"at":"2026-10-01T12:00:00Z"}

event встроен в анонимную структуру, и его поля вышли на верхний уровень; внешние At и Kind на глубине 0 перекрыли одноимённые на глубине 1 — как в уроке о встраивании. Вторая строка — тот же Event без метода: время строкой, kind нет.

Вторая половина цитаты — оговорка, о которой забывают: методы встроенных полей у двойника остаются. Если в Event встроено time.Time, его MarshalJSON поднимется в анонимную структуру, и вместо объекта выйдет одна строка времени — без ошибки.

Вывод, раздел 9:

9) ДВОЙНИК НЕ СПАСАЕТ ОТ МЕТОДОВ ВСТРОЕННОГО ПОЛЯ
─────────────────────────────────────────────────
  json.Marshal(st)  -> "2026-10-01T12:00:00Z" err=<nil>
  type stampTwin Stamp реализует json.Marshaler: true

Stamp здесь — тот же Event, только время встроено, а не лежит полем At. id и kind пропали. Поэтому поле со своим MarshalJSON в такой структуре не встраивают, а объявляют именованным.

Двойник обязан быть новым типом, а не псевдонимом. type event = Event со знаком = — это то же самое имя для того же типа, методы у него те же. И хуже: встроенный в анонимную структуру, он поднимает MarshalJSON в неё саму — цикл идёт уже через анонимный тип.

Вывод bench/gojson/recursion.sh, раздел 2 — намеренно неверный вариант:

2) ДВОЙНИК-ПСЕВДОНИМ: type event = Event
────────────────────────────────────────
  runtime: goroutine stack exceeds 1048576-byte limit
  fatal error: stack overflow
  код выхода: 2
  один виток цикла в трассе стека горутины 1 (сверху — вызванный):
    encoding/json.Marshal
    main.Event.MarshalJSON
    go:(*struct { main.event = main.Event; Kind string "json:\"kind\"" }).MarshalJSON

Один символ = — разница между рабочим методом и падением процесса.

Глубже: сколько стоит encoding/json и где цена

наблюдение замераbench/gojson/cost.go, go1.24.7 linux/amd64, 2 ядра, GOMAXPROCS=2, вход — одна структура из пяти полей (91 байт JSON), лучший из 15 кругов по 200 мс (минимум, а не типичное время), круги способов чередуются, результат пишется в глобальную переменную. Машина общая. Это наблюдение одной конфигурации, а не свойство Go: кратности зависят от входа, версии и машины.

«JSON в Go медленный, потому что рефлексия» — фраза, которую чаще говорят, чем измеряют. Ниже — что наблюдали в одной конфигурации; механизм объясняет, почему стороны разные, а свои кратности нужно мерить у себя. И это замер старой реализации: в Go 1.27 encoding/json работает на v2, и release notes обещают significantly faster (значительно более быстрый разбор) Unmarshal при паритете Marshal, — урок этого не измерял. Структура из пяти полей, четыре способа.

Вывод bench/gojson/cost.go:

1) ОДНА СТРУКТУРА ИЗ ПЯТИ ПОЛЕЙ
───────────────────────────────
  {"id":90210,"customer":"Ann Smith","total":12345,"paid":true,"items":["book","pen","lamp"]}
  91 байт; ручная сборка даёт те же байты: true

  способ                               нс/оп  аллок  байт
  json.Marshal(&o)                     340.0      1    96
  ручная сборка (strconv.Append*)      184.3      1    96
  json.Unmarshal в Order              2044.3     13   432
  json.Unmarshal в map[string]any     2498.8     33   896

  Marshal дороже ручной сборки          в  1.8 раза
  Unmarshal в структуру дороже Marshal  в  6.0 раза
  Unmarshal в map дороже, чем в Order   в  1.2 раза

В этом замере Marshal оказался дороже ручной сборки той же строки в 1,8 раза — и аллоцировал столько же: одну аллокацию на 96 байт, сам результат. Разбор тегов и поиск полей пакет делает один раз на тип и кеширует — об этом кеше и о цене поиска поля по имени подробно в уроке о рефлексии. На каждый вызов остаются обход полей по готовому описанию и проверки, а не «рефлексия» в смысле поиска по именам.

деталь реализации · Go 1.24Номера строк — encoding/json/decode.go из go1.24.7.

Дорогой стороной оказался Unmarshal: в записи прогона в 6,0 раза дороже Marshal и 13 аллокаций вместо одной. Число аллокаций — наблюдение реализации go1.24.7, а не контракт пакета. Каждая строка входа копируется в новую строку Go (encoding/json/decode.go:953–957 в go1.24.7), а срез items растёт по мере разбора — Grow, когда кончается ёмкость (decode.go:545–548). И вход пакет проходит дважды: прежде чем заполнять структуру, Unmarshal целиком проверяет синтаксис — Avoids filling out half a data structure before discovering a JSON syntax error (чтобы не заполнить половину структуры, прежде чем обнаружить синтаксическую ошибку JSON) (checkValid, encoding/json/decode.go:102 в go1.24.7). Разбор в map[string]any ещё дороже — в записи прогона в 1,2 раза — и даёт 33 аллокации: под каждое значение — интерфейс, под объект — карта.

Выводы для дизайна:

что делаетечто это стоит
json.Marshal структурыодна аллокация под результат; ручная сборка быстрее меньше чем вдвое, а не на порядки
json.Unmarshal в структурудороже Marshal в разы; два прохода по входу, аллокация на каждую строку
json.Unmarshal в map[string]anyещё дороже, плюс числа становятся float64
ручная сборкабыстрее, но экранирование, NaN и порядок полей — ваша забота

Ручная сборка в замере выиграла меньше чем вдвое, а дала те же байты только потому, что строки здесь — ASCII без кавычек: strconv.AppendQuote в общем случае экранирует не так, как JSON. Переписывать Marshal руками ради такой разницы редко оправдано; в замере дорогая сторона — разбор входа.

Как отвечать на собеседовании

Короткий ответ: в JSON выходят экспортируемые поля, ключ — имя поля или тег json:"…". omitempty пропускает false, 0, "", nil и пустые срезы, карты и массивы нулевой длины, но не структуры — поэтому time.Time{} выходит как "0001-01-01T00:00:00Z". Unmarshal ищет поля без учёта регистра, лишние ключи молча пропускает и не трогает поля, которых во входе нет.

Этого достаточно, чтобы ответить верно. Дальше — то, что добавляют, если собеседник копает.

Если интервьюер копает глубже

На «а пустая структура с omitempty?» — правило и замена. «Структура для omitempty не пуста никогда: в прогоне Address{} вышел как {"city":""}, а time.Time{} — как дата первого года. Пропускает их omitzero — на Go 1.24 и новее: он спрашивает IsZero() или сравнивает с нулевым значением».

На «что будет с лишним полем» — строгий режим. «Пропадёт без ошибки. Если нужно отказать, — Decoder с DisallowUnknownFields, ошибка json: unknown field "role". И ключ "NAME" заполнит поле name: регистр не учитывается».

На «почему id не тот» — float64. «В any число становится float64, и 9007199254740993 превращается в 9007199254740992. Решения — поле int64, Decoder.UseNumber или тег ,string для клиентов на JavaScript».

На «nil-срез» — контракт. «nil-срез — null, пустой — []. Если API обещает массив, срез создают явно».

На «свой MarshalJSON» — две ловушки. «На указателе метод не вызывается у значения без адреса: json.Marshal(inv) дал {"total":1234}. А json.Marshal(e) внутри метода — бесконечная рекурсия и падение процесса; внутри объявляют type event Event — новый тип без методов самого Event, не псевдоним».

Дальше спросят

Спросят дальше

Как отличить «поле не пришло» от «поле равно нулю»?

Короткий ответ

Указателем. У поля int оба случая дают 0. У поля *int они разные: ключа нет — указатель остался nil; пришёл 0 — Unmarshal выделил значение и записал в него ноль. Документация описывает это прямо: If the pointer is nil, Unmarshal allocates a new value for it to point to (Если указатель nil, Unmarshal выделяет для него новое значение), а null указатель обнуляет.

На выходе то же различие держит omitempty: в прогоне указатель на ноль вышел как {"v":0}, а nil-указатель был пропущен. Так делают PATCH-запросы: nil — «не менять», указатель на значение — «записать это значение», даже если оно нулевое.

Третьего состояния указатель не даёт: null во входе и отсутствие ключа приводят к одному и тому же nil. Если API должен отличать «очистить поле» от «не трогать», указателя мало — нужен свой тип с UnmarshalJSON, который запомнит, что его вызвали: по документации including when the input is a JSON null (включая случай, когда во входе null) метод вызывается и на null.

Подвох в том, что поле, разобранное однажды, при повторном Unmarshal в ту же структуру не обнулится — Механизм 2. Для каждого запроса — новая переменная.

Спросят дальше

Что такое encoding/json/v2 и что в нём иначе?

Короткий ответ
наблюдение замераbench/gojson/jsonv2.go, go1.25.1 linux/amd64, GOEXPERIMENT=jsonv2. Эксперимент: пакет существует только с этим флагом и не подпадает под обещание совместимости Go 1.

Это другой пакет с другим путём импорта — "encoding/json/v2", — и другим набором правил; "encoding/json" (v1) остаётся со своими. Статус у него менялся, и здесь важно не смешать версии.

В go1.25 — эксперимент, и прогон ниже — именно он. Пакет есть в исходниках go1.25.1, но собирается только с GOEXPERIMENT=jsonv2 и, по его документации в этой версии, not subject to the Go 1 compatibility promise (не подпадает под обещание совместимости Go 1).

В Go 1.27, по release notes, — без флага. Там encoding/json/v2 и encoding/json/jsontext доступны как обычные пакеты, а сам encoding/json is now backed by the v2 implementation. Marshaling and unmarshaling behavior is preserved, but the exact text of error messages may differ (теперь работает на реализации v2. Поведение кодирования и разбора сохранено, но точный текст сообщений об ошибках может отличаться); вернуть прежнюю реализацию можно через GOEXPERIMENT=nojsonv2. Урок на Go 1.27 не запускался.

Почти каждое отличие v2 — одно из молчаливых поведений этого урока; прогон на go1.25.1 с флагом.

Вывод bench/gojson/jsonv2.go (go1.25.1, GOEXPERIMENT=jsonv2):

1) РЕГИСТР КЛЮЧЕЙ ПРИ UNMARSHAL
───────────────────────────────
  {"name":"ann"}   v1: Name="ann"  err=<nil>
                   v2: Name="ann"  err=<nil>
  {"NAME":"ann"}   v1: Name="ann"  err=<nil>
                   v2: Name=""     err=<nil>

2) NIL-СРЕЗ И NIL-КАРТА
───────────────────────
  List{}  v1: {"tags":null,"meta":null}
  List{}  v2: {"tags":[],"meta":{}}

3) OMITEMPTY У ЧИСЛА
────────────────────
  Counter{N: 0}  v1: {}
  Counter{N: 0}  v2: {"n":0}

4) MARSHALJSON НА УКАЗАТЕЛЕ, ЗНАЧЕНИЕ БЕЗ АДРЕСА
────────────────────────────────────────────────
  json.Marshal(inv)  v1: {"total":1234}
  json.Marshal(inv)  v2: {"total":"12.34"}

5) NULL В ЗАПОЛНЕННОЕ ПОЛЕ
──────────────────────────
  {"email":null}  v1: {Name:Ann Email:ann@example.com}
  {"email":null}  v2: {Name:Ann Email:}

6) ПОВТОРЯЮЩИЙСЯ КЛЮЧ
─────────────────────
  {"name":"a","name":"b"}  v1: Name="b" err=<nil>
  {"name":"a","name":"b"}  v2: Name="a" err=jsontext: duplicate object member name "name"

В v2 регистр учитывается, nil-срез — [], omitempty у числа больше не пропускает ноль (для этого теперь omitzero), а MarshalJSON на указателе вызывается и у значения без адреса, а null обнуляет заполненное поле — вопрос 5 проверки знаний в v2 имеет другой ответ. Повторяющийся ключ v1 принимает молча, а v2 отвергает — release notes Go 1.27 называют это одним из более строгих умолчаний v2. Ещё два отличия из списка в документации v1 под тем же флагом (encoding/json/v2_options.go:84–92 в go1.25.1) прогоном не показаны: карта в v2 кодируется в недетерминированном порядке, а HTML-символы не экранируются — сравнивать вывод Marshal для карт в тестах там уже нельзя. Под тем же флагом и старый encoding/json переписан поверх v2 — его документация в go1.25.1 говорит, что all the behavior of the v1 package is implemented in terms of the v2 package (всё поведение пакета v1 реализовано через пакет v2). Прогон internals.go в этом режиме дал то же поведение во всех одиннадцати разделах; отличаются тексты трёх ошибок — ровно то, о чём предупреждают release notes Go 1.27.

Спросят дальше

Unmarshal или Decoder — что брать для тела HTTP-запроса?

Короткий ответ

Decoder — прежде всего ради настроек: только у него есть DisallowUnknownFields и UseNumber. json.NewDecoder(r.Body) читает из io.Reader, и тело не нужно заранее собирать в []byte, — как устроено чтение из io.Reader, в уроке об io.Reader.

Памяти это не экономит: одно значение JSON Decoder всё равно держит в своём буфере целиком — Decode сначала читает значение до конца (Read whole value into buffer (читает всё значение в буфер), encoding/json/stream.go:62 в go1.24.7) и только потом разбирает.

Оговорка из документации NewDecoder: декодер introduces its own buffering and may read data from r beyond the JSON values requested (вводит собственную буферизацию и может прочитать из r больше данных, чем запрошенные значения JSON). Один вызов Decode разбирает одно значение; что после него лежит в потоке, он не проверяет. Повторяющиеся ключи v1 тоже не запрещает — ни Unmarshal, ни DisallowUnknownFields. Строгий обработчик поэтому делает три вещи: ограничивает тело через http.MaxBytesReader (подробнее о нём — в уроке об io.Reader), требует, чтобы второй Decode вернул io.EOF, и отличает превышение размера от плохого JSON.

Полный файл bench/gojson/strict.go — его можно скопировать в пустой каталог и запустить командой go run strict.go. Комментарии и вывод в нём английские: тот же файл стоит и в английской версии урока.

GO
//go:build ignore
 
// Strict decoding of an HTTP request body: the complete example from the
// encoding/json lesson. Comments and output are in English on purpose: the
// same file is shown in both locales of the lesson.
//
// DisallowUnknownFields alone does not make decoding strict: one Decode call
// reads one value and ignores what follows it, and v1 silently accepts
// duplicate keys. The handler adds the missing steps — a second Decode must
// return io.EOF, and the body size is capped by http.MaxBytesReader — and is
// run on eight request bodies through httptest, without a network.
//
// RUN:
//
//	go run bench/gojson/strict.go
package main
 
import (
	"encoding/json"
	"errors"
	"fmt"
	"io"
	"net/http"
	"net/http/httptest"
	"runtime"
	"strings"
)
 
type CreateUser struct {
	Name string `json:"name"`
}
 
// decodeStrict decodes exactly one JSON value with no unknown keys.
func decodeStrict(r io.Reader, v any) error {
	dec := json.NewDecoder(r)
	dec.DisallowUnknownFields()
	if err := dec.Decode(v); err != nil {
		return err
	}
	// Nothing but whitespace may follow the value.
	if err := dec.Decode(&struct{}{}); err != io.EOF {
		return errors.New("unexpected data after the JSON value")
	}
	return nil
}
 
func createUser(w http.ResponseWriter, r *http.Request) {
	r.Body = http.MaxBytesReader(w, r.Body, 64) // at most 64 bytes
	var u CreateUser
	if err := decodeStrict(r.Body, &u); err != nil {
		var tooBig *http.MaxBytesError
		if errors.As(err, &tooBig) {
			http.Error(w, err.Error(), http.StatusRequestEntityTooLarge)
			return
		}
		http.Error(w, err.Error(), http.StatusBadRequest)
		return
	}
	fmt.Fprintf(w, "ok: name=%q", u.Name)
}
 
func main() {
	fmt.Printf("%s %s/%s | strict decoding of an HTTP request body\n",
		runtime.Version(), runtime.GOOS, runtime.GOARCH)
	fmt.Println()
	fmt.Println("1) HANDLER createUser ON EIGHT REQUEST BODIES")
	fmt.Println("─────────────────────────────────────────────")
	long := `{"name":"` + strings.Repeat("a", 100) + `"}`
	cases := []struct{ label, body string }{
		{`{"name":"ann"}`, `{"name":"ann"}`},
		{`{"name":"ann"}` + "\\n", `{"name":"ann"}` + "\n"},
		{`{"name":"ann","role":"admin"}`, `{"name":"ann","role":"admin"}`},
		{`{"name":"ann"} {}`, `{"name":"ann"} {}`},
		{`{"name":"ann"}x`, `{"name":"ann"}x`},
		{`{"name":"a","name":"b"}`, `{"name":"a","name":"b"}`},
		{`(empty body)`, ``},
		{`{"name":"<100 x a>"}`, long},
	}
	for _, c := range cases {
		req := httptest.NewRequest(http.MethodPost, "/users", strings.NewReader(c.body))
		rec := httptest.NewRecorder()
		createUser(rec, req)
		fmt.Printf("  %-31s -> %d %s\n", c.label, rec.Code, strings.TrimSpace(rec.Body.String()))
	}
}

Вывод go run bench/gojson/strict.go:

1) HANDLER createUser ON EIGHT REQUEST BODIES
─────────────────────────────────────────────
  {"name":"ann"}                  -> 200 ok: name="ann"
  {"name":"ann"}\n                -> 200 ok: name="ann"
  {"name":"ann","role":"admin"}   -> 400 json: unknown field "role"
  {"name":"ann"} {}               -> 400 unexpected data after the JSON value
  {"name":"ann"}x                 -> 400 unexpected data after the JSON value
  {"name":"a","name":"b"}         -> 200 ok: name="b"
  (empty body)                    -> 400 EOF
  {"name":"<100 x a>"}            -> 413 http: request body too large

{} {} и мусор после объекта отвергнуты вторым Decode; тело больше 64 байт — кодом 413 через *http.MaxBytesError. А {"name":"a","name":"b"} прошёл с name="b": в v1 повторяющийся ключ — не ошибка, побеждает последний. Если это важно, нужен v2 (там дубли отвергаются) или своя проверка. И DisallowUnknownFields — не валидация: обязательные поля, диапазоны и формат значений проверяют отдельно.

Частые заблуждения

Утверждение

omitempty пропускает любое нулевое значение

На самом деле

Только false, 0, "", nil-указатель, nil-интерфейс и массив, срез, карту или строку нулевой длины. Структуры — никогда: в прогоне time.Time{} вышел как "0001-01-01T00:00:00Z", Address{} — как {"city":""}. Массив [2]int{} тоже не пуст — длина 2. Для нулевых структур есть omitzero — с Go 1.24.

Утверждение

Ключ в JSON должен совпадать с тегом буква в букву

На самом деле

Не в encoding/json: Unmarshal предпочитает точное совпадение, но принимает и без учёта регистра. "name", "Name", "NAME" и "nAmE" заполнили одно поле. В экспериментальном v2 — уже нет.

Утверждение

Лишнее поле во входе — ошибка Unmarshal

На самом деле

Лишние ключи пропускаются молча, err — nil. Ошибку даёт только Decoder с DisallowUnknownFields: json: unknown field "role".

Утверждение

Unmarshal заполняет структуру заново

На самом деле

Он сливает вход с тем, что в структуре уже есть: поле, которого нет во входе, сохраняет старое значение, null для string ничего не меняет, карта дополняется. Срез обрезается до нуля и дописывается в тот же массив, а элементы-структуры сливаются со старыми.

Утверждение

Целое число из JSON в any останется целым

На самом деле

Оно станет float64. 9007199254740993 в прогоне превратилось в 9007199254740992 — без ошибки, и так же ушло обратно в JSON. Точно — через UseNumber, поле int64 или тег ,string.

Утверждение

Если у типа есть MarshalJSON, он вызывается всегда

На самом деле

Метод на указателе есть только у адресуемого значения. json.Marshal(inv) дал {"total":1234}, json.Marshal(&inv) — {"total":"12.34"}; значение в карте метод тоже не получило.

Утверждение

Для двойника в MarshalJSON годится псевдоним: type event = Event

На самом деле

Псевдоним — тот же тип с теми же методами, и рекурсия остаётся: в прогоне — fatal error: stack overflow. Нужен новый тип: type event Event — по спецификации он не наследует методов самого Event (методы встроенных полей остаются).

Утверждение

encoding/json медленный из-за рефлексии, Marshal лучше писать руками

На самом деле

В замере на go1.24.7 Marshal структуры из пяти полей дороже ручной сборки в 1,8 раза и аллоцирует столько же — одну аллокацию под результат. Дорогой стороной там был Unmarshal: в 6,0 раза дороже Marshal, 13 аллокаций. Это одна конфигурация (go1.24.7, linux/amd64) и старая реализация; на своей версии — мерить.

История версий

ВерсияИзменениеЧто это значит для кода
Go 1.1Поля встроенных структур выходят в JSON как поля внешней: по документации Marshal, до Go 1.1 встроенные структуры игнорировались.Встраивание переносит поля на верхний уровень объекта
Go 1.24Опция omitzero в теге — по release notes Go 1.24: поле пропускается, если значение нулевое, а при наличии метода IsZero() bool решает он. Прогоны урока (go1.24.7, go1.25.1) её поведение показывают.Нулевые структуры и нулевое time.Time можно пропускать — на Go 1.24 и новее
Go 1.25Пакет encoding/json/v2 — эксперимент: только с GOEXPERIMENT=jsonv2, вне обещания совместимости; под тем же флагом encoding/json реализован поверх v2. В go1.24.7 исходников v2 нет. Проверено прогоном на go1.25.1.Эксперимент: регистр, nil-срез, omitempty и дубли ключей работают иначе
Go 1.27По release notes: encoding/json/v2 и encoding/json/jsontext доступны без флага; encoding/json работает на реализации v2 с сохранённым поведением, но тексты ошибок могут отличаться; вернуть прежнюю реализацию — GOEXPERIMENT=nojsonv2. Урок это не измерял: Go 1.27 в окружении замеров нет.v2 — отдельный импорт со своими правилами; числа цены из урока к 1.27 не относятся

Замеры урока сняты на go1.24.7 и go1.25.1, linux/amd64; цитаты сверены 14.10.2026. Строки Go 1.24 и Go 1.27 опираются на release notes. Без флага поведение encoding/json между go1.24.7 и go1.25.1 в прогоне не изменилось: internals.go печатает то же самое, кроме строки версии (runs/internals-go1.25.txt).

Практика

Две задачи. Сначала ответьте, потом сверьтесь с настоящим выводом: в обеих правильный ответ взят из прогона скрипта, а не назначен.

Практика · что напечатает

Пакет encoding/json (v1), go1.24.7. У Item два поля: Price int с тегом price,omitempty и Created time.Time с тегом created,omitempty. У User одно поле Name с тегом name. input — объект JSON с ключами NAME (значение ann) и role (значение admin); big — объект с ключом id и значением 9007199254740993. Что напечатает этот код?
b, _ := json.Marshal(Item{})
fmt.Println(string(b))
var u User
err := json.Unmarshal(input, &u)
fmt.Println(u.Name, err)
var m map[string]any
json.Unmarshal(big, &m)
fmt.Println(int64(m["id"].(float64)))

Практика · оцените

Структура из пяти полей: два int64, строка, bool и срез из трёх строк. Во сколько раз json.Marshal(&order) дороже ручной сборки той же строки JSON через strconv.AppendInt, AppendQuote и AppendBool в новый буфер?
раза

Проверка знаний

Вопрос 1 из 5

encoding/json (v1). Поле CreatedAt time.Time с тегом json:"created_at,omitempty" никто не заполнил. Что будет в JSON?

Источники и что читать дальше

8 ИСТОЧНИКОВ

  1. Пакет encoding/json — MarshalОфициальная документация. Какие поля выходят в JSON: «Each exported struct field becomes a member of the object, using the field name as the object key, unless the field is omitted for one of the reasons given below» (Каждое экспортируемое поле структуры становится членом объекта, и ключом служит имя поля, если только поле не пропущено по одной из причин, перечисленных ниже). Что пусто для omitempty: «The "omitempty" option specifies that the field should be omitted from the encoding if the field has an empty value, defined as false, 0, a nil pointer, a nil interface value, and any array, slice, map, or string of length zero» (Опция omitempty означает, что поле пропускается, если его значение пустое; пустым считаются false, 0, nil-указатель, nil-интерфейс и любые массив, срез, карта или строка нулевой длины). Срезы и []byte: «Array and slice values encode as JSON arrays, except that []byte encodes as a base64-encoded string, and a nil slice encodes as the null JSON value» (Массивы и срезы кодируются как массивы JSON, кроме []byte — он кодируется строкой base64, — а nil-срез кодируется как null). Числа: «NaN and +/-Inf values will return an UnsupportedValueError» (Для NaN и ±Inf возвращается UnsupportedValueError). Карты: «The map keys are sorted and used as JSON object keys» (Ключи карты сортируются и становятся ключами объекта JSON).https://pkg.go.dev/encoding/json#Marshal
  2. Пакет encoding/json — UnmarshalОфициальная документация. Регистр и лишние ключи: «Unmarshal matches incoming object keys to the keys used by Marshal (either the struct field name or its tag), preferring an exact match but also accepting a case-insensitive match. By default, object keys which don't have a corresponding struct field are ignored» (Unmarshal сопоставляет ключи входного объекта с ключами, которые использует Marshal (имя поля или его тег), предпочитая точное совпадение, но принимая и совпадение без учёта регистра. По умолчанию ключи, которым не соответствует поле структуры, игнорируются). Числа в интерфейсе: «float64, for JSON numbers» (float64 — для чисел JSON). И null: «unmarshaling a JSON null into any other Go type has no effect on the value and produces no error» (null JSON, разобранный в любой другой тип Go, никак не меняет значение и не даёт ошибки).https://pkg.go.dev/encoding/json#Unmarshal
  3. Пакет encoding/json — Decoder.DisallowUnknownFields и Decoder.UseNumberОфициальная документация. Строгий разбор: «DisallowUnknownFields causes the Decoder to return an error when the destination is a struct and the input contains object keys which do not match any non-ignored, exported fields in the destination» (DisallowUnknownFields заставляет Decoder вернуть ошибку, если приёмник — структура, а во входе есть ключи, которым не соответствует ни одно неигнорируемое экспортируемое поле). Числа без потерь: «UseNumber causes the Decoder to unmarshal a number into an interface value as a Number instead of as a float64» (UseNumber заставляет Decoder разбирать число в интерфейс как Number, а не как float64).https://pkg.go.dev/encoding/json#Decoder.DisallowUnknownFields
  4. Спецификация Go — Type definitionsОфициальная документация. Что теряет и что сохраняет тип-двойник внутри MarshalJSON: «It does not inherit any methods bound to the given type, but the method set of an interface type or of elements of a composite type remains unchanged» (Он не наследует методов, привязанных к исходному типу, но набор методов интерфейсного типа или элементов составного типа остаётся прежним). Пример оттуда же: «The method set of *PrintableMutex contains the methods Lock and Unlock bound to its embedded field Mutex» (Набор методов *PrintableMutex содержит методы Lock и Unlock, привязанные к его встроенному полю Mutex).https://go.dev/ref/spec#Type_definitions
  5. Пакет encoding/json/v2 (go1.25, эксперимент)Официальная документация. Статус пакета: «This package (encoding/json/v2) is experimental, and not subject to the Go 1 compatibility promise. It only exists when building with the GOEXPERIMENT=jsonv2 environment variable set» (Этот пакет (encoding/json/v2) экспериментальный и не подпадает под обещание совместимости Go 1. Он существует только при сборке с переменной окружения GOEXPERIMENT=jsonv2). И главное отличие при разборе: «By default, unmarshaling uses case-sensitive matching to identify the Go struct field associated with a JSON object name» (По умолчанию при разборе поле структуры Go для имени в объекте JSON ищется с учётом регистра).https://pkg.go.dev/encoding/json/v2
  6. Go 1.24 Release Notes — encoding/jsonОфициальная документация. С какой версии есть omitzero: «When marshaling, a struct field with the new omitzero option in the struct field tag will be omitted if its value is zero. If the field type has an IsZero() bool method, that will be used to determine whether the value is zero» (При кодировании поле структуры с новой опцией omitzero в теге пропускается, если его значение нулевое. Если у типа поля есть метод IsZero() bool, нулевое ли значение, определяет он). И: «If both omitempty and omitzero are specified, the field will be omitted if the value is either empty or zero (or both)» (Если указаны и omitempty, и omitzero, поле пропускается, если значение пустое, нулевое или и то и другое). Сверено 14.10.2026.https://go.dev/doc/go1.24#encoding/json
  7. Go 1.27 Release Notes — encoding/json и encoding/json/v2Официальная документация. Текущий статус v2: «The encoding/json package is now backed by the v2 implementation. Marshaling and unmarshaling behavior is preserved, but the exact text of error messages may differ» (Пакет encoding/json теперь работает на реализации v2. Поведение кодирования и разбора сохранено, но точный текст сообщений об ошибках может отличаться); «Two new packages are now available: … encoding/json/v2 … encoding/json/jsontext» (Доступны два новых пакета: … encoding/json/v2 … encoding/json/jsontext); «The v2 package chooses stricter, more interoperable defaults than v1: it rejects invalid UTF-8 in JSON strings and rejects duplicate names within a JSON object» (Пакет v2 выбирает более строгие и совместимые с другими реализациями умолчания, чем v1: он отвергает невалидный UTF-8 в строках JSON и повторяющиеся имена в объекте JSON); «Marshal performance is broadly at parity with the previous implementation, while unmarshal performance is significantly faster» (Скорость кодирования в целом на уровне прежней реализации, а разбор значительно быстрее). Вернуть прежнюю реализацию — GOEXPERIMENT=nojsonv2. Урок это не измерял: Go 1.27 в окружении замеров нет. Сверено 14.10.2026.https://go.dev/doc/go1.27
  8. Спецификация Go — Arithmetic operators (целочисленное деление)Официальная документация. Почему наивная формула для Cents печатает «-1.-23»: частное усекается к нулю — «with x / y truncated towards zero» (где x / y усечено к нулю), — и в таблице спецификации -5 % 3 равно -2: остаток несёт знак делимого.https://go.dev/ref/spec#Arithmetic_operators