Updated Nov 5, 20265 min read

Request Validation in Go: validator, ozzo-validation and Clean 400 Errors

How to validate incoming JSON in a Go API. Strict decoding, struct tags with go-playground/validator, rule-based validation with ozzo-validation, turning failures into useful 400 responses, and where validation should run so no handler forgets it.

A Go struct with a string field accepts an empty string, and an int field accepts zero, so decoding JSON into a typed struct proves only that the payload had the right shape. It says nothing about whether the email is an email, the quantity is positive or the role is one your system knows. Request validation is where those rules live, and in Go it comes down to three decisions: how strictly to decode, which library expresses the rules, and where the check runs so that no handler skips it.

Step one: decode strictly

encoding/json ignores fields it does not recognize, so a client sending "emial" gets a silent zero value. Rejecting unknown fields and capping the body size catches that class of bug before validation starts:

var req CreateUserRequest dec := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)) dec.DisallowUnknownFields() if err := dec.Decode(&req); err != nil { http.Error(w, "invalid JSON: "+err.Error(), http.StatusBadRequest) return } if err := dec.Decode(&struct{}{}); err != io.EOF { http.Error(w, "invalid JSON: trailing data", http.StatusBadRequest) return }

The trailing-data check needs the io package for io.EOF. DisallowUnknownFields and MaxBytesReader are both in the standard library, documented in the encoding/json package.

Struct tags with go-playground/validator

go-playground/validator is the default choice, with about 20,000 GitHub stars and a v10.30.6 release on November 2, 2026. Gin's binding tags use it underneath. Rules sit in struct tags next to the fields:

type CreateUserRequest struct { Email string `json:"email" validate:"required,email"` Name string `json:"name" validate:"required,min=2,max=100"` Age int `json:"age" validate:"gte=13,lte=130"` Role string `json:"role" validate:"oneof=admin member viewer"` Password string `json:"password" validate:"required,min=12"` } var validate = validator.New(validator.WithRequiredStructEnabled())

Create the validator once and reuse it, since it caches struct metadata. WithRequiredStructEnabled makes required apply to nested struct fields too, which the v10 docs recommend and v11 will make the default.

A raw validation error reads like Key: 'CreateUserRequest.Email' Error:Field validation for 'Email' failed on the 'email' tag, which is not something to send to a client. Unpack it into a field map instead:

if err := validate.Struct(req); err != nil { var verrs validator.ValidationErrors if errors.As(err, &verrs) { fields := map[string]string{} for _, fe := range verrs { fields[strings.ToLower(fe.Field())] = fe.Tag() } w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusBadRequest) json.NewEncoder(w).Encode(map[string]any{"errors": fields}) return } http.Error(w, err.Error(), http.StatusInternalServerError) return }

A request with a valid age and password but a bad email, a one-letter name and an unknown role now returns {"errors":{"email":"email","name":"min","role":"oneof"}}, which a frontend can map to messages next to each input. For translated messages, the library ships a universal-translator integration.

Rules in code with ozzo-validation

Tags get awkward once rules depend on each other, such as "seats is required and at least 2 when the plan is team". ozzo-validation writes rules as Go code, so conditions are ordinary expressions:

func (s Signup) Validate() error { return validation.ValidateStruct(&s, validation.Field(&s.Email, validation.Required, is.Email), validation.Field(&s.Plan, validation.Required, validation.In("starter", "team")), validation.Field(&s.Seats, validation.When(s.Plan == "team", validation.Required, validation.Min(2))), ) }

The returned validation.Errors is a map from field name to error and marshals to JSON directly. ozzo-validation received a v4.5.0 release in October 2026 after a long quiet period. Both examples above compile and run with the current releases of each library.

Where validation should run

The usual bug is not a wrong rule but a handler that never calls the validator. Three patterns prevent it:

  • One decode helper used by every handler, which decodes strictly and validates in one call.
  • Framework binding, such as Gin's ShouldBindJSON, which runs validator tags as part of decoding.
  • A Validate() method on the request type that infrastructure calls automatically, so a handler only receives requests that already passed.

Format and range checks belong at this boundary. Checks that need the database, such as "this email is not taken", belong in the handler or the service layer, where the uniqueness constraint in Postgres is the final word.

Automatic validation with Encore

Encore

Encore is a tool that works alongside your framework for TypeScript and Go, with a platform that provisions and deploys into your own AWS or GCP account, and its Go SDK builds the third pattern in. As described in the validation docs, if a request type has a Validate() error method, Encore calls it after decoding and runs the handler only when it returns nil. A plain error becomes an InvalidArgument error with status 400, and an *errs.Error is passed through unchanged, so any library works inside it.

Set up the app

Get started
brew install encoredev/tap/encore &&
encore app create
Copy

Run in your terminal to get started locally.

~
$ encore app create accounts --example=go/hello-world$ cd accounts && mkdir users
package users import ( "context" "encore.dev/storage/sqldb" "github.com/go-playground/validator/v10" ) var db = sqldb.NewDatabase("users", sqldb.DatabaseConfig{ Migrations: "./migrations", }) var validate = validator.New(validator.WithRequiredStructEnabled()) type CreateParams struct { Email string `json:"email" validate:"required,email"` Name string `json:"name" validate:"required,min=2,max=100"` } // Validate runs before Create is called. func (p *CreateParams) Validate() error { return validate.Struct(p) } type User struct { ID int64 `json:"id"` Email string `json:"email"` } //encore:api public method=POST path=/users func Create(ctx context.Context, p *CreateParams) (*User, error) { u := &User{Email: p.Email} err := db.QueryRow(ctx, `INSERT INTO users (email, name) VALUES ($1, $2) RETURNING id`, p.Email, p.Name).Scan(&u.ID) return u, err }
-- users/migrations/1_create_users.up.sql CREATE TABLE users ( id BIGSERIAL PRIMARY KEY, email TEXT UNIQUE NOT NULL, name TEXT NOT NULL );

encore run starts Postgres locally, and a request with an invalid email returns 400 before Create runs. When an AI coding agent adds endpoints, there is no decode helper to remember: a new request type with a Validate() method is checked automatically, and the agent can confirm the 400 path against the local app. Database setup is covered in the Go database docs, and deploying creates RDS for PostgreSQL or Cloud SQL in your account, as described in the AWS infrastructure docs.

The Infrastructure page in the Encore dashboard with a database's configuration panel open, showing fields for vCPUs, memory, storage, availability type, max connections and backups.
Each provisioned resource can be reviewed and its configuration edited per environment in the Encore dashboard. The screenshot shows a GCP Cloud SQL database.

Best fit: Go teams who want every endpoint validated before the handler runs, using the validation library they already know.

Frequently asked questions

What is the best validation library for Go?

go-playground/validator v10 is the most widely used, with around 20,000 GitHub stars, and it is the library Gin uses for its binding tags. It validates structs with tags such as validate:"required,email". ozzo-validation is the main alternative and expresses rules in Go code, which suits conditional and cross-field rules.

How do I return validation errors as a 400 response in Go?

Call validate.Struct(req), use errors.As to get validator.ValidationErrors, and build a map from each FieldError's Field() to its Tag() or a message. Write it as JSON with http.StatusBadRequest. Decoding errors from encoding/json should also return 400.

How do I reject unknown JSON fields in Go?

Create a json.Decoder and call DisallowUnknownFields() before Decode. Unknown fields then return an error you can turn into a 400 response, which catches typos in client payloads. Wrap the body in http.MaxBytesReader to limit its size.

How does request validation work in an Encore Go app?

Add a Validate() error method to the request type. Encore calls it after decoding the request and only runs the handler if it returns nil. A plain error becomes an InvalidArgument error with HTTP status 400, and you can call go-playground/validator inside Validate().

Deploy to your cloud on AWS and Google Cloud
without managing infrastructure

Encore lets developers and agents define application infrastructure directly in code, then automatically provisions it from local development to production in your AWS or GCP account.

~/orders — claude
Claude Code
Claude Code v2.1.180Opus 5.5~/orders