Gin Basics #3 Request Binding and Validation

5 min read

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.

Struct definition
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.

JSON binding
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})
}
Request
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.

Adding validation rules
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 present
  • email — must be in email format
  • gte=0,lte=130 — between 0 and 130 inclusive

Now any request that violates the rules will fail at the ShouldBindJSON step.

A request that fails validation
curl -X POST http://localhost:8080/users \
  -H "Content-Type: application/json" \
  -d '{"name":"","email":"not-an-email","age":200}'
# 400, validation error message

Here are a few more rules you’ll use often.

TagMeaning
requiredThe value must not be empty
emailEmail format
min=3 / max=20String length or numeric size range
gte=0 / lte=100Greater than or equal / less than or equal
oneof=admin userOne of the listed values
len=10Exact 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 ShouldBindJSON family — only returns the error. You write the response yourself.
  • The BindJSON family — on error, it automatically responds with a 400 and aborts the request.
Comparing the two approaches
// 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.

Query binding
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.

Path binding
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.

Form binding
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.

Per-field error cleanup
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/uri tags
  • Body uses c.ShouldBindJSON, query uses c.ShouldBindQuery, path uses c.ShouldBindUri
  • You must pass a struct pointer (&req) for the values to be filled
  • Declare validation rules with the binding tag — required/email/gte/oneof and so on
  • The ShouldBind family only returns the error; the Bind family auto-responds with 400, but ShouldBind gives you more control
  • Validation errors can be organized per-field with validator.ValidationErrors for the response

In the next post (#4 Responses) we’ll cover response formats beyond JSON, status codes, and how to build consistent error responses.

X