JSON в Go: encoding/json, теги структур и omitempty на примерах

На 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"`  // имя по умолчанию, но с опцией
}

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 в цикле.

Частые ловушки

Несколько вещей, о которых стоит знать заранее, чтобы не отлаживать их в проде.

Заключение

encoding/json покрывает практически все повседневные задачи с JSON, но держите в голове несколько правил:

  1. Сериализация — json.Marshal (или MarshalIndent для читаемого вывода), разбор — json.Unmarshal с указателем на приёмник.
  2. В JSON попадают только экспортируемые поля (с заглавной буквы). «Пропавшее» поле — почти всегда вопрос регистра.
  3. Теги структур задают имена ключей: json:"name" переименовывает, json:"-" исключает поле.
  4. omitempty пропускает пустые значения, но не отличает «не задано» от нуля. Для флагов и опциональных полей используйте указатели или новую опцию omitzero (Go 1.24+).
  5. Произвольный JSON разбирается в map[string]any, но помните: все числа там становятся float64.
  6. Для потоков (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 требует указатель?
Функции нужно записать результат в вашу переменную, а Go передаёт аргументы по значению (копией). Без указателя Unmarshal менял бы копию, которую вы бы не увидели. Поэтому передают &u. Если забыть &, во время выполнения будет ошибка json: Unmarshal(non-pointer ...).
Как обрабатывать JSON из тела HTTP-запроса?
Используйте json.NewDecoder(r.Body).Decode(&v) вместо чтения всего тела в память. Декодер читает прямо из потока. Для ответа симметрично применяйте json.NewEncoder(w).Encode(v). Это идиоматичный для Go способ, экономящий память на больших телах запросов.

Теги: