This episode dissects Gin's request binding: JSON, query, form, URI, XML, YAML, and TOML; understanding the struct tags json, form, uri, and binding; the go-playground/validator/v10 library; and the differences between ShouldBind, MustBind, and Bind.

In episode 4 you already learned to design API URLs. Now we get to the part that's often underestimated but causes the most bugs: mapping request data to structs (binding) and making sure the data is valid (validation). If these two aren't handled correctly, your API will accept dirty data.
Episode 5 dissects binding thoroughly: the various formats Gin supports, the struct tags that control mapping, the validator library that enforces rules, and when to use ShouldBind, MustBind, or Bind.
Gin maps request data to a struct through the appropriate tags. JSON data is read from the body, query data from the URL, and form data from an application/x-www-form-urlencoded or multipart body.
type SearchRequest struct {
Query string `json:"query" form:"q" uri:"q" binding:"required"`
Page int `json:"page" form:"page" uri:"page" binding:"gte=1"`
Limit int `json:"limit" form:"limit" uri:"limit" binding:"min=1,max=100"`
}
type CreateUser struct {
Name string `json:"name" form:"name" binding:"required,min=3"`
Email string `json:"email" form:"email" binding:"required,email"`
Age int `json:"age" form:"age" binding:"gte=17,lte=100"`
}The form:"q" tag lets a field be filled via query or form. The uri:"q" tag enables filling via a path parameter. Structures like this are the foundation of a clean REST API.
To read a JSON body, call ShouldBindJSON:
func createUserHandler(c *gin.Context) {
var user CreateUser
if err := c.ShouldBindJSON(&user); err != nil {
c.JSON(400, gin.H{"error": err.Error()})
return
}
c.JSON(201, gin.H{"name": user.Name, "email": user.Email})
}The function c.ShouldBindJSON(&user) reads c.Request.Body, decodes the JSON into the struct, then runs validation. If the Content-Type doesn't match, use the approach we'll discuss shortly.
Gin provides specific methods for each data source:
// query: /search?q=golang&page=2&limit=20
var q SearchRequest
c.ShouldBindQuery(&q)
// uri: /search/golang/2/20
c.ShouldBindUri(&q)
// form: body application/x-www-form-urlencoded
var form CreateUser
c.ShouldBind(&form)c.ShouldBind(&form) picks a binder automatically based on the request's Content-Type: JSON, XML, or form. Meanwhile, ShouldBindQuery, ShouldBindUri, and ShouldBindJSON force a specific format.
Gin uses the go-playground/validator/v10 library behind the scenes. The binding tag defines validation rules that are applied right after a successful bind:
required — field is mandatory.min=3, max=20 — minimum and maximum lengths.email — must be a valid email address.gte=1,lte=100 — value must be within a range.oneof=admin user — value must be one of the listed options.type RegisterInput struct {
Username string `json:"username" binding:"required,min=4,max=16,alphanum"`
Email string `json:"email" binding:"required,email"`
Password string `json:"password" binding:"required,min=8"`
Role string `json:"role" binding:"oneof=user admin"`
Score int `json:"score" binding:"gte=0,lte=100"`
}The oneof=user admin tag ensures the Role field only contains user or admin. Combinations of min, max, email, and alphanum keep input clean from the start.
For complex data, the validator also descends into nested structs with the dive tag. The dive tag instructs the validator to validate every element of a slice; without dive, validation only checks the slice itself, not its contents.
ShouldBind*: returns an error without changing the response status; you handle the error yourself.MustBind*: on error, immediately returns 400 with an error JSON message; the code after it isn't executed.Bind: the old version (deprecated since Gin v1.14 for many cases), automatically picks a binder based on Content-Type and writes 400 on error.// pola yang disarankan
var user CreateUser
if err := c.ShouldBindJSON(&user); err != nil {
c.JSON(400, gin.H{"error": "data tidak valid"})
return
}
// pola cepat, tidak fleksibel
c.MustBindWith(&user, binding.JSON)
c.JSON(200, gin.H{"user": user})c.MustBindWith(&user, binding.JSON) writes a 400 response automatically when binding fails. For full control over error messages, ShouldBindJSON remains the best choice.
The validator returns errors of type validator.ValidationErrors. With errors.As(err, &verrs) you can check whether the error comes from the validator, then read the details of each FieldError such as field, tag, and the failing value — useful for building consistent error responses across your whole API.
Besides JSON, Gin supports binding XML, YAML, and TOML:
var xmlData MyXML
c.ShouldBindXML(&xmlData)
var yamlData MyYAML
c.ShouldBindBodyWithYAML(&yamlData)
var tomlData MyTOML
c.ShouldBindBodyWithTOML(&tomlData)The methods c.ShouldBindBodyWithYAML(&yamlData) and c.ShouldBindBodyWithTOML have been available since Gin v1.10. The ShouldBindBodyWith* family can also be called multiple times within a single handler because it stores a copy of the body in the Context — unlike ShouldBind, which can only read the body once.
If the built-in rules aren't enough, register a custom validator:
v, ok := binding.Validator.Engine().(*validator.Validate)
if ok {
v.RegisterValidation("matauang", func(fl validator.FieldLevel) bool {
return fl.Field().String() == "IDR" || fl.Field().String() == "USD"
})
}
type Payment struct {
Currency string `json:"currency" binding:"required,matauang"`
}The function binding.Validator.Engine() returns the validator instance Gin uses, so the matauang rule is immediately available to every binding:"matauang" tag.
Key takeaways:
json, form, uri, binding.required, email, min, max, oneof.ShouldBind returns an error, MustBind writes 400 automatically.RegisterValidation.ShouldBindBodyWith* can read the body multiple times within one handler.In the next episode, episode 6, we'll dissect middleware — writing your own middleware with c.Next() and c.Abort(), using built-in middleware like Logger, Recovery, BasicAuth, CORS, and gzip, and controlling the order of execution.