Gin Basics #3 Request Binding and Validation
In the previous post we covered routing. This post is about binding incoming request data into Go structs, and validating that the values are correct. These are the steps you need to handle POST/PUT requests properly.
- #1 Getting started and your first server
- #2 Routing and handlers
- #3 Request binding and validation ← this post
- #4 Responses — JSON, status codes, errors
- #5 Middleware
- #6 Database integration (GORM)
- #7 Project structure and a mini REST API
What is binding? #
Binding is the act of filling a predefined Go struct with the JSON body or form data sent by the client. Instead of pulling out and converting each field one by one, you receive everything at once into a single struct.
type CreateUserRequest struct {
Name string `json:"name"`
Email string `json:"email"`
Age int `json:"age"`
}The json:"name" tag specifies which JSON key maps to which field. If the JSON key is name, it goes into the Name field.
Binding the JSON body — ShouldBindJSON #
c.ShouldBindJSON reads the request body and fills the struct.
func createUser(c *gin.Context) {
var req CreateUserRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"received": req})
}curl -X POST http://localhost:8080/users \
-H "Content-Type: application/json" \
-d '{"name":"Curtis","email":"a@b.com","age":30}'
# {"received":{"name":"Curtis","email":"a@b.com","age":30}}Notice that we pass a pointer to the struct. Binding has to fill the struct’s values, so we pass the address with &req. If the body format is wrong or the types don’t match, an error comes back, and the code above responds with a 400 and ends processing with return.
Validation — the binding tag #
Binding alone doesn’t check whether “a value was actually provided” or “the format is correct.” Gin has a built-in feature for declaring validation rules through struct tags. You write the rules in the binding tag.
type CreateUserRequest struct {
Name string `json:"name" binding:"required"`
Email string `json:"email" binding:"required,email"`
Age int `json:"age" binding:"gte=0,lte=130"`
}required— a value must be presentemail— must be in email formatgte=0,lte=130— between 0 and 130 inclusive
Now any request that violates the rules will fail at the ShouldBindJSON step.
curl -X POST http://localhost:8080/users \
-H "Content-Type: application/json" \
-d '{"name":"","email":"not-an-email","age":200}'
# 400, validation error messageHere are a few more rules you’ll use often.
| Tag | Meaning |
|---|---|
required | The value must not be empty |
email | Email format |
min=3 / max=20 | String length or numeric size range |
gte=0 / lte=100 | Greater than or equal / less than or equal |
oneof=admin user | One of the listed values |
len=10 | Exact length |
The validation engine is go-playground/validator, and you can find more rules in its documentation.
ShouldBind vs Bind #
Gin’s binding functions come in two families.
- The
ShouldBindJSONfamily — only returns the error. You write the response yourself. - The
BindJSONfamily — on error, it automatically responds with a 400 and aborts the request.
// Handle the error yourself — recommended
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// Automatic 400 — hard to control the response format
if err := c.BindJSON(&req); err != nil {
return // Gin has already sent a 400 response
}To keep your API’s error response format consistent, it’s better to use the ShouldBind family. This series uses ShouldBind by default.
Query strings and paths bind too #
Not just the body — query strings and path parameters can also be received into structs. This keeps handlers clean when there are many parameters.
type ListQuery struct {
Page int `form:"page,default=1" binding:"gte=1"`
Size int `form:"size,default=20" binding:"gte=1,lte=100"`
Sort string `form:"sort"`
}
func listUsers(c *gin.Context) {
var q ListQuery
if err := c.ShouldBindQuery(&q); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"query": q})
}For queries you specify the key with the form tag, and you can provide a default with default=. For path parameters, use the uri tag and c.ShouldBindUri.
type UserURI struct {
ID int `uri:"id" binding:"required"`
}
func getUser(c *gin.Context) {
var u UserURI
if err := c.ShouldBindUri(&u); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"userID": u.ID})
}Binding form data #
application/x-www-form-urlencoded and multipart/form-data forms are received the same way. Use the form tag and call c.ShouldBind, and Gin looks at the Content-Type and handles it appropriately.
type LoginForm struct {
Username string `form:"username" binding:"required"`
Password string `form:"password" binding:"required"`
}
func login(c *gin.Context) {
var form LoginForm
if err := c.ShouldBind(&form); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"user": form.Username})
}Cleaning up validation errors before responding #
If you send err.Error() as-is, the message is verbose and hard for users to read. Returning a tidy summary of which field failed and why makes it easier for the client to handle.
import "github.com/go-playground/validator/v10"
if err := c.ShouldBindJSON(&req); err != nil {
var ve validator.ValidationErrors
if errors.As(err, &ve) {
out := make(map[string]string, len(ve))
for _, fe := range ve {
out[fe.Field()] = fe.Tag() // e.g. "Email": "email"
}
c.JSON(http.StatusBadRequest, gin.H{"errors": out})
return
}
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"})
return
}Refining error messages even further is something we’ll continue in the error-handling post of the intermediate series.
Wrapping up #
What this post covered:
- Binding fills request data into a Go struct, mapped with
json/form/uritags - Body uses
c.ShouldBindJSON, query usesc.ShouldBindQuery, path usesc.ShouldBindUri - You must pass a struct pointer (
&req) for the values to be filled - Declare validation rules with the
bindingtag —required/email/gte/oneofand so on - The
ShouldBindfamily only returns the error; theBindfamily auto-responds with 400, butShouldBindgives you more control - Validation errors can be organized per-field with
validator.ValidationErrorsfor the response
In the next post (#4 Responses) we’ll cover response formats beyond JSON, status codes, and how to build consistent error responses.