На JSON формате держится почти весь обмен данными между сервисами: API, конфиги, очереди сообщений. В Go для работы с ним есть стандартный пакет encoding/json, и в типичных задачах он покрывает всё. Общая идея простая: структура превращается в JSON и обратно одной функцией. Однако, за этой простотой прячется несколько деталей, на которых регулярно спотыкаются новички. Почему поле может не попадать в вывод, как переименовать ключи, что на самом деле делает omitempty и как разобрать JSON, структуру которого вы не знаете заранее. Разберём по порядку с работающими примерами.
Это один из разборов в серии для начинающих. Общий маршрут изучения языка собран в статье «Go для начинающих: дорожная карта».
Marshal: структура → JSON
Сериализация (то есть превращение значения в JSON) делается функцией json.Marshal. Она принимает любое значение и возвращает срез байтов []byte и ошибку.
package main
import (
"encoding/json"
"fmt"
)
type User struct {
Name string
Email string
Age int
}
func main() {
u := User{Name: "Анна", Email: "anna@example.com", Age: 30}
data, err := json.Marshal(u)
if err != nil {
panic(err)
}
fmt.Println(string(data))
}
$ go run main.go
{"Name":"Анна","Email":"anna@example.com","Age":30}
Результат []byte, поэтому для вывода мы приводим его к строке через string(data). Подробнее про разницу между строками и байтами в Go в статье про работу со строками.
Результат функции Marshal идёт в одну строку без отступов. Это удобно для передачи по сети, но плохо читается человеком. Когда нужен «красивый» JSON (например, для конфига или вывода программы), используйте json.MarshalIndent. В ней вторым аргументом указывается префикс каждой строки, а третьим отступ.
data, _ := json.MarshalIndent(u, "", " ")
fmt.Println(string(data))
$ go run main.go
{
"Name": "Анна",
"Email": "anna@example.com",
"Age": 30
}
Unmarshal: JSON → структура
Обратная операция (то есть разбор JSON в значение) — это json.Unmarshal. Она принимает байты JSON и указатель на то, куда положить результат. Указатель здесь обязателен потому что функции нужно изменить внести изменения в оригинальную переменную, а не её копию.
func main() {
data := []byte(`{"Name":"Борис","Email":"boris@example.com","Age":25}`)
var u User
if err := json.Unmarshal(data, &u); err != nil {
panic(err)
}
fmt.Printf("%+v\n", u)
}
$ go run main.go
{Name:Борис Email:boris@example.com Age:25}
Если забыть & и передать саму структуру, компилятор допустит такой код, но во время исполнения вы получите ошибку json: Unmarshal(non-pointer main.User). Такое бывает у тех, кто только знакомится с языком (и это нормально в начале).
Только экспортируемые поля
Основной нюанс для новичка в том, что encoding/json видит только экспортируемые поля. То есть, те что начинаются с заглавной буквы. Поля со строчной буквы пакет полностью игнорирует и при сериализации, и при десериализации.
type Account struct {
Login string // попадёт в JSON
password string // НЕ попадёт: со строчной буквы, поле не экспортируемо
}
func main() {
a := Account{Login: "admin", password: "secret"}
data, _ := json.Marshal(a)
fmt.Println(string(data))
}
$ go run main.go
{"Login":"admin"}
Причина сугубо техническая и заключается в особенностях реализации. Пакет encoding/json находится вне вашего пакета и через рефлексию имеет доступ только к экспортируемым полям. Если в вашей работе столкнётесь с тем, что поле «пропало» из JSON без видимой причины, то первым делом проверьте регистр первой буквы вашего поля.
Теги структур: управляем именами ключей
По умолчанию имя ключа в JSON совпадает с именем поля: Name → "Name". Но в большинстве API используют форматы наименования snake_case или camelCase. Чтобы задать имя ключа, не меняя имя поля в Go, используются теги структур — строка в обратных кавычках после типа поля.
type User struct {
Name string `json:"name"`
Email string `json:"email"`
Age int `json:"age"`
}
$ go run main.go
{"name":"Анна","email":"anna@example.com","age":30}
Несколько полезных приёмов с тегами:
type Config struct {
Host string `json:"host"` // переименование
Password string `json:"-"` // полностью исключить из JSON
Internal string `json:",omitempty"` // имя по умолчанию, но с опцией
}
json:"host"— поле сериализуется под именемhost.json:"-"— поле всегда игнорируется (удобно для секретов и служебных данных).json:",omitempty"— имя остаётся стандартным (Internal), но добавляется опция. Обратите внимание на ведущую запятую: до неё стоит имя, и если оставить его пустым, используется имя поля.
omitempty: пропускаем пустые значения, но осторожно
Опция omitempty говорит: не включать поле в вывод, если его значение «пустое». Это удобно, чтобы не засорять JSON нулевыми и пустыми полями. Но важно точно понимать, что пакет считает пустым: false, число 0, nil-указатель, nil-интерфейс, а также пустые строка, срез, словарь и массив.
type Filter struct {
Query string `json:"query,omitempty"`
Limit int `json:"limit,omitempty"`
Active bool `json:"active,omitempty"`
}
func main() {
f := Filter{Query: "go"}
data, _ := json.Marshal(f)
fmt.Println(string(data))
}
$ go run main.go
{"query":"go"}
Поля Limit и Active исчезли, хотя мы их не трогали. И здесь кроется ловушка: omitempty не различает «значение отсутствует» и «значение задано, но равно нулю». Если пользователь осознанно прислал limit: 0 или active: false, при сериализации обратно это значение пропадёт. Для флага active такое поведение почти всегда нежелательно.
Есть два решения. Первое, классическое — указатель. Тогда «не задано» это nil, а false/0 это валидное значение, которое попадёт в JSON:
type Filter struct {
Active *bool `json:"active,omitempty"` // nil пропустится, &false — нет
}
Второе, более новое и удобное — опция omitzero, добавленная в стандартный encoding/json в Go 1.24. Она пропускает поле только если оно равно нулевому значению типа, и в отличие от omitempty корректно работает, например, с time.Time (у которой «пустое» по omitempty не определяется). Семантика чище: «нулевое — пропустить». Если указаны обе опции сразу, поле пропускается, когда значение пустое или нулевое.
type Event struct {
Name string `json:"name"`
At time.Time `json:"at,omitzero"` // пропустится, если время не задано
}
При сериализации time.Time помните, что она выводится в формате RFC 3339. О том, почему форматирование дат в Go устроено через «эталонное время» 2006-01-02, — в отдельной статье про даты и время.
Разбираем произвольный JSON
Иногда структура JSON заранее неизвестна или слишком динамична, чтобы описывать её типом. В этом случае разбирают в map[string]any (для объекта) или в any (для произвольного значения).
func main() {
data := []byte(`{"name":"Анна","age":30,"tags":["go","backend"]}`)
var m map[string]any
if err := json.Unmarshal(data, &m); err != nil {
panic(err)
}
fmt.Println(m["name"], m["tags"])
}
$ go run main.go
Анна [go backend]
Здесь важная ловушка: все числа из JSON становятся float64, даже если в исходнике это целые. JSON не различает int и float, а Go при разборе в any выбирает float64 для любого числа.
var m map[string]any
json.Unmarshal([]byte(`{"count": 42}`), &m)
fmt.Printf("%T\n", m["count"]) // float64, а не int
Поэтому m["count"].(int) приведёт к панике — нужно m["count"].(float64) и затем при необходимости int(...). Если работаете с большими целыми числами, где потеря точности float64 недопустима, используйте json.Decoder с UseNumber() — тогда числа разбираются в тип json.Number, который хранит исходную строку. Но в большинстве случаев, если структура хоть как-то известна, лучше описать её обычной структурой: код будет понятнее и безопаснее, чем россыпь приведений типов.
Потоковая обработка: Decoder и Encoder
Marshal и Unmarshal работают с целым []byte в памяти. Когда данные приходят из потока — тело HTTP-запроса, файл, сетевое соединение, — удобнее и экономнее использовать json.Decoder и json.Encoder. Они читают и пишут напрямую в io.Reader/io.Writer, не требуя сначала собрать всё в один буфер.
// Разбор тела HTTP-запроса
func handler(w http.ResponseWriter, r *http.Request) {
var u User
if err := json.NewDecoder(r.Body).Decode(&u); err != nil {
http.Error(w, "некорректный JSON", http.StatusBadRequest)
return
}
// Запись ответа сразу в поток
resp := map[string]string{"status": "ok", "name": u.Name}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(resp)
}
Это идиоматичный способ работы с JSON в веб-сервисах на Go: не читать всё тело в память через io.ReadAll, а отдать r.Body декодеру. Дополнительный бонус — Decoder умеет читать поток из нескольких JSON-значений подряд, вызывая Decode в цикле.
Частые ловушки
Несколько вещей, о которых стоит знать заранее, чтобы не отлаживать их в проде.
- Отсутствующие поля — не ошибка. Если в JSON нет поля, которое есть в структуре,
Unmarshalне вернёт ошибку: соответствующее поле просто останется нулевым. Проверять обязательность полей придётся самостоятельно (или через указатели, чтобы отличить «не пришло» от «пришло нулевое значение»). - Лишние поля молча игнорируются. Если в JSON есть ключи, которых нет в структуре, они тоже не вызывают ошибку. Чтобы запретить такое, используйте
decoder.DisallowUnknownFields()— полезно для строгой валидации входящих данных API. - Сопоставление имён не зависит от регистра. Поле с тегом
json:"name"примет и"name", и"Name", и"NAME". Это редко мешает, но иногда удивляет. - Обрабатывайте ошибку Unmarshal. На невалидном JSON или несовпадении типов (
"age": "тридцать"в полеint) функция вернёт ошибку — её нельзя игнорировать. Про идиоматичную работу с ошибками в Go, проверку черезerrors.As(например, до*json.UnmarshalTypeError) и оборачивание контекста — в статье про обработку ошибок.
Заключение
encoding/json покрывает практически все повседневные задачи с JSON, но держите в голове несколько правил:
- Сериализация —
json.Marshal(илиMarshalIndentдля читаемого вывода), разбор —json.Unmarshalс указателем на приёмник. - В JSON попадают только экспортируемые поля (с заглавной буквы). «Пропавшее» поле — почти всегда вопрос регистра.
- Теги структур задают имена ключей:
json:"name"переименовывает,json:"-"исключает поле. omitemptyпропускает пустые значения, но не отличает «не задано» от нуля. Для флагов и опциональных полей используйте указатели или новую опциюomitzero(Go 1.24+).- Произвольный JSON разбирается в
map[string]any, но помните: все числа там становятсяfloat64. - Для потоков (HTTP, файлы) используйте
json.Decoder/json.Encoderвместо чтения всего в память.
Освоив эти правила, вы закроете подавляющее большинство задач с JSON в Go. Следующие шаги в изучении языка собраны в дорожной карте для начинающих.
FAQ
Почему поле структуры не попадает в JSON?
encoding/json сериализует только экспортируемые поля (с заглавной буквы). Вторая частая причина — опция omitempty или omitzero, которая пропускает поле при пустом или нулевом значении. Третья — тег json:"-", который явно исключает поле.Как переименовать поле в JSON?
json:"name". Имя поля в Go остаётся прежним, а в JSON ключ будет name. Так приводят имена к snake_case или camelCase, принятым в API.Чем omitempty отличается от omitzero?
omitempty пропускает поле, если значение «пустое»: false, 0, nil-указатель, пустые строка/срез/словарь. Он не отличает «не задано» от «задано нулевое значение». omitzero (добавлен в Go 1.24) пропускает поле, если оно равно нулевому значению типа, и корректно работает там, где omitempty бессилен — например, с time.Time. Если нужно отличать нулевое значение от «не задано», используйте указатель.Как разобрать JSON, структура которого неизвестна?
map[string]any для объекта или в any для произвольного значения. Учтите: все числа при этом становятся float64, поэтому m["x"].(int) вызовет панику — нужно m["x"].(float64). Если важна точность больших целых, используйте json.Decoder с UseNumber().Почему json.Unmarshal требует указатель?
Unmarshal менял бы копию, которую вы бы не увидели. Поэтому передают &u. Если забыть &, во время выполнения будет ошибка json: Unmarshal(non-pointer ...).Как обрабатывать JSON из тела HTTP-запроса?
json.NewDecoder(r.Body).Decode(&v) вместо чтения всего тела в память. Декодер читает прямо из потока. Для ответа симметрично применяйте json.NewEncoder(w).Encode(v). Это идиоматичный для Go способ, экономящий память на больших телах запросов.Теги: