Validation
How rules in struct tags become a cached plan, what web.H does with the
result, and why validation works the way it does.
flowchart TB
R["Request"] --> B["Bind<br/>(bind plan)"]
B -->|value can't be converted| E1["400 + errors"]
B --> T["Tag rules<br/>(cached validation plan)"]
T -->|a rule fails| E2["422 + errors"]
T --> M["Validate(ctx), if the input has it"]
M -->|validate.Fail or *validate.Errors| E2
M -->|any other error| E3["500, details hidden"]
M --> H["Your handler"]
E1 --> X["Error handler: problem JSON,<br/>or redirect back for a browser form"]
E2 --> X
Rules are compiled once
Rules use Laravel’s syntax: validate:"required|max:200|in:a,b". The first
time a struct type is validated, its tags are parsed into a
validate.Plan and cached for the life of the process. For web.H, that
happens when the route is added, so tag mistakes are startup errors,
not per-request ones: an unknown rule, min:abc, email on an int, a
rule naming a missing field, or a ValidationMessages key that matches
nothing. web.H panics with the message.
Per request, only the plan runs: messages are built only when a rule
fails, and with the built-in rules valid input is checked without
allocating (maps of structs and file rules excepted). validate.Struct
uses the same cache for input that doesn’t come from HTTP, such as job
payloads.
Empty means “not sent”
Only the required family (required, required_if, required_unless,
required_with, required_without) and accepted run on an empty field.
Every other rule, custom rules included, passes it. Empty means a nil
pointer, a blank string, an empty slice or map, or a zero struct or array
such as a zero time.Time. So a field is optional unless you say
required, and needs no nullable marker.
Numbers and booleans are never empty, because zero can be a real
answer; required rejects 0 and false in them. A pointer expresses
“not sent”:
// illustrative
type UpdateProfile struct {
Name string `json:"name" validate:"required|max:100"`
Website string `json:"website" validate:"url:https"` // optional; checked only when sent
Age *int `json:"age" validate:"min:13"` // nil passes, 0 fails
Terms bool `json:"terms" validate:"accepted"`
}The order inside web.H
- Bind. A value that can’t be converted (
page=abc) is a 400 listing each bad field. The rules don’t run. - Tag rules. Failures are collected into a
*validate.Errors, one message per field (the first failing rule wins), and returned as a 422. The handler isn’t called. Validate(ctx), only if the input implementsweb.Validatorand the tags passed, so it can rely on well-formed input. Use it for checks that need the database or several fields.validate.Failor a*validate.Errorsgives the same 422; aweb.HTTPErrorkeeps its status.- Your handler.
Any other error from Validate, or from a custom rule, is a 500
whose text stays out of the response. A failing database check never
reaches a client as a “validation message”.
One error, two responses
*validate.Errors reports status 422 and implements web.FieldErrorer.
web.DefaultErrorHandler turns it into one of two responses:
- API clients get RFC 9457 problem JSON with an
errorsobject keyed by the request key: thejsonname, elseform,query,pathorheader, with dotted keys for nested data (items.0.name). - Browser form posts (a navigation or
Accept: text/html, not GET) on a route with a session are redirected back to the form’s page. The errors, keyed byformname, and the submitted input are flashed;view.Errorsandview.Oldread them on the next page. Fields whose name containspassword,secretortokenaren’t flashed. A 400 from binding with field errors is redirected the same way. htmx requests (except boosted ones) get the 422.
A custom error handler keeps the redirect only if it calls
web.DefaultErrorHandler for these errors.
Custom rules and messages
Tags can only name rules, so custom rules live in a registry filled from
init, like sql.Register:
// illustrative
func init() {
validate.Register("even", "The {label} field must be even.",
func(ctx context.Context, f validate.Field) (bool, error) {
n, ok := f.Value.(int)
return ok && n%2 == 0, nil
})
}A rule gets the request context and the field’s key, label, value
(pointers followed), parameters and parent struct. Registering a
duplicate or built-in name panics, and so does a route that uses a rule
before it is registered (“unknown rule”). The db package registers
unique and exists this way.
Messages are English, in Laravel’s wording. The label comes from the key
(first_name becomes “first name”) or a label tag. A
ValidationMessages() map[string]string method replaces templates by
key.rule or rule; it is read once, when the plan compiles.
What it deliberately doesn’t do
- No
nullable,sometimesorbail. Empty fields already skip non-required rules, and a field stops at its first failure. Using them is a startup error that says so. - No go-playground syntax.
required,emailfails at startup. - No changes to the input. Values aren’t trimmed; a blank string counts as empty but stays as sent.
- No translations yet. Messages are English only.
Coming from Laravel? The input struct is your Form Request, and
view.Errorsandview.Oldare$errorsandold(). Unlike Laravel, a typo in a rule stops the app at startup instead of failing a request.