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).
Что здесь на самом деле спрашивают
Типичная лестница такая:
- «Как сделать, чтобы поле называлось в JSON иначе?» — разминка: тег
json:"name". - «Что делает
omitempty?» — и сразу следом: «а пустая структура? аtime.Time{}?». Здесь отвечают по памяти и ошибаются. - «Что будет, если в JSON лишнее поле? А если ключ в другом регистре?» —
проверяют, знает ли кандидат, что
Unmarshalмолчит. - «Почему id приехал не тот?» — числа в
interface{}. - «Чем
nil-срез отличается от пустого в JSON?» — контракт API с фронтендом. - «Как написать свой
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.
Каждое экспортируемое поле структуры становится членом объекта, и ключом служит имя поля, если только поле не пропущено по одной из причин, перечисленных ниже.
Одна и та же структура без тегов и с ними.
Фрагмент bench/gojson/internals.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-интерфейс и любые массив, срез, карта или строка нулевой длины.
В списке нет структур — и это не упущение документации. Проверка по полю
каждого вида, рядом — опция 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, поле пропускается, если значение пустое, нулевое или и то и другое).
В исходнике правило — одна функция 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 (имя поля или его тег), предпочитая точное совпадение, но принимая и совпадение без учёта регистра. По умолчанию ключи, которым не соответствует поле структуры, игнорируются.
Поле 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 вернуть ошибку, если приёмник — структура, а во входе есть ключи, которым не соответствует ни одно неигнорируемое экспортируемое поле.
Ошибка в прогоне — 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:
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:
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.
Он не наследует методов, привязанных к исходному типу, но набор методов интерфейсного типа или элементов составного типа остаётся прежним.
Фрагмент bench/gojson/internals.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 и где цена
«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 байт, сам результат. Разбор тегов и поиск
полей пакет делает один раз на тип и кеширует — об этом кеше и о цене поиска
поля по имени подробно в уроке о рефлексии.
На каждый вызов остаются обход полей по готовому описанию и проверки, а не
«рефлексия» в смысле поиска по именам.
Дорогой стороной оказался 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 и что в нём иначе?
Это другой пакет с другим путём импорта — "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: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).
Практика
Две задачи. Сначала ответьте, потом сверьтесь с настоящим выводом: в обеих правильный ответ взят из прогона скрипта, а не назначен.
Практика · что напечатает
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)))Практика · оцените
Проверка знаний
encoding/json (v1). Поле CreatedAt time.Time с тегом json:"created_at,omitempty" никто не заполнил. Что будет в JSON?
Это не пересказ и не отдельный текст: всё ниже взято из самой статьи — её выжимка, заголовки разборов, колонка «на самом деле» и таблица версий. Поэтому разойтись со статьёй эти тезисы не могут.
Суть
encoding/json(v1 — пакет, который импортируют как"encoding/json") превращает значение Go в текст JSON и обратно, а что куда попадёт, решает по правилам, которые читает из типа. Ключ — имя экспортируемого поля или тегjson:"…"; неэкспортируемые поля пакет пропускает (кроме встроенных структур — их экспортируемые поля поднимаются наверх).Marshalобходит поля структуры,Unmarshalищет поле для каждого ключа входа. Как пакет читает теги черезreflect, разобрано в [уроке о рефлексии](/ru/interview/golang/reflection); здесь — что он с ними делает.- Главное следствие: почти всё, что идёт не так, идёт молча. Пустая структура и нулевое
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работает уже на другой реализации.
На самом деле
- Только
false,0,"",nil-указатель,nil-интерфейс и массив, срез, карту или строку нулевой длины. Структуры — никогда: в прогоне{time.Time{}}вышел как"0001-01-01T00:00:00Z",{Address{}}— как{city":"}. Массив{[2]int{}}тоже не пуст — длина 2. Для нулевых структур естьomitzero— с Go 1.24. - Не в
encoding/json:Unmarshalпредпочитает точное совпадение, но принимает и без учёта регистра."name","Name","NAME"и"nAmE"заполнили одно поле. В экспериментальном v2 — уже нет. - Лишние ключи пропускаются молча,
err—nil. Ошибку даёт толькоDecoderсDisallowUnknownFields:json: unknown field "role". - Он сливает вход с тем, что в структуре уже есть: поле, которого нет во входе, сохраняет старое значение,
nullдляstringничего не меняет, карта дополняется. Срез обрезается до нуля и дописывается в тот же массив, а элементы-структуры сливаются со старыми. - Оно станет
float64.9007199254740993в прогоне превратилось в9007199254740992— без ошибки, и так же ушло обратно в JSON. Точно — черезUseNumber, полеint64или тег,string. - Метод на указателе есть только у адресуемого значения.
json.Marshal(inv)дал{total":1234}},json.Marshal(&inv)—{{"total":"12.34}; значение в карте метод тоже не получило. - Псевдоним — тот же тип с теми же методами, и рекурсия остаётся: в прогоне —
fatal error: stack overflow. Нужен новый тип:type event Event— по спецификации он не наследует методов самогоEvent(методы встроенных полей остаются). - В замере на 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) её поведение показывают.< - Go 1.25
- Пакет
encoding/json/v2— эксперимент: только сGOEXPERIMENT=jsonv2, вне обещания совместимости; под тем же флагомencoding/jsonреализован поверх v2. В go1.24.7 исходников v2 нет. Проверено прогоном на go1.25.1.< - Go 1.27
- По release notes:
encoding/json/v2иencoding/json/jsontextдоступны без флага;encoding/jsonработает на реализации v2 с сохранённым поведением, но тексты ошибок могут отличаться; вернуть прежнюю реализацию —GOEXPERIMENT=nojsonv2. Урок это не измерял: Go 1.27 в окружении замеров нет.<
Что разобрано
- Что здесь на самом деле спрашивают
- База: поле становится ключом, тег меняет правило
- Механизм 1: omitempty — «пустое» по правилам пакета, а не по смыслу
- Механизм 2: Unmarshal сливает и молчит
- Механизм 3: числа в any — float64, точность и ошибка диапазона
- Механизм 4: формы значений, которые пакет выбирает сам
- Механизм 5: свой MarshalJSON — адрес и рекурсия
- Глубже: сколько стоит encoding/json и где цена
- Как отвечать на собеседовании
- Дальше спросят
- Частые заблуждения
- История версий
- Практика
- Проверка знаний
Источники и что читать дальше
8 ИСТОЧНИКОВ
- Пакет 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
- Пакет 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
- Пакет 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
- Спецификация 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
- Пакет 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
- 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
- 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
- Спецификация Go — Arithmetic operators (целочисленное деление)Официальная документация. Почему наивная формула для Cents печатает «-1.-23»: частное усекается к нулю — «with x / y truncated towards zero» (где x / y усечено к нулю), — и в таблице спецификации -5 % 3 равно -2: остаток несёт знак делимого.https://go.dev/ref/spec#Arithmetic_operators