Binding & Validating Input
In Phase 2 you pulled values out of the URL one at a time — c.Param("id"), c.Query("done") — and got back strings you had to massage by hand. That's fine for one parameter. The moment a client POSTs a JSON body with five fields, doing it by hand turns into a pile of c.GetRawData, json.Unmarshal, and "is this field actually present?" checks. Gin has a better way, and it's the part of the framework you'll reach for in almost every handler.
The mental model: decode + validate, in one move
💡 Binding is one step that does two jobs: it decodes the incoming request (a JSON body, a query string, the URL path) onto a Go struct you define, and it validates that struct against rules you wrote as tags. After it succeeds, your handler works with normal, typed Go values —
in.Titleis astring,in.Doneis abool— not with raw bytes ormap[string]any.
Think of the struct as a contract. You declare the shape you expect; binding either fills that shape with clean data or hands you an error explaining why it couldn't. Your handler logic never runs on half-parsed garbage, because you return early the instant binding fails.
That single idea — "describe the input as a struct, let Gin fill and check it" — is what this whole phase is about. Everything else is which method to call and which tags to write.
ShouldBindJSON vs BindJSON: who handles the error?
Gin gives you two flavors of every binder, and the difference is one decision: who writes the 400 response when the input is bad?
c.ShouldBindJSON(&obj)decodes and validates, then returns anerrorand writes nothing. If it fails, you decide the status code and the response body. This is the idiomatic choice — you stay in control.c.BindJSON(&obj)does the same decode and validate, but on failure it automatically aborts the request with a400and a default error body. Less typing, less control, and a response shape you didn't choose.
Reach for ShouldBind* by default. Here's the create-task handler for our tasks API, written the idiomatic way:
What just happened: We declared a CreateTask struct describing the body we expect. ShouldBindJSON(&in) read the request body, unmarshalled it onto in, and checked the binding rules. If anything went wrong — malformed JSON, a missing title, a title over 120 characters — we got a non-nil error, returned 400 with a message we chose, and bailed before touching in. If it succeeded, the rest of the handler runs on trustworthy data.
📝 Note we bind onto a separate
CreateTaskstruct, not directly onto our storedTask{id, title, done}. The input contract and the stored model are different things — the client doesn't get to set theid. Keeping them apart is a small habit that saves real bugs later.
Struct tags: json names, binding validates
Two different tags do two different jobs on the same field, and mixing them up is a common early stumble:
json:"title"tells the decoder which JSON key maps to this field. Without it, Gin matches case-insensitively on the field name, but being explicit is clearer and survives renames.binding:"required,min=1"tells the validator what rules this field must satisfy. Rules are comma-separated.
Gin's validation is powered by go-playground/validator v10, a mature library with a big rule vocabulary. The ones you'll actually use:
| Rule | Meaning |
|---|---|
required |
Field must be present and non-zero |
email |
Must be a valid email address |
min / max |
String length, or numeric value, bounds |
gte / lte |
Numeric: greater/less than or equal |
oneof=a b c |
Must be exactly one of the listed values |
len |
Exact length |
numeric |
Must be a numeric string |
A richer input struct for the tasks API shows several at once:
What just happened: Title must be present and 1–120 characters. Priority is optional (omitempty skips validation when it's empty), but if supplied it must be exactly low, medium, or high — anything else is a 400. Owner is optional but must look like an email when present. Estimate must land between 0 and 40 inclusive. All of that enforcement is declarative: no if statements in your handler, just tags.
Binding query strings and URI params
JSON isn't the only thing you can bind. The same struct-driven approach works for the query string and for the path parameters from your routes — they just use different tags.
Query string uses form tags and ShouldBindQuery. This is the clean way to handle the list-with-filters endpoint:
What just happened: A request to /tasks?done=true&limit=20 gets decoded onto q — Gin parses "true" into a real bool and "20" into a real int, with the limit bounds enforced. No more strconv.Atoi by hand. (We'll come back to why Done is a *bool in a second.)
URI params use uri tags and ShouldBindUri. Remember the /tasks/:id route from Phase 2 — here's how to bind and validate that :id:
What just happened: The uri:"id" tag wires the struct field to the :id route parameter. ShouldBindUri pulls the path segment and converts it to an int; a request to /tasks/abc fails the conversion and returns 400, so your handler never sees a bad id. There's also c.ShouldBind, which picks the binder automatically based on the request's Content-Type (JSON body for application/json, form data otherwise) — handy when a handler accepts more than one input format.
⚠️ The zero-value gotcha
Here's the trap that bites everyone once. Go has no concept of "absent" for a plain bool or int — a missing field decodes to the type's zero value: false for bool, 0 for int, "" for string.
That collides with validation in two ways:
What just happened: You wanted "done must be provided." But required rejects the zero value, and false is the zero value for bool. So a client sending {"done": false} — a perfectly valid, deliberate "mark it not done" — gets rejected as if the field were missing. required works well for strings and pointers (where empty/nil genuinely means absent); it does not distinguish "the client sent false" from "the client sent nothing" for a bare bool.
The fix when absent must differ from false/0 is a pointer:
What just happened: With *bool, a missing done decodes to nil and an explicit {"done": false} decodes to a pointer to false. Now you can tell them apart with a simple nil check — exactly what a PATCH-style partial update needs. The cost is a little pointer-dereferencing in the handler; the payoff is that "don't touch this field" and "set this field to false" stop being the same thing. When you don't need that distinction (a full create where every field is meant to be provided), plain values plus required on the strings is simpler and fine.
Recap
- Binding = decode + validate in one step: describe the input as a struct, and Gin fills it with typed values and checks your rules, so handlers never run on raw or half-parsed data.
- Prefer
ShouldBind*overBind*:ShouldBindJSONreturns anerrorand lets you choose the status and body;BindJSONauto-aborts with a default400. - Two tags, two jobs:
json:"..."(orform:/uri:) names the field;binding:"..."declares validation rules via go-playground/validator v10 (required,email,min/max,oneof,gte/lte, …). - One struct shape per source:
ShouldBindJSONfor bodies,ShouldBindQuery(withformtags) for the query string,ShouldBindUri(withuritags) for path params like:id. - Watch the zero value:
requiredrejectsfalse/0/"", so it can't tell "absent" from "explicitly false" on barebool/int— use pointers when that difference matters.
Quick check
[
{
"q": "What's the practical difference between c.ShouldBindJSON and c.BindJSON?",
"choices": ["ShouldBindJSON validates but BindJSON does not", "ShouldBindJSON returns an error and writes nothing; BindJSON auto-aborts with a 400 on failure", "BindJSON is faster because it skips struct tags", "They are identical aliases for the same function"],
"answer": 1,
"explain": "ShouldBindJSON hands you the error so you control the response; BindJSON automatically aborts with a default 400."
},
{
"q": "Which struct tag declares a validation rule (as opposed to naming the JSON field)?",
"choices": ["json:\"title\"", "form:\"title\"", "binding:\"required,min=1\"", "uri:\"id\""],
"answer": 2,
"explain": "binding:\"...\" holds the go-playground/validator rules. json/form/uri just map a field to a source key."
},
{
"q": "Why does binding:\"required\" on a plain bool field cause trouble for a 'mark as not done' update?",
"choices": ["bool fields can't be bound at all", "required rejects the zero value, and false IS the zero value, so a deliberate {\"done\": false} is rejected as if absent", "required only works on query parameters", "bool fields always default to true"],
"answer": 1,
"explain": "A missing field and false both decode to the zero value, so required can't tell them apart. Use *bool when absent must differ from false."
}
]
← Phase 2: Routing & Route Groups · Guide overview · Phase 4: Responses & Rendering →
Check your understanding
1. What's the practical difference between c.ShouldBindJSON and c.BindJSON?
2. Which struct tag declares a validation rule (as opposed to naming the JSON field)?
3. Why does binding:"required" on a plain bool field cause trouble for a 'mark as not done' update?