---
title: "Привязка данных (binding)"
description: "Привязка данных в Gin: ShouldBindJSON, Query и Uri, теги binding и обязательные поля. TodoInput, ошибки валидации gin и ответ 400 — с примерами и разбором."
course: "Gin: самый популярный веб-фреймворк Go"
url: "https://golangmaster.ru/courses/gin/lessons/04-binding/"
published: "2026-09-19"
updated: "2026-09-21"
minutes: 22
---

# Привязка данных (binding)

*Курс «Gin: самый популярный веб-фреймворк Go», урок 4 из 25. ~22 минут. Обновлено 21.09.2026. Источник: https://golangmaster.ru/courses/gin/lessons/04-binding/*

Привязка данных в Gin — это gin binding: один вызов ShouldBindJSON декодирует JSON-тело в структуру и сразу исполняет теги binding, а ошибка возвращается вам для аккуратного ответа 400. Разберём семейство Should и Bind, теги form и uri для query-строки и пути, обязательные поля, указатели для опциональных и свои сообщения об ошибках — на хендлерах трекера задач.

### Зачем нужна привязка: gin binding одним вызовом

В уроке про [первый сервер](/courses/gin/lessons/02-pervyj-server/) мы принимали POST-тело в map[string]any: поле пришло или не пришло — и проверять это приходилось вручную. Привязка (binding) — следующий шаг: gin binding декодирует запрос сразу в вашу структуру и заодно прогоняет её через валидатор по тегам. Поля, типы, обязательность и ограничения живут в одном объявлении структуры, а хендлер остаётся коротким: вызвать ShouldBindJSON, разобрать ошибку, работать с готовыми данными. Ручные проверки — пустая строка, нечисловой page, отрицательный priority — переезжают из тела хендлера в теги структуры. Трекер задач продолжаем на входной структуре TodoInput: на ней пройдём JSON-тело, query-фильтры списка и параметры пути. Ручную конвертацию строк через strconv, знакомую по [прошлому уроку](/courses/gin/lessons/03-parametry/), ShouldBindQuery и ShouldBindUri снимут с вас полностью.

### ShouldBindJSON против BindJSON: кто пишет 400

В Gin два семейства методов привязки, и разница между ними — только в обработке ошибки. ShouldBindJSON привязывает и возвращает error: ответ клиенту пишете вы сами, со своим форматом и своим статусом. BindJSON — это сокращение для MustBindWith(obj, binding.JSON): при ошибке метод сам отвечает статусом 400 (а при превышении лимита размера тела — 413), кладёт ошибку в c.Errors, прерывает цепочку хендлеров через Abort и всё равно возвращает error. Паники при этом нет — это распространённый миф из старых статей; но если после BindJSON вы попробуете ответить своим c.JSON(http.StatusOK, ...), заголовки уже отправлены, и в консоли появится [WARNING] Headers were already written. Правило простое: Should-семейство — когда формат ошибок ваш (JSON API с массивом ошибок), Bind-семейство — для черновиков и внутренних утилит, где голый 400 без тела допустим. У MustBindWith есть ярлыки на все форматы: BindXML, BindYAML, BindTOML, BindQuery, BindUri, BindHeader, в свежих версиях ещё и BindPlain.

### POST /todos: TodoInput с тегами binding

```go
type TodoInput struct {
        Title    string `json:"title" binding:"required,max=140"`
        Priority int    `json:"priority" binding:"min=0,max=3"`
        Done     bool   `json:"done"`
}

r.POST("/todos", func(c *gin.Context) {
        var in TodoInput

        // Декодируем JSON-тело и сразу исполняем теги binding.
        if err := c.ShouldBindJSON(&in); err != nil {
                c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
                return
        }

        c.JSON(http.StatusCreated, gin.H{"todo": in})
})
```

Тег у поля двойной: имя json для декодера и binding для валидатора, правила внутри разделяются запятой. Title обязан быть непустым и не длиннее 140 символов; priority — число от 0 до 3; done без тега binding — любое. Пустой title, priority=-1 или битый JSON уедут в ветку ошибки со статусом 400, валидный запрос вернёт 201 Created и эхо структуры. ShouldBindJSON принимает указатель — &in: привязывать некуда, если передать структуру по значению.

### Что лежит в err.Error()

err.Error() у ошибки валидации — техническая строка вида Key: 'TodoInput.Title' Error:Field validation for 'Title' failed on the 'required' tag. Несколько нарушений валидатор склеивает переводами строк: title пуст, у description превышен максимум — обе строки приедут одним error. Для API это некрасиво, но для отладки честно: сразу видно поле и тег, на котором споткнулись. Отдельный класс — ошибки разбора, до валидации дело просто не доходит: пустое тело вернёт EOF, битый JSON — invalid character ... looking for beginning of value. Обе ветки ловятся одинаково — if err != nil, ответ 400; и семантика у статуса общая: Bad Request честен в обоих случаях, виноват клиент. Разделять их и собирать человекочитаемые сообщения будем ниже — через errors.As и validator.ValidationErrors; там же вытащим имя поля и тег программно.

### Помощник ответа об ошибке привязки

```go
// respondBindError — единый ответ на любую ошибку привязки.
func respondBindError(c *gin.Context, err error) {
        // Ошибки валидации: соберём по одному сообщению на поле.
        var verrs validator.ValidationErrors
        if errors.As(err, &verrs) {
                msgs := make([]string, 0, len(verrs))
                for _, e := range verrs {
                        // e.Field() — имя поля, e.Tag() — споткнувшийся тег.
                        msgs = append(msgs, e.Field()+": "+msgForTag(e.Tag()))
                }
                c.JSON(http.StatusBadRequest, gin.H{"errors": msgs})
                return
        }

        // Иначе — ошибка разбора: битый JSON, EOF, нечисловой параметр.
        c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
}

// msgForTag — карта тегов в короткие человеческие фразы.
func msgForTag(tag string) string {
        switch tag {
        case "required":
                return "обязательное поле"
        case "max":
                return "превышен лимит длины или значения"
        case "min":
                return "значение меньше допустимого"
        default:
                return "не прошло проверку"
        }
}
```

Импорты: errors из стандартной библиотеки и github.com/go-playground/validator/v10 — та же библиотека, что крутится внутри Gin. errors.As достаёт ValidationErrors, если ошибка именно валидационная; у JSON-биндера это всегда так, когда декодирование прошло успешно. Клиент получает массив errors по полям вместо простыни Key: '...' — фронтенду проще подсветить нужные инпуты.

### Теги binding и валидация gin: required, min, max, email

Под капотом теги binding исполняет go-playground/validator/v10 — библиотека, о которой мы упоминали ещё в [знакомстве с Gin](/courses/gin/lessons/01-znakomstvo/). Gin инициализирует её один раз вызовом SetTagName("binding"), поэтому тег в Gin называется binding, а не validate: в [Echo](/courses/echo/) тот же валидатор стоит по умолчанию с тегом validate, и структуры между проектами придётся переписывать. required требует не-нулевое значение: непустая строка, число не ноль. min и max полиморфны: для строк проверяют длину в рунах, для чисел — значение, для срезов и карт — количество элементов. binding:"min=1,max=140" на строке — длина от 1 до 140 символов; те же правила на int — значение от 1 до 140. Другие частые теги: email проверяет формат адреса, oneof ограничивает набором значений (oneof=open done), gte и lte — сравнения. У валидатора есть собственный omitempty: правило после него пропускается, если поле нулевое — не путайте с json:"...,omitempty", который работает только при сериализации ответа. Про [валидацию gin](/courses/gin/) подробно будет отдельный урок, здесь — база, без которой привязка не собирается.

> **required на bool запрещает false.** required проваливается на любом нулевом значении — и false для bool тоже ноль. Поставите binding:"required" на Done bool, и ни один запрос с "done": false не пройдёт: клиент будет обязан прислать true. На необязательные логические поля required не вешают; если нужно отличать «не прислали» от false — берите *bool и проверяйте на nil.

### Семейство биндеров: источник, тег имён, авто-400

| Метод | Источник данных | Тег имён полей | Валидация binding | Авто-400 |
| --- | --- | --- | --- | --- |
| c.ShouldBindJSON | JSON-тело | json | да | нет |
| c.BindJSON | JSON-тело | json | да | да: 400 и abort |
| c.ShouldBind | по Content-Type; у GET — form | json, xml или form | да | нет |
| c.ShouldBindQuery | query-строка | form | да | нет |
| c.ShouldBindUri | параметры пути :id | uri | да | нет |
| c.ShouldBindHeader | HTTP-заголовки | header | да | нет |
| json.Unmarshal вручную | JSON-тело | json | нет — сами | нет |

### ShouldBindQuery: тег form, дефолты и типы

Query-параметры из прошлого урока приходят строками, и каждый c.Query("limit") со следующим за ним strconv — ручная работа. ShouldBindQuery делает это одним вызовом: имена полей берёт из тега form, типы конвертирует сама (int, bool, float, слайсы по повторяющимся ключам), а после заполнения запускает ту же валидацию binding — required на query-поле сработает так же, как на JSON-теле. Отсутствующий ключ поле не трогает: останется нулевое значение. В теге form можно задать дефолт — form:"limit,default=20": подставится, только если параметра нет вовсе; а вот ?limit= молча превратится в ноль, и провалится уже тег binding:"min=1". Чтобы различать «ключа нет» и «ключ пуст», берите указатель: *int останется nil при отсутствии и укажет на число при наличии — об этом ниже. Имя в form-теге чувствительно к регистру: form:"page" не поймает ?Page=2, в отличие от JSON-декодера, который матчит поля без учёта регистра.

### Фильтр списка: TodoFilter через ShouldBindQuery

```go
type TodoFilter struct {
        Status   string `form:"status" binding:"omitempty,oneof=open done"`
        Priority *int   `form:"priority" binding:"omitempty,min=0,max=3"`
        Limit    int    `form:"limit,default=20" binding:"min=1,max=100"`
}

r.GET("/todos", func(c *gin.Context) {
        var f TodoFilter

        // Query-строка заполняет поля по тегу form.
        if err := c.ShouldBindQuery(&f); err != nil {
                respondBindError(c, err)
                return
        }

        c.JSON(http.StatusOK, gin.H{"filter": f})
})
```

Три приёма в одной структуре: omitempty в теге binding пропускает проверку для нулевых значений — пустой status не упрётся в oneof; Priority — указатель, при отсутствии параметра останется nil, при ?priority=2 укажет на число; limit с default=20 обходится без указателя. Валидация для query запускается так же, как для тела: ?limit=500 получит 400 по тегу max=100. В JSON-ответе отсутствующий priority сериализуется как "priority": null — честный сигнал для фронтенда.

### Что вернёт GET /todos?priority=abc

```go
type TodoFilter struct {
        Priority *int `form:"priority" binding:"omitempty,min=0,max=3"`
        Limit    int  `form:"limit,default=20" binding:"min=1,max=100"`
}

r.GET("/todos", func(c *gin.Context) {
        var f TodoFilter
        if err := c.ShouldBindQuery(&f); err != nil {
                respondBindError(c, err)
                return
        }
        c.JSON(http.StatusOK, gin.H{"filter": f})
})
```

**Что выведет программа?**

- 200 и {"filter":{"priority":null}}
- ✅ 400 и strconv.ParseInt: parsing "abc": invalid syntax
- 400 и Field validation for 'Priority' failed on the 'min' tag
- 200 и {"filter":{"priority":0}}

priority=abc — не валидационная, а ошибка конвертации: строка в *int превращается через strconv.ParseInt, и её текст приедет как есть. До тегов omitempty и min дело не доходит — разбор сломался раньше. Держите в голове разделение: битое значение параметра — ошибка разбора, непустое, но запрещённое правилом — ошибка валидации.

### ShouldBindUri: тег uri и параметры пути

Параметры пути — :id из маршрута — тоже привязываются структурой. ShouldBindUri собирает c.Params в карту, заполняет поля по тегу uri и затем исполняет ту же валидацию binding. Тип конвертируется автоматически: ID int64 вместо ручного strconv.Atoi. Синтаксис выглядит избыточно — параметр объявлен и в маршруте, и в теге, — но имена должны совпадать посимвольно: опечатка в uri-теге оставит поле нулевым молча, без ошибок. required на uri-поле бессмыслен: сегмент, не подходящий под :id, просто не сматчит маршрут; зато binding:"min=1" отсечёт /todos/0, если нулевые идентификаторы в вашей схеме не существуют. Числовой мусор вида /todos/abc вернёт ошибку ParseInt — как и в query, это ошибка разбора, а не валидации, и respondBindError её классифицирует правильно.

### PATCH /todos/:id: uri плюс JSON-тело

```go
type TodoUri struct {
        ID int64 `uri:"id" binding:"min=1"`
}

type TodoUpdate struct {
        Done     *bool `json:"done"`
        Priority *int  `json:"priority" binding:"omitempty,min=0,max=3"`
}

r.PATCH("/todos/:id", func(c *gin.Context) {
        var u TodoUri

        // Сперва параметры пути.
        if err := c.ShouldBindUri(&u); err != nil {
                respondBindError(c, err)
                return
        }

        var in TodoUpdate

        // Затем JSON-тело: два источника — два вызова.
        if err := c.ShouldBindJSON(&in); err != nil {
                respondBindError(c, err)
                return
        }

        c.JSON(http.StatusOK, gin.H{"id": u.ID, "done": in.Done})
})
```

Один хендлер — две привязки с разными источниками: путь читается по uri-тегу, тело — по json. Они не конфликтуют: тело одноразовое, параметры пути — нет. TodoUpdate собран из указателей: поле, которого нет в теле, останется nil — это основа частичного обновления PATCH, когда клиент присылает только done или только priority. Обе ошибки идут через общий помощник respondBindError со статусом 400.

### Проверка: что делает BindJSON при ошибке

**Вопрос:** Хендлер вызывает c.BindJSON(&in) и игнорирует возвращённую ошибку. Что произойдёт при невалидном JSON?

- ✅ Gin сам ответит статусом 400, прервёт цепочку хендлеров и положит ошибку в c.Errors
- Ошибка только запишется в c.Errors — ответ целиком остаётся за вами
- Произойдёт паника, и Recovery вернёт 500
- Ничего: BindJSON молча оставит in нулевым

BindJSON — это MustBindWith: AbortWithError(400) пишет статус (тело ответа пустое), добавляет ошибку в c.Errors и прерывает цепочку. Паники нет. Ваш последующий c.JSON уже не изменит статус — в консоли появится [WARNING] Headers were already written.

### Проверка: min и max на строке

**Вопрос:** Поле Title string с тегом binding:"min=1,max=140". Что именно проверяет max=140?

- ✅ Длину строки — не больше 140 рун
- Числовое значение строки — не больше 140
- Размер в байтах UTF-8 — не больше 140
- Ничего: max работает только для чисел и срезов

Для строк min и max считают длину в рунах, не в байтах: слово «ёлка» — четыре руны и восемь байт. Для чисел те же теги проверяют значение, для срезов и карт — количество элементов. Поэтому oneof, а не max, ограничивает набор строковых значений.

> **Тело запроса читается один раз.** Два ShouldBindJSON на одном запросе не сработают: после первого чтения поток пуст, и второй вернёт EOF. Если одна и та же структура нужна дважды — например, для логирования и для обработки — есть c.ShouldBindBodyWith(&obj, binding.JSON): она кэширует байты тела в контексте и отдаёт их повторно. Query и uri одноразовыми не являются, их можно привязывать сколько угодно раз.

### c.ShouldBind: Gin сам выбирает биндер

ShouldBind без суффикса определяет биндер сам: binding.Default(method, contentType) смотрит на метод и заголовок Content-Type. application/json — JSON-биндер, application/xml — XML, multipart/form-data — формы с файлами, всё остальное, включая пустой заголовок, — form-биндер. У GET-запросов выбор всегда form, что бы ни стояло в заголовке: query — единственный их источник данных. Отсюда ловушка: ShouldBindJSON заголовок игнорирует — JSON с Content-Type: text/plain он привяжет, а ShouldBind отправит тот же запрос в form-биндер и, скорее всего, упрётся в required. Ручной json.Unmarshal остаётся нишевым инструментом: кастомный декодер, потоковое чтение большого тела, нестандартный формат. В [Fiber](/courses/fiber/) из параллельного курса похожая автоопределение живёт в BodyParser. Но у ручного пути цена: валидацию binding придётся запускать самому.

### Тот же хендлер руками: json.Unmarshal

```go
var in TodoInput

// Читаем тело целиком и декодируем вручную.
body, err := io.ReadAll(c.Request.Body)
if err != nil {
        c.JSON(http.StatusBadRequest, gin.H{"error": "read: " + err.Error()})
        return
}

if err := json.Unmarshal(body, &in); err != nil {
        c.JSON(http.StatusBadRequest, gin.H{"error": "json: " + err.Error()})
        return
}

// Unmarshal теги binding не исполняет — валидируем сами.
if err := binding.Validator.ValidateStruct(in); err != nil {
        respondBindError(c, err)
        return
}

c.JSON(http.StatusCreated, gin.H{"todo": in})
```

Три шага вместо одного — чтение, декодирование, валидация: ровно столько работы делает ShouldBindJSON под капотом. binding.Validator — интерфейс валидатора Gin: ValidateStruct исполняет теги binding и возвращает знакомые ValidationErrors. Ручной путь оправдан, когда нужен нестандартный декодер или промежуточная обработка сырых байтов; в остальных случаях это дублирование стандартной механики. Учтите: json.Unmarshal молча игнорирует неизвестные поля, а в Gin за это отвечает глобальный флаг binding.EnableDecoderDisallowUnknownFields.

### Опциональные поля: указатели и omitempty

Значение типа не умеет отличать «клиент не прислал» от «прислал ноль»: Done bool после привязки равно false в обоих случаях. Указатели закрывают разрыв: *bool останется nil, если поля не было в JSON, и покажет false при явном "done": false — на этом стоит частичное обновление из PATCH выше. Для строк та же логика: *string — nil против пустой строки. Отдельно про json:"...,omitempty": тег действует только при маршалинге ответа — нулевые поля не попадут в исходящий JSON; на входе он ничего не делает, опциональность на входе дают указатель или валидаторский omitempty в теге binding. Form-биндер тоже умеет указатели: ключ отсутствует — поле останется nil, ключ есть — выделится значение. Цена — проверки на nil перед использованием: разыменование *in.Done при nil — паника, поэтому перед каждым использованием идёт проверка. Опциональность не нужна — берите значения: меньше аллокаций и меньше мест для nil.

### Свои сообщения об ошибках: три стратегии

Первая стратегия — карта тегов, как в msgForTag: быстро и без зависимостей, но тексты не знают про параметры тега. Параметр достаётся отдельно: e.Param() вернёт 140 из max=140, e.Value() — присланное значение, e.Field() — имя поля. Вторая — RegisterTranslation: валидатор умеет подменять сообщения на любые языки через universal-translator, движок достаётся через binding.Validator.Engine(). Третья — не переводить вовсе: API отдаёт структурированные ошибки (поле, тег, параметр), а человекочитаемые тексты рисует фронтенд на своём языке. Для одного проекта честнее первая или третья: RegisterTranslation тянет зависимость ради текстов, которые чаще всего всё равно живут во фронтенде. Чего точно не делать — парсить строку err.Error() по подстроке Key: '...': формат не контракт, и смена версии валидатора сломает парсер молча.

### Допишите createTodo

Восстановите тег обязательного поля, вызов привязки и статус ответа об ошибке.

```go
type TodoInput struct {
        Title string `json:"title" binding:"___,max=140"`
        Done  bool   `json:"done"`
}

func createTodo(c *gin.Context) {
        var in TodoInput

        if err := c.___(&in); err != nil {
                c.JSON(http.___, gin.H{"error": err.Error()})
                return
        }

        c.JSON(http.StatusCreated, gin.H{"todo": in})
}
```

Подсказка: Обязательное поле — тег required; метод Should-семейства возвращает ошибку вместо автоответа; статус 400 в net/http называется StatusBadRequest.

Правильные ответы в пропусках: `required`, `ShouldBindJSON`, `StatusBadRequest`

### Соберите updateTodo из строк

Соберите хендлер частичного обновления: сперва параметры пути, затем JSON-тело, затем успешный ответ.

Правильный порядок строк:

1. `func updateTodo(c *gin.Context) {`
2. `  var u TodoUri`
3. `  if err := c.ShouldBindUri(&u); err != nil {`
4. `    c.JSON(http.StatusBadRequest, gin.H{"error": "некорректный id"})`
5. `    return // до тела дело не дошло`
6. `  } // параметры пути готовы`
7. `  var in TodoUpdate`
8. `  if err := c.ShouldBindJSON(&in); err != nil {`
9. `    c.JSON(http.StatusBadRequest, gin.H{"error": "некорректное тело"})`
10. `    return // до ответа не дошли`
11. `  } // тело готово`
12. `  c.JSON(http.StatusOK, gin.H{"id": u.ID, "done": *in.Done})`
13. `}`

### Карточки: привязка данных в Gin

- **ShouldBindJSON** — Декодирует JSON-тело в структуру и исполняет теги binding. Возвращает ошибку — ответ 400 пишете вы сами.
- **BindJSON** — Ярлык MustBindWith: при ошибке сам отвечает 400 и прерывает цепочку через Abort. Ошибка всё равно возвращается.
- **Тег binding** — Имя тега валидатора в Gin: SetTagName("binding") над go-playground/validator/v10. Правила: required, min, max, email, oneof.
- **Тег form** — Имена полей для ShouldBindQuery и form-биндера. Без тега берётся имя поля; поддерживает default=X. Регистр важен.
- **Тег uri** — Имена полей для ShouldBindUri — параметры пути :id. Валидация binding запускается и здесь: min=1 отсечёт /todos/0.
- **required и bool** — required проваливается на нулевом значении: false — это ноль. Обязательное логическое поле через binding так не делают — берите *bool.

> **Проверяйте привязку curl-ом.** Привязка и валидация живут в HTTP-слое, и прощупывать их удобнее всего curl-ом с флагом -i: статус и тело ответа видны целиком. Три запроса — {} без title, priority=-1 и битый JSON — закрывают все ветки хендлера за пару секунд. Заодно взгляните на строку [GIN] в консоли: статус 400 подскажет, что запрос дошёл до валидатора, а не упал раньше по маршруту.

## Коротко: что запомнить

- Should-семейство возвращает ошибку — ответ 400 пишете вы; Bind-семейство (MustBindWith) само отвечает 400 и прерывает цепочку.
- Под тегами binding — go-playground/validator/v10; для строк min и max проверяют длину рун, для чисел — значение.
- ShouldBindQuery читает тег form, ShouldBindUri — тег uri; валидация binding исполняется в обоих случаях.
- c.ShouldBind выбирает биндер по Content-Type (у GET — всегда form), ShouldBindJSON заголовок игнорирует.
- err.Error() валидатора — техническая строка; для API разбирайте validator.ValidationErrors через errors.As.
- Опциональные поля — указатели: nil отличает «не прислали» от нулевого значения; json omitempty работает только при сериализации.
- binding:"required" на bool запрещает false — не вешайте required на необязательные логические поля.

## Домашнее задание

Добавьте в TodoInput поле Description string с тегом json:"description" и ограничением max=200. Сделайте DELETE /todos/:id со структурой TodoUri и эндпоинт GET /todos с фильтром TodoFilter (status, limit с default=10) через ShouldBindQuery. Прогоните curl-ом три запроса: пустой title, priority=-1 и limit=abc — все три должны вернуть 400 с внятными сообщениями из respondBindError.

## Вопросы и ответы по уроку

### Чем ShouldBindJSON отличается от ручного json.Unmarshal?

ShouldBindJSON читает тело из c.Request.Body и сразу исполняет теги binding; Unmarshal — только декодирование, валидацию запускаете сами через binding.Validator.ValidateStruct. Ручной путь берут ради кастомных декодеров и нестандартных форматов.

### Почему второй ShouldBindJSON возвращает EOF?

Тело запроса — поток, его читают один раз: после первой привязки декодеру уже нечего читать. Для повторной привязки тела используйте c.ShouldBindBodyWith — она кэширует байты в контексте. Query и uri одноразовыми не являются.

### Как перевести сообщения валидации на русский?

Короткий путь — карта msgForTag по e.Tag() с параметрами из e.Param(). Полноценный — RegisterTranslation через universal-translator на движке binding.Validator.Engine(). Часто разумнее третий вариант: API отдаёт поле и тег, тексты рисует фронтенд.
