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.
Frame the contract
API design starts with a consumer, a design question and a guarantee — never with a URL.
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".
1POST /users2{ "email": "not-an-email", "role": "superadmin",3 "name": "<281 chars…>" }4 5→ 400 Bad Request6{ "error": "Validation failed" }7 8# which field? which rule? are the other9# fields fine? resubmit and find out —10# three mistakes = three round trips1POST /users2{ "email": "not-an-email", "role": "superadmin",3 "name": "<281 chars…>" }4 5→ 422 Unprocessable Content6{ "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.
pathuses full addressing (items[2].quantity) so batch and nested failures map back to inputs mechanically.ruleis 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.
- 1Team → API: validates ad hoc inside handlers, returning
400 {"error": "Validation failed"}from whichever check fires first. - 2Form team → API: cannot map failures to fields; builds a client-side duplicate of the validation rules from the docs.
- 3Contract → drift: the server tightens
nameto 280 chars; the client-side copy still allows 500; users get unfixable "Validation failed" on submit. - 4Batch consumer → API: submits 10,000 rows, gets one opaque error per bad row, and files a support ticket asking which rules exist.
- 5Team → support: answers rule questions by reading source code — the validation contract exists only as implementation.
- 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.
- • 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.
- • 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.
- • 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.
- • 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
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.