Errorsvalidationfield errors422schemaboundary

Validation Errors: Feedback, Not Verdicts

Reject invalid input at the boundary, and say exactly which field failed which rule — all of them, in one pass. A validation error is a collaboration with the caller; "bad request" is a verdict nobody can act on.

Follow the failure

Frame the contract

API design starts with a consumer, a design question and a guarantee — never with a URL.

Design question
When a request fails validation, does the response let the caller fix every problem in one round trip — mechanically?
Consumers
A web form that must highlight the exact fields a user got wrong; a partner's integration test suite mapping failures to their bug tracker; a data-import pipeline deciding which of 10,000 rows need human attention; an LLM agent repairing its own tool-call arguments.
The promise
A well-designed validation contract guarantees that every invalid request is rejected before any side effect, with a machine-readable list of every failing field, the rule it broke, and enough context to fix it without re-reading the docs.
RequirementConsumersResource ModelStyleContractValidationAuthorizationErrorsIdempotencyPaginationVersioningObservabilityEvolutionTrade-offs

Validate at the boundary, report in full

Validation is the contract's enforcement point: the schema said email is required and role is one of three values, and the boundary is where the promise is checked — before authentication state changes, before the database is touched, before any side effect. A request that fails validation must have changed *nothing*, which is what makes "fix and resubmit" a safe instruction. Validation that happens deep inside the handler, after a row was inserted, converts a client typo into a data-cleanup task.

Reporting in full is the half that teams skip. A validator that stops at the first failure turns a three-mistake request into three round trips: fix the email, resubmit, learn the role is invalid, resubmit, learn the name is too long. For a human on a form this is irritation; for a batch integration it is a debugging session; for a partner's nightly job it is three nights. Collect every field failure in one pass and return them as a list — the marginal server cost is microseconds, and the caller's cost drops by the number of mistakes.

The one honest exception: fail fast on *structural* problems. If the body is not valid JSON, or is 40MB against a 1MB limit (see Large Requests and Documented Limits), field-level validation is meaningless — reject with a body-level error and skip the field pass. The contract distinguishes "I could not read your request" from "I read it, and here is everything wrong with it".

A verdict: the caller learns one bit per round trip
1POST /users
2{ "email": "not-an-email", "role": "superadmin",
3 "name": "<281 chars…>" }
4
5400 Bad Request
6{ "error": "Validation failed" }
7
8# which field? which rule? are the other
9# fields fine? resubmit and find out —
10# three mistakes = three round trips
Feedback: every failure, named and located, in one pass
1POST /users
2{ "email": "not-an-email", "role": "superadmin",
3 "name": "<281 chars…>" }
4
5422 Unprocessable Content
6{ "error": {
7 "code": "validation_failed",
8 "message": "3 fields failed validation.",
9 "request_id": "req_01J9…",
10 "details": { "fields": [
11 { "path": "email", "rule": "format",
12 "message": "Must be a valid email address." },
13 { "path": "role", "rule": "enum",
14 "message": "Must be one of: admin, member, viewer.",
15 "allowed": ["admin", "member", "viewer"] },
16 { "path": "name", "rule": "max_length",
17 "message": "Must be at most 280 characters.",
18 "max": 280 }
19 ] } } }

The good side is a machine-usable diff between the request and the contract: path locates, rule classifies (stable enough to branch on), message explains, and rule parameters (allowed, max) let the caller fix without consulting docs. One round trip instead of three, and a form can highlight all three fields at once.

The field-error schema is itself a contract

The fields array needs the same design care as any response model, because clients build UI and automation on it. path should address nested and repeated structures unambiguously — items[2].quantity, not "the quantity field" — or batch callers cannot map failures back to their inputs. rule should come from a small documented vocabulary (required, format, enum, max_length, range, unique…) so a client can translate rules to localized messages; your message is a fallback, not the UX (see The Error Model: Structure Over Apology on why clients must not parse it).

Decide the semantics of absence deliberately. "Field missing" and "field explicitly null" are different caller mistakes with different fixes, and your rule vocabulary should distinguish them the same way your request contract does (see Request Contracts: Required, Optional, Null and Absent). And decide where cross-field rules land: "end_date before start_date" belongs to a designed location — the first offending path, or a documented body-level path: "" entry — not to whichever field the validator happened to visit last.

One boundary case deserves policy, not improvisation: unknown fields. Silently ignoring them is friendly but hides client bugs forever — the caller sending pssword gets a 2xx and a user with no password. Rejecting them is strict but makes additive client-side evolution awkward. A documented middle exists: reject in sandbox/test mode, warn-and-ignore in production. Any of the three is defensible; the contract must say which one you chose.

  • path uses full addressing (items[2].quantity) so batch and nested failures map back to inputs mechanically.
  • rule is a small, documented, stable vocabulary — the field clients branch and localize on.
  • Rule parameters (allowed, max, min) ship with the error, so the fix needs no docs lookup.
  • Missing vs explicit-null vs unknown-field each get a deliberate, documented answer.
  • Cross-field failures land in a designed location, not wherever the validator stopped.

One schema, two audiences: correctness and safety

The same boundary that produces friendly field errors is a security control, and the two jobs share one schema. Allowlist validation — known fields, typed values, bounded lengths, enumerated choices — is what keeps a 2GB string, a negative quantity, or a role: "superadmin" privilege probe from reaching your handler logic. The security domain calls this input validation at a trust boundary; the API domain calls it enforcing the request contract. Same code, run before anything else touches the payload.

The audiences pull in one interesting opposite direction: helpfulness. allowed: ["admin", "member", "viewer"] is great UX and also enumerates your role model to anyone probing. For public signup forms that trade is almost always fine; for fields that gate privilege or reveal internal topology, return the failure without the enumeration. The rule of thumb: be maximally helpful about *format* (the caller already knows what they sent) and deliberately quiet about *what else exists*. Validation messages must also never echo secrets back — a failed password or API-key field is reported by path and rule, never by value, because error responses end up in logs on both sides.

Key points

  • Validate at the boundary, before any side effect — "fix and resubmit" is only safe advice if the failed request changed nothing.
  • Report every field failure in one pass; first-failure-only validation costs the caller one round trip per mistake.
  • The field-error schema (path, rule, message, parameters) is a contract surface clients build UIs and automation on — design and freeze it like one.
  • Distinguish structural rejection ("could not read the request") from field validation ("read it; here is everything wrong").
  • Unknown-field handling (ignore / warn / reject) is a policy decision that hides or surfaces client bugs — document which you chose.
  • The validation boundary is also the security boundary: allowlists and bounds enforcement, run before handler logic, with helpfulness tempered for privilege-revealing fields.

Follow the failure

How the contract fails or gets misused, hop by hop — and what it costs when it completes.

  1. 1
    Team → API: validates ad hoc inside handlers, returning 400 {"error": "Validation failed"} from whichever check fires first.
  2. 2
    Form team → API: cannot map failures to fields; builds a client-side duplicate of the validation rules from the docs.
  3. 3
    Contract → drift: the server tightens name to 280 chars; the client-side copy still allows 500; users get unfixable "Validation failed" on submit.
  4. 4
    Batch consumer → API: submits 10,000 rows, gets one opaque error per bad row, and files a support ticket asking which rules exist.
  5. 5
    Team → support: answers rule questions by reading source code — the validation contract exists only as implementation.
What breaks
  • Every serious client rebuilds validation client-side from prose docs, and the copies drift — producing errors users cannot fix.
  • Multi-error requests cost one round trip per mistake; batch and partner integrations feel it as hours, not milliseconds.
  • Handler-deep validation leaks partial side effects: a typo'd request creates half an entity, and cleanup becomes the provider's job.

Design, observe, evolve

A contract decision is incomplete until you know how you would notice it failing and how it changes later.

Design the contract
  • • Declare request schemas (types, bounds, enums, required/optional) and generate both the boundary validator and the docs from the same source — one truth, no drift (see [[schema-first-vs-code-first]]).
  • • Return all field failures in one pass as `{path, rule, message, params}` entries under a stable `validation_failed` code, with full path addressing for nested and batch shapes.
  • • Run validation before side effects, allowlist-first; temper helpfulness (no enumeration) on fields that reveal privilege or topology, and never echo secret values.
  • • Document the rule vocabulary and the unknown-field policy as contract clauses, and treat additions to the vocabulary as additive contract changes.
Observe in production
  • • Track validation-failure rate by `path` and `rule` per endpoint: one field dominating means a docs or SDK bug, a step change after a client release means their regression shipped.
  • • Alert on validation failures from your own first-party clients — the form should be catching these locally; a spike means the client and server schemas have drifted.
  • • Sample rejected payloads (metadata, sizes and paths — never values) to spot probing patterns: repeated `role`/`is_admin` failures from one key is reconnaissance, not typos.
Evolve without breaking
  • • Loosening a rule (raising a max, adding an enum value) is additive and safe; tightening one rejects requests that used to succeed and needs a deprecation window with telemetry on who would break (see [[consumer-driven-evolution]]).
  • • New `rule` values are additive if clients were told to fall back on `message` display for unknown rules.
  • • A generated-from-schema validator makes rule changes reviewable diffs in one place instead of archaeology across handlers.
What it costs
  • • Collect-all validation is more machinery than fail-fast, and interacts with ordering: some checks (existence, uniqueness) cost a lookup each, so full-pass validation of a 10,000-row batch has a real price — bound it.
  • • A stable rule vocabulary is a commitment; renaming `max_length` later breaks every client that branched on it.
  • • Schema-generated validation constrains expressiveness — genuinely dynamic rules ("valid if the plan allows it") still need hand-written checks, now in a second place.

Misconceptions

Claim
“The client validates the form, so server-side field errors are redundant.”
Reality
Client validation is UX; server validation is the contract. Clients drift, get bypassed (curl, partners, attackers), and cannot check server-side facts like uniqueness. The server copy is the real one — and when it fires, it needs to be actionable, because by definition the client-side copy just failed.
Claim
“Returning which fields failed and why helps attackers.”
Reality
Format rules are not secrets — the attacker can read your docs. What helps attackers is *unbounded* input reaching deep logic, and *enumeration* of privileged values. Validate strictly, report format failures fully, and stay quiet only where the answer reveals what exists rather than what was sent.
Claim
“400 vs 422 is the important validation design decision.”
Reality
Pick one, document it, move on — clients branch on your code and fields, not on the status nuance. The decisions that matter are full-pass reporting, path addressing, the rule vocabulary, and validating before side effects.

Apply it