Core

Validation

Reject bad input with validate tags before your handler runs.

After binding, ezz runs the validate tags on your request struct. If anything fails, the request stops with a 400 and your handler is never called.

type CreateUserRequest struct {
    Name    string `json:"name" validate:"required,min=2,max=100"`
    Email   string `json:"email" validate:"required,email"`
    Website string `json:"website" validate:"omitempty,url"`
    Age     int    `json:"age" validate:"min=13,max=130"`
    Code    string `json:"code" validate:"len=6"`
}

Available rules

RuleApplies toMeaning
requiredanyValue must not be the zero value
min=nstring, slice, mapLength is at least n
min=nnumbersValue is at least n
max=nstring, slice, mapLength is at most n
max=nnumbersValue is at most n
len=nstring, slice, mapLength is exactly n
emailstringMatches an email pattern
urlstringStarts with http:// or https://
omitemptyanySkip every other rule on this field when the value is the zero value

Rules are comma-separated and evaluated in order. Every failing rule on every field is collected, so one response can report several problems at once.

Rule names outside this table are ignored silently. A tag like validate:"uuid" or validate:"gte=5" will not fail the request, and it will not warn you. Stick to the supported set and do anything else in the handler.

Make a field optional

required and the other rules treat the zero value differently, which trips people up. An email rule on an empty string fails, because the empty string is not a valid email. When a field is genuinely optional, add omitempty first:

type UpdateProfileRequest struct {
    // optional, but must be a valid email when supplied
    Email string `json:"email" validate:"omitempty,email"`

    // optional, but between 2 and 50 characters when supplied
    Nickname string `json:"nickname" validate:"omitempty,min=2,max=50"`
}

omitempty is a marker rather than a check. When the field holds its zero value, the remaining rules on that field are skipped entirely.

Validate nested structs

Nested structs and non-nil pointers to structs are walked recursively, whether or not the parent field carries a validate tag. Error field names are prefixed with the path:

type Address struct {
    City string `json:"city" validate:"required"`
    Zip  string `json:"zip" validate:"len=5"`
}

type CreateOrderRequest struct {
    Ship Address  `json:"ship"`
    Bill *Address `json:"bill"`
}

A missing city on the shipping address reports as field 'Ship.City' is required. A nil Bill pointer is skipped, so mark it required if the caller must supply it.

Read the error response

Failures are returned as a single 400 with the messages joined by ; :

{
  "error": true,
  "message": "field 'Name' is required; field 'Email' must be a valid email",
  "status": 400
}

Field names in messages are the Go field names, not the JSON names.

If you need a different error shape, validate manually in the handler and return your own error. binding.Validate gives you the structured form:

import "github.com/sulv-io/ezz/binding"

app.POST("/users", ezz.H(func(ctx *ezz.Context, req CreateUserRequest) (any, error) {
    if err := binding.Validate(req); err != nil {
        var verrs binding.ValidationErrors
        if errors.As(err, &verrs) {
            fields := make(map[string]string, len(verrs))
            for _, e := range verrs {
                fields[e.Field] = e.Message
            }
            return nil, ctx.JSON(http.StatusUnprocessableEntity, map[string]any{
                "error":  true,
                "fields": fields,
            })
        }
    }
    return store.Create(req), nil
}))

Each binding.ValidationError carries Field, Tag, Value, and Message.

Add rules the tags cannot express

Business rules belong in the handler, where you have your dependencies:

app.POST("/users", ezz.H(func(ctx *ezz.Context, req CreateUserRequest) (*User, error) {
    if store.EmailTaken(ctx, req.Email) {
        return nil, ezz.Conflict("email already registered")
    }
    if !passwordStrongEnough(req.Password) {
        return nil, ezz.UnprocessableEntity("password is too weak")
    }
    return store.Create(ctx, req)
}))

See the rules in your documentation

The OpenAPI generator reads the same tags, so constraints appear in the spec without repeating yourself:

TagSchema output
min / max on a string or arrayminLength / maxLength
min / max on a numberminimum / maximum
lenminLength and maxLength set to the same value
emailformat: email
urlformat: uri
required on a query or header fieldMarks the parameter required

Next steps

  • Errors for the status codes and error bodies ezz produces.
  • OpenAPI for the rest of the generated schema.
Copyright © 2026