Validation with go-playground/validator
encoding/json maps bytes to Go types; it does not enforce business rules.
github.com/go-playground/validator/v10 (verify version at build) reads validate struct tags and returns field-level errors suitable for HTTP 400 responses.
Summary
Decode JSON first, then call validator.Struct on the populated struct.
Tags express common constraints (required, email, gte, oneof) and custom validators extend the engine for domain-specific rules.
Validation stays separate from serialization so the same struct can round-trip to JSON without embedding policy in wire format.
Recipe
Quick-reference recipe card - copy-paste ready.
import "github.com/go-playground/validator/v10"
var validate = validator.New()
type CreateOrder struct {
SKU string `json:"sku" validate:"required,alphanum,min=3,max=32"`
Qty int `json:"qty" validate:"required,gte=1,lte=1000"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil { /* 400 */ }
if err := validate.Struct(req); err != nil {
verrs := err.(validator.ValidationErrors)
// map to JSON problem details
}When to reach for this:
- HTTP handlers need consistent input validation after JSON bind.
- You want declarative rules beside
jsontags for reviewability. - Multiple transports (JSON, forms) share one struct.
Working Example
package main
import (
"encoding/json"
"fmt"
"strings"
"github.com/go-playground/validator/v10"
)
type Signup struct {
Email string `json:"email" validate:"required,email"`
Age int `json:"age" validate:"gte=13,lte=120"`
Country string `json:"country" validate:"required,len=2,alpha"`
Referral string `json:"referral" validate:"omitempty,alphanum"`
}
func formatErrors(err error) string {
var b strings.Builder
for _, fe := range err.(validator.ValidationErrors) {
fmt.Fprintf(&b, "%s failed %s; ", fe.Field(), fe.Tag())
}
return strings.TrimSuffix(b.String(), "; ")
}
func main() {
validate := validator.New()
raw := []byte(`{"email":"not-an-email","age":10,"country":"USA"}`)
var s Signup
_ = json.Unmarshal(raw, &s)
if err := validate.Struct(s); err != nil {
fmt.Println(formatErrors(err))
}
good := Signup{Email: "ada@example.com", Age: 30, Country: "US"}
fmt.Println(validate.Struct(good))
}What this demonstrates:
- Validation runs after JSON decode, not during.
validator.ValidationErrorsexposes field, tag, and param metadata.omitemptyskips rules when the field is zero.- Multiple tag rules chain with commas.
Deep Dive
Common Tags
| Tag | Meaning | Example |
|---|---|---|
required | Non-zero value | IDs, emails |
omitempty | Skip other rules if zero | Optional referral |
email | Email format | User signup |
gte / lte | Numeric bounds | Quantities |
len | String/array length | Country codes |
oneof | Enum strings | oneof=red green blue |
dive | Validate slice elements | validate:"dive,email" |
Full tag list is in the upstream docs (verify version at build).
Custom Validators
validate := validator.New()
_ = validate.RegisterValidation("sku", func(fl validator.FieldLevel) bool {
s := fl.Field().String()
return strings.HasPrefix(s, "SKU-")
})Use custom tags for domain rules that are awkward as regex.
Keep validators pure and fast; they run per request.
HTTP Handler Flow
- Limit body size with
http.MaxBytesReader. json.Decoderwith optionalDisallowUnknownFields.validate.Struct.- Map errors to stable API codes (do not leak internal field paths in public APIs if policy requires friendly names).
gin offers binding tags that wrap validator; echo has similar binders.
You can still use validator directly for non-framework code.
Translate Errors
validator.ValidationErrors can feed en_translations or custom translators for end-user messages.
Log technical detail server-side; return safe messages client-side.
Go Notes
// validate:"-" skips a field entirely (server-set timestamps)
// Use pointers so required can distinguish missing vs zero for numbersPair with Struct Tags for JSON, DB & Validation for tag alignment.
Gotchas
- Expecting validate on Marshal - Tags are inert until
Structruns. Fix: validate explicitly in handlers. - required on int zero -
0failsrequiredfor ints. Fix: use pointers for optional numbers or droprequiredwhen0is valid. - email is not perfect - Tag checks format, not deliverability. Fix: add confirmation flows for real accounts.
- Nested structs - Must call
Structon root; inner structs needvalidatetags on nested fields. Fix: usedivefor slices of nested DTOs. - Race on validator.New per request - Creating engine each request is slow. Fix: reuse a package-level
validateinstance; register custom validators ininit. - Different rules per endpoint - Same struct used for create vs patch may need separate DTOs. Fix: split types instead of overloaded tags.
Alternatives
| Alternative | Use When | Don't Use When |
|---|---|---|
| go-playground/validator | Tag-driven HTTP APIs | Complex graph rules |
| Hand-written checks | Tiny handlers | Large forms |
| OpenAPI / JSON Schema | Contract-first CI | Runtime-only validation |
| CEL or policy engines | Authorization rules | Simple field formats |
| Protobuf constraints | gRPC with protovalidate | Plain REST only |
FAQs
Does validator run before or after JSON?
After - unmarshaling must populate the struct first.
Can I validate query params?
Yes - bind query to a struct and call Struct.
Framework binders often share the same tag engine.
How do I validate PATCH bodies?
Use separate partial structs or pointers so required does not fire on absent fields.
What about cross-field rules?
Use struct-level validation with RegisterStructValidation.
Keep cross-field logic readable and tested.
Is validator safe for concurrency?
A configured Validate instance is safe for concurrent Struct calls.
Register custom validators before serving traffic.
How does this relate to SQL constraints?
Validate at the edge; keep DB constraints as last resort.
Duplicate rules are acceptable for defense in depth.
Can I use validator without JSON?
Yes - any populated struct qualifies.
Tags are transport-agnostic.
What errors should HTTP return?
400 with machine-readable field errors for public APIs.
Log full detail server-side.
Does golangci-lint help?
It does not run validator.
Use unit tests per DTO and optional linters for tag naming alignment.
How do I test validators?
Table-test valid and invalid JSON fixtures per endpoint.
Assert error tags, not only error presence.
Related
- Struct Tags for JSON, DB & Validation - validate tags beside json/db
- Serialization Basics - decode before validate
- Schema Evolution & Unknown Field Handling - optional vs required fields
- encoding/json Custom Marshaling - custom types with validation
- Serialization Best Practices - validation in handler checklist
Stack versions: This page was written for Go 1.26.x (Green Tea GC default, go fix modernizers - verify patch at build), chi (latest - verify at build), gin (latest - verify at build), echo (latest - verify at build), google.golang.org/grpc (latest - verify at build), sigs.k8s.io/controller-runtime (latest - verify at build), kubebuilder (latest - verify at build), tinygo (latest - verify board targets at build), wazero (latest - verify at build), and golangci-lint (latest - verify linter set at build).