Работа с JSON в Go: marshal, unmarshal и грабли между ними
Пакет encoding/json в Go выглядит простым ровно до момента, когда поле приходит то строкой, то числом, вложенная структура меняется между версиями API, а время разбора ответа на тысяче объектов начинает мешать. Разбираем приёмы, которые закрывают реальные задачи, и типичные ошибки, из-за которых данные молча теряются.

Почему стандартного примера не хватает
Каждый учебник по Go начинается с одного и того же: объявить структуру, навесить теги json:"name", вызвать json.Marshal / json.Unmarshal. Этого хватает, пока входящий JSON контролируете вы сами. Как только данные приходят от внешнего API — Stripe, Telegram, произвольного партнёрского сервиса — начинаются вопросы. Поле id приходит то числом, то строкой. Поле created_at — то в RFC 3339, то в Unix-секундах. Часть полей вообще отсутствует, и разница между «поле не пришло» и «поле пришло с null» критична для бизнес-логики.
Материал написан для Go 1.22+. Весь код использует стандартный пакет encoding/json — без сторонних библиотек, если не оговорено отдельно.
Marshal: из структуры в байты
Базовый сценарий
Сериализация управляется тегами структуры. Три тега покрывают 90 % задач:
json:"field_name"— имя ключа в JSON;json:"field_name,omitempty"— не включать поле, если оно имеет нулевое значение для своего типа;json:"-"— полностью исключить поле из сериализации.
package main
import (
"encoding/json"
"fmt"
"log"
)
type Order struct {
ID int64 `json:"id"`
UserID int64 `json:"user_id"`
Total float64 `json:"total"`
Comment string `json:"comment,omitempty"`
SecretKey string `json:"-"` // никогда не попадёт в JSON
}
func main() {
order := Order{
ID: 42,
UserID: 7,
Total: 1299.90,
SecretKey: "внутренний токен",
}
data, err := json.Marshal(order)
if err != nil {
log.Fatal(err)
}
fmt.Println(string(data))
// {"id":42,"user_id":7,"total":1299.9}
// Comment отсутствует (omitempty + пустая строка), SecretKey исключён
}
Ловушка с omitempty и нулевыми значениями
Для числовых типов нулевое значение — 0, для булевых — false. Если поле Price float64 `json:"price,omitempty"` действительно равно нулю (бесплатный товар), оно исчезнет из JSON. Клиент не узнает, что цена — ноль, он увидит отсутствие поля.
Решение — использовать указатель. *float64 с нулевым значением по-прежнему сериализуется в 0, а nil-указатель опустится по omitempty.
type Product struct {
Name string `json:"name"`
Price *float64 `json:"price,omitempty"` // nil — поле исчезнет; &0.0 — останется
}
Правило: если «нулевое значение» несёт бизнес-смысл, оборачивайте поле в указатель. Это единственный штатный способ отличить «не задано» от «задано нулём» при сериализации стандартным пакетом.
Unmarshal: из байтов в структуру
Молчаливое игнорирование
Самая частая причина багов — json.Unmarshal не возвращает ошибку, если JSON содержит поля, которых нет в структуре, или если в структуре есть поля, которых нет в JSON. Данные молча теряются в обе стороны.
type User struct {
Name string `json:"name"`
}
func main() {
raw := []byte(`{"name":"Алиса","age":30,"email":"a@b.com"}`)
var u User
_ = json.Unmarshal(raw, &u)
// u.Name == "Алиса"
// age и email — потеряны, ошибки нет
}
Если вам важно знать, что пришли лишние поля (например, вы проверяете контракт стороннего API), используйте json.Decoder с методом DisallowUnknownFields:
import (
"bytes"
"encoding/json"
"fmt"
)
func strictUnmarshal(data []byte, dst any) error {
dec := json.NewDecoder(bytes.NewReader(data))
dec.DisallowUnknownFields()
return dec.Decode(dst)
}
func main() {
raw := []byte(`{"name":"Алиса","age":30}`)
var u User
if err := strictUnmarshal(raw, &u); err != nil {
fmt.Println("лишние поля:", err)
// json: unknown field "age"
}
}
Несовпадение типов: строка вместо числа
Внешний API может отдавать числовой идентификатор как строку: "id": "42". Стандартный Unmarshal в поле int64 вернёт ошибку. Тег string решает проблему:
type ExternalOrder struct {
ID int64 `json:"id,string"` // ожидаем "42", а не 42
}
Тег работает в обе стороны: Marshal тоже запишет число как строку.
Кастомная сериализация: интерфейсы Marshaler и Unmarshaler
Когда теги не справляются, реализуйте интерфейсы json.Marshaler и json.Unmarshaler. Типичный кейс — время в Unix-секундах.
import (
"encoding/json"
"fmt"
"strconv"
"time"
)
// UnixTime оборачивает time.Time и сериализуется в Unix-секунды
type UnixTime struct {
time.Time
}
func (t UnixTime) MarshalJSON() ([]byte, error) {
return []byte(strconv.FormatInt(t.Unix(), 10)), nil
}
func (t *UnixTime) UnmarshalJSON(data []byte) error {
sec, err := strconv.ParseInt(string(data), 10, 64)
if err != nil {
return fmt.Errorf("UnixTime: ожидалось число, получено %s", string(data))
}
t.Time = time.Unix(sec, 0)
return nil
}
type Event struct {
Name string `json:"name"`
CreatedAt UnixTime `json:"created_at"`
}
Теперь {"name":"deploy","created_at":1718000000} корректно разберётся, а при сериализации время запишется обратно числом.
json.RawMessage: отложенный разбор
Когда структура поля зависит от значения другого поля (полиморфный JSON), разбирать всё за один проход невозможно. json.RawMessage позволяет сохранить кусок JSON «как есть» и разобрать его позже, когда тип уже известен.
type Webhook struct {
Event string `json:"event"`
Payload json.RawMessage `json:"payload"` // разберём позже
}
type UserPayload struct {
UserID int64 `json:"user_id"`
Email string `json:"email"`
}
type OrderPayload struct {
OrderID int64 `json:"order_id"`
Total float64 `json:"total"`
}
func handleWebhook(data []byte) error {
var wh Webhook
if err := json.Unmarshal(data, &wh); err != nil {
return err
}
switch wh.Event {
case "user.created":
var p UserPayload
return json.Unmarshal(wh.Payload, &p)
case "order.paid":
var p OrderPayload
return json.Unmarshal(wh.Payload, &p)
default:
return fmt.Errorf("неизвестный тип события: %s", wh.Event)
}
}
json.RawMessage — это просто type RawMessage []byte, реализующий интерфейсы Marshaler и Unmarshaler. Хранит исходные байты без аллокации промежуточных структур.
Сравнение подходов: когда что выбирать
| Задача | Подход | Цена |
|---|---|---|
| Простой DTO c фиксированной схемой | Структура + теги | Минимум кода, максимум читаемости |
| Отличить «не задано» от нулевого значения | Указатели на поля | Повсеместные проверки на nil, больше аллокаций |
| Нестандартный формат поля (Unix-время, enum) | Интерфейсы Marshaler/Unmarshaler | Кастомный тип на каждое нестандартное поле |
| Полиморфный payload | json.RawMessage + двухэтапный разбор | Двойной unmarshal, нужен switch по типу |
| Произвольный JSON без схемы | map[string]any | Потеря типизации, каст каждого значения вручную |
| Высокая нагрузка, критичен CPU | Сторонние библиотеки (json-iterator, sonic) | Внешняя зависимость, возможны расхождения с encoding/json |
Типичные ошибки
- Неэкспортируемые поля. Поле
name string(с маленькой буквы) не сериализуется и не десериализуется. Ошибки не будет — поле просто не попадёт в JSON и не заполнится из него. Это самая частая причина «пустого» объекта у новичков. - Передача значения вместо указателя в Unmarshal.
json.Unmarshal(data, u)вместоjson.Unmarshal(data, &u)— компилятор не предупредит, еслиuуже указатель; но если это значение, данные никуда не запишутся. - Числа больше 253. JSON не различает целые и дробные числа. JavaScript и многие парсеры теряют точность после 253. Если вы отдаёте 64-битные идентификаторы клиенту на JS — сериализуйте их строкой через тег
,string. - Игнорирование ошибки Marshal.
json.Marshalвозвращает ошибку, хотя на практике она возникает редко (каналы, функции, complex-числа в структуре). Привычка писатьdata, _ := json.Marshal(x)сломается в неожиданный момент.
Что делать дальше
Стандартный encoding/json использует рефлексию, и на больших объёмах (десятки тысяч объектов в секунду) это заметно. Если профайлер показывает, что сериализация стала узким местом, присмотритесь к кодогенераторам вроде easyjson или библиотекам с JIT-подходом — но замеряйте на своих данных, а не на синтетике. Для подавляющего большинства сервисов стандартного пакета хватает с запасом.
Отдельно стоит следить за proposal по пакету encoding/json/v2, который обсуждается в Go-сообществе и обещает более строгое поведение по умолчанию. Актуальный статус смотрите в трекере Go на GitHub.
Проектная работа: От проблемы до защиты: продукт, этапы, вопросы комиссии
Спросить за 15 ₽Источники: Пакет encoding/json — официальная документация Go, JSON and Go — The Go Blog
Частые вопросы
Чем отличается json.Unmarshal от json.NewDecoder?
Как сериализовать поле с разными возможными типами (число или строка)?
Почему после Unmarshal все поля структуры пустые?
Как обработать null в JSON — отличить его от отсутствующего поля?
Не сработало в другой версии Go — что проверить?
encoding/json медленный — стоит ли сразу брать стороннюю библиотеку?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.