The bouncer at the door
You ship an API endpoint. A client sends JSON. The JSON is missing a field. Your code panics because it tries to access a nil pointer, or worse, it saves an empty string to the database and breaks downstream logic. Validation stops this before it starts. Gin integrates with the go-playground/validator package to let you define rules directly on your structs. You describe what valid data looks like. Gin enforces it.
Think of validation like a bouncer at a club. The bouncer doesn't care about the music inside. The bouncer cares about the rules at the door: ID required, no shoes, must be on the list. If you fail a rule, you don't get in. Gin acts as the bouncer. You write the rules as tags on your struct fields. When a request arrives, Gin checks the tags. If the data passes, the handler runs. If the data fails, Gin returns an error and your handler never sees the bad data.
Declarative validation with tags
Gin uses a declarative approach. You attach validation tags to struct fields using the binding tag key. The tags come from the validator package, which supports a rich set of built-in rules. Common tags include required, min, max, email, url, and oneof. You can combine multiple tags with a comma.
The struct defines the shape of the data and the constraints simultaneously. This keeps validation logic close to the data definition. You don't scatter checks across multiple functions. You look at the struct and you see exactly what the API expects.
Here's the simplest goroutine: spawn one, send a message, close the channel.
Here's the simplest validation: define a struct with tags, bind JSON, check the error.
package main
import (
"github.com/gin-gonic/gin"
)
// LoginRequest defines the expected JSON structure for login.
type LoginRequest struct {
// binding:"required,email" ensures the field exists and matches email format.
Email string `json:"email" binding:"required,email"`
// binding:"required,min=6" requires the password and enforces a minimum length.
Password string `json:"password" binding:"required,min=6"`
}
func main() {
r := gin.Default()
// POST /login binds the JSON body to the struct and validates tags automatically.
r.POST("/login", func(c *gin.Context) {
var req LoginRequest
// ShouldBindJSON returns an error if JSON decoding fails or validation tags reject the data.
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(400, gin.H{"error": err.Error()})
return
}
// Data is valid. Proceed with business logic.
c.JSON(200, gin.H{"status": "ok", "email": req.Email})
})
r.Run()
}
Validation tags are declarative. Keep rules close to the data.
How binding works under the hood
The ShouldBindJSON method does two distinct steps. First, it decodes the JSON payload into the struct using the standard library's json.Unmarshal. If the JSON is malformed, you get a JSON syntax error. Second, it runs the validator against the populated struct. If the JSON is valid but the data violates a tag, you get a validation error.
The error returned by ShouldBindJSON can be different types. If JSON decoding fails, the error is a *json.SyntaxError or *json.UnmarshalTypeError. If validation fails, the error is a *validator.ValidationErrors. This type is a slice of individual field errors. You can inspect it to extract field names and failed tags.
Gin follows a naming convention for binding methods. Methods starting with Should return an error and let you handle it. Methods without Should abort the request immediately with a 400 status. Always prefer ShouldBind variants. They give you control over the response format and allow you to log or transform errors before sending them to the client.
Realistic error handling
Dumping err.Error() to the client works for debugging, but it leaks internal details and produces ugly output. The raw error string looks like Key: 'LoginRequest.Email' Error:Field validation for 'Email' failed on the 'email' tag. Clients need structured errors. You can type-assert the error to validator.ValidationErrors and build a clean response.
Here's how to extract field-level errors and return a map of field names to error messages.
import (
"errors"
"github.com/gin-gonic/gin"
"github.com/go-playground/validator/v10"
)
// handleValidationErrors converts validation errors into a structured map.
func handleValidationErrors(err error) gin.H {
var ve validator.ValidationErrors
// Type assert to check if the error is a validation error slice.
if errors.As(err, &ve) {
fields := make(map[string]string)
for _, e := range ve {
// Map field name to the failed tag for a clean response.
fields[e.Field()] = e.Tag()
}
return gin.H{"errors": fields}
}
// Fallback for non-validation errors like JSON syntax issues.
return gin.H{"error": err.Error()}
}
func loginHandler(c *gin.Context) {
var req LoginRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(400, handleValidationErrors(err))
return
}
c.JSON(200, gin.H{"status": "ok"})
}
Structured errors help clients. Return field names, not raw validator strings.
Custom validators for business logic
Built-in tags cover most cases. Sometimes you need rules that depend on business logic. You can register custom validator functions and use them as tags. The validator package exposes a RegisterValidation method. You provide a tag name and a function that returns a boolean.
Custom validators receive a FieldLevel interface. This interface gives you access to the field value, the parent struct, and the translation engine. You can read other fields from the struct to implement cross-field validation. For example, you might check that Password and ConfirmPassword match.
Here's how to register a custom tag that checks for a digit in a password.
import (
"github.com/gin-gonic/gin"
"github.com/go-playground/validator/v10"
)
var validate *validator.Validate
func init() {
// Initialize the validator instance once at startup.
validate = validator.New()
// Register a custom tag "hasDigit" for password checks.
validate.RegisterValidation("hasDigit", func(fl validator.FieldLevel) bool {
password := fl.Field().String()
for _, r := range password {
if r >= '0' && r <= '9' {
return true
}
}
return false
})
// Tell Gin to use the custom validator instance.
gin.SetValidator(&CustomValidator{validator: validate})
}
// CustomValidator wraps the validator to satisfy Gin's interface.
type CustomValidator struct {
validator *validator.Validate
}
func (cv *CustomValidator) ValidateStruct(obj interface{}) error {
return cv.validator.Struct(obj)
}
Custom validators extend the system. Register them once, use them everywhere.
Pitfalls and compiler errors
Validation errors often stem from subtle mistakes in tags or binding calls. If you forget to add the binding tag key, Gin ignores the tags. The struct field might have required,email, but without binding:"required,email", nothing happens. The compiler won't catch this. You'll get silent failures where invalid data slips through.
If you use BindJSON instead of ShouldBindJSON, you lose control. BindJSON calls c.AbortWithError(400, err). This sends a generic 400 response and stops execution. You can't customize the error body. You can't log the error. You can't retry. Stick to ShouldBindJSON.
Nested structs require care. If you have a struct inside a struct, validation runs recursively. Tags on inner fields work automatically. However, if the inner struct is a pointer and the JSON omits the field, the pointer remains nil. Validation stops at the nil pointer. You need binding:"required" on the outer field to force the inner struct to be present.
The compiler rejects programs with undefined packages. If you forget to import validator, you get undefined: validator. If you import a package and don't use it, Go complains with imported and not used. Keep imports clean.
Gin's binding also respects the omitempty JSON tag. If a field has json:"name,omitempty", the field is optional during decoding. Validation tags still apply if the field is present. If the field is missing, required fails unless you handle the omission logic explicitly.
Don't fight the binding system. Use ShouldBind and structure your errors.
Decision matrix
Use binding tags when you have standard constraints like required fields, length limits, or format checks. Use custom validator functions when business logic requires complex checks that tags can't express, such as cross-field comparison or database lookups. Use UnmarshalJSON when you need to transform data during decoding, not just validate it, such as parsing a custom date format. Use manual validation in the handler when the rules depend on runtime state that isn't available in the request, such as user permissions or inventory levels.
Validation is plumbing. Run it through every request that accepts external data.