Reporting Validation Failures
Three layers reject for three reasons, so one 400 with a sentence in it is the wrong answer to all three.
The requirement, the obvious build, and why it breaks
Every lesson starts where the work starts: someone asked for something, and the first implementation that comes to mind has a problem.
What should a rejected request actually return, and how much can you safely say?
Clients need to know what they got wrong, precisely enough to fix it and to show a message next to the right form field, in the right language.
Return 400 with { "error": "Invalid email" }. It is honest, it is readable, and the client can display it.
The client cannot tell which field it refers to when three fields are wrong, so it shows one banner and the user fixes one thing per round trip.
- The client cannot tell which field it refers to when three fields are wrong, so it shows one banner and the user fixes one thing per round trip.
- Only the first failure is reported, because validation throws on the first bad field. Fixing three mistakes takes three submissions.
- The message is English. The client cannot translate it without string-matching, and someone improving the wording breaks the mobile app that was matching on it.
- Everything is 400: a malformed body, a forbidden state transition, a duplicate email and an expired invite. The client cannot decide whether retrying makes sense (Status Codes From the Server's Side).
- A constraint violation escapes uncaught and returns 500 with
duplicate key value violates unique constraint "users_email_lower_idx"— a schema disclosure in an error body (Not Leaking Your Internals). - On the login form, "no account with that email" versus "wrong password" tells an attacker which emails are registered (Broken Access Control (IDOR / BOLA)).
What is actually happening
- The three validation layers fail for three different reasons, and the response has to distinguish them because the client's correct reaction differs: fix the request, change nothing and tell the user, or retry with different data (The Three Validations).
- The distinction the status code carries: 400 means the request was malformed and could not be understood; 422 means it was understood and semantically unacceptable; 409 means it conflicts with current state; 403 means the caller is not permitted. Collapsing these loses information the client needs.
- A machine-readable code plus a field path is what a client can act on. The human sentence is a developer convenience and must never be the thing a client branches on.
- Collect, do not throw on first. Transport validation can evaluate every field independently, so returning all failures at once is nearly free and removes a round trip per mistake.
- Business and constraint failures are usually singular by nature — the first one that fires is the answer — which is why they carry a different shape from a field-error list.
- The response body is a contract. Adding a field is safe; changing a code or a path is a breaking change for anyone branching on it (Backward Compatibility: The Real Rules).
- How much you may say is a security decision on authentication-adjacent endpoints, where a precise error is an enumeration oracle. Everywhere else, precision is the correct default.
One shape, three sources
The response body below reports a transport failure: several fields, each with a code and a path, all evaluated in one pass. The second reports a constraint violation: one reason, no field list, and a different status. Same envelope, different contents — which is what lets a client write one parser.
The details that matter are small. No value is echoed. The code is a token, not a sentence. The correlationId makes a support conversation into a query. And the status code is not the same for the two cases.
1// 422 Unprocessable Content — parsed, understood, semantically wrong.2// All fields evaluated in one pass, so the user fixes everything in one edit.3{4 "type": "https://errors.example.com/validation",5 "title": "Request validation failed",6 "status": 422,7 "correlationId": "01JBX7K2P9QW3F",8 "errors": [9 { "path": "items.0.quantity", "code": "out_of_range", "message": "Must be between 1 and 999." },10 { "path": "currency", "code": "not_in_enum", "message": "Must be one of EUR, USD, GBP." },11 { "path": "customerId", "code": "required", "message": "This field is required." }12 ]13}14// note: no "value" anywhere. It may be a password, a token or personal data.15 16// 409 Conflict — well-formed, permitted, but inconsistent with stored state.17// One reason, not a field list, because the database decided.18{19 "type": "https://errors.example.com/conflict",20 "title": "Email already registered",21 "status": 409,22 "correlationId": "01JBX7K3B1RM8T",23 "code": "email_taken",24 "field": "email",25 "retryable": false26}27// NOT: 'duplicate key value violates unique constraint "users_org_email_lower_uidx"'retryable is a separate assertion from the status code, and worth stating explicitly: email_taken will never succeed on retry, while concurrently_modified will probably succeed immediately. Retryable is not the same property as idempotent (Retries, Idempotency in Backends).
Which status, and what the client should do
The status code is the only part of the response that infrastructure reads. Proxies, retry policies, circuit breakers, dashboards and alerts all branch on it before anyone parses a body, which is why collapsing everything into 400 has effects far beyond client convenience.
The last column is the useful one: the reason each distinction exists is that the caller's correct next action differs, and a code that does not distinguish forces the caller to guess.
| Situation | Status | Layer that rejected | What the client should do |
|---|---|---|---|
| Body is not valid JSON | 400 | Transport (parse) | Fix the request; do not retry unchanged |
| Field wrong type, missing, out of range | 422 (or 400 by house style) | Transport (schema) | Show errors per field; resubmit corrected |
| Body larger than the limit | 413 | Transport (pre-parse) | Send less; chunk or use an upload URL (Presigned URLs) |
| Not authenticated, or credentials invalid | 401 | Auth | Re-authenticate; do not report which part was wrong |
| Authenticated but not permitted | 403, or 404 to hide existence | Authorization | Nothing — this is not a client bug (Object-Level Authorization) |
| Forbidden state transition | 409 (or 422) | Business | Re-fetch the entity; the state moved |
| Unique constraint violated | 409 | Database | Choose a different value; retrying is pointless |
| Optimistic-concurrency conflict | 409 | Database | Re-read, re-apply, retry — this one *will* likely succeed (Optimistic Concurrency) |
| Rate limit exceeded | 429 | Middleware | Back off per Retry-After (Backoff and Jitter) |
| Unmapped constraint or unexpected error | 500 | Nobody — a gap | Nothing. This is your bug; alert on it (Error Boundaries: Three Translations, Not One) |
What you can safely say
Precision is the right default and it has exceptions. On endpoints where the existence of an account or an object is itself sensitive, a precise error is an enumeration oracle, and the tradeoff has to be made per endpoint rather than as a global policy.
Note that the answer is not always "be vague". A consumer signup form that refuses to say "that email is already registered" produces a genuinely worse product, and the information it protects is usually obtainable from the same endpoint by other means. Decide, write down the reasoning, and be consistent within the endpoint.
Does the precise reason reveal something the caller should not be able to learn?
when Ordinary business endpoints where the caller is already authorized to see the object. The default.
cost None worth worrying about. Vagueness here just slows integrations down.
when Consumer signup, where "email taken" is necessary for a usable form and the same fact is discoverable from password reset anyway.
cost A deliberate enumeration oracle. Mitigate with rate limits and monitoring rather than vagueness (Rate Limiting).
when Login, password reset, magic links. "If that account exists, we sent an email" regardless of what happened.
cost Users cannot tell a typo from a missing account; support load rises. Timing must be uniform too, or the response time is the oracle.
when Objects whose existence is sensitive — another tenant's resources, private records (Tenant Isolation).
cost A legitimately confused caller cannot distinguish "gone" from "not yours", which makes support harder.
when Unexpected server errors, always.
cost The caller has nothing to act on beyond the correlation id — which is exactly why the correlation id must be in the body (Correlation Ids That Survive Every Hop).
How to build it
Most important first.
- Return a stable machine code, a field path, and a human message — in that order of importance. The client branches on the code and displays the message only as a fallback (The Error Model: Structure Over Apology).
- Use RFC 9457 problem details, or a documented shape of your own, and use the same shape on every endpoint. One shape everywhere is worth more than the best possible shape on one endpoint.
- Collect all transport-level field errors in one response; report business and constraint failures as a single reason.
- Map database constraint names to codes and fields in one table, and rethrow anything unmapped so a rule with no handler is loud (Database Constraints).
- Never echo the rejected value. It is regularly a password, a token or personal data (Secrets in Logs).
- Include the correlation id in the body so a user-reported failure is one query away (Correlation Ids That Survive Every Hop).
- Decide the enumeration policy per endpoint, deliberately: signup on a consumer product usually must say "email taken", a login endpoint must not distinguish, and an admin endpoint can be precise.
- Document the codes alongside the endpoint. A code nobody has written down becomes a string the client matched by guessing.
What can go wrong
- A different error shape per endpoint, so every client writes per-endpoint parsing.
- Error codes that change when a message is reworded, because the code is derived from the message.
- Field paths that do not match the request structure —
emailfor a body of{ user: { email } }— so the client cannot map the error to a form field. - A generic handler that turns every unhandled exception into 400, hiding genuine 500s in a client-error metric where nobody alerts on them (An Error Taxonomy That Maps Cause to Response).
- Localised messages on the server, which fixes one client and leaves the API returning content negotiated by a header nobody set correctly.
- A 200 response containing
{ ok: false, errors: [...] }, so proxies, retries, dashboards and alerting all treat a failure as a success (Status Codes From the Server's Side). - Reporting every field of a bulk import in one response, producing a 40 MB error body from a bad file.
- A 409 from a constraint violation is a race that already happened: the pre-check passed and the write lost. That is the correct response and it is worth logging as a race rather than as a client error (Database Constraints).
- A client that retries a 409 immediately will usually get the same answer, so the code should say whether retrying can help —
email_takencannot,concurrently_modifiedcan (Retries).
- Error precision is an information-disclosure decision on any endpoint where the existence of a record is sensitive: login, password reset, invitation and anything that reveals another tenant's data (Broken Access Control (IDOR / BOLA)).
- Never return database, driver or stack information. A constraint name, a table name and a SQL fragment are all schema disclosure (Not Leaking Your Internals).
- Authorization failures should not leak existence: returning 404 rather than 403 for an object the caller may not see is the standard trade, at the cost of a less helpful message (Object-Level Authorization).
- Detailed validation errors can also be an oracle for a filtering control — an attacker probing a WAF or an allowlist learns its shape from precise rejections.
- Log the full detail server-side, keyed by correlation id, and return the minimum. That gives support the information without giving it to everyone (Structured Logging).
- "Validation failures are 400." 400 is for malformed. A well-formed request that violates a business rule is 422, and one that conflicts with state is 409.
- "422 is more correct than 400 for validation." Only for requests that were parsed and understood. A body that is not valid JSON is 400 (Status Codes Clients Can Branch On).
- "Return the exception message." That is how database internals and stack traces reach clients (Not Leaking Your Internals).
- "Hide all detail for security." Vagueness everywhere makes every integration slower and prevents nothing on endpoints where existence is not sensitive.
- "The client can parse the message." The message is prose and will be reworded. The code is the contract.
- "A validation error is not an error, so 200 with a flag is fine." Proxies, retry policies, dashboards and alerts all read the status code (An Error Taxonomy That Maps Cause to Response).
Operating it
- Count by
codeandfield, not by status. One dominant code is a documentation problem you can fix; a flat distribution is normal. - Split 4xx by layer: transport rejections, business rejections, constraint violations. They have different owners and different remedies.
- Alert on 500s that carry a constraint error code — each one is an unmapped rule (Error Boundaries: Three Translations, Not One).
- Watch 4xx rate by client version. A step change after a client release is a contract mismatch (Deploys Are the First Suspect).
- Log the correlation id with every rejection so "the app said something went wrong" becomes a lookup (Correlation Ids That Survive Every Hop).
- Error responses are cheap unless they are large. Bulk endpoints need a cap on how many failures are reported, plus a count of the rest.
- At high volume, high-cardinality error labels — a metric label per field path — are a monitoring cost. Aggregate to the code and keep the path in logs (Cardinality: The Label That Took Down Monitoring).
- At 10x clients, the error contract becomes as load-bearing as the success contract, and changing a code becomes a versioning event (Running Two API Versions in One Service).
- Precise errors help legitimate clients and help attackers enumerate. The balance differs per endpoint and cannot be set globally.
- Stable codes mean you are committed to them. Improving the taxonomy later is a breaking change (Removing Fields Without Removing Consumers).
- Machine codes push translation to the client, which is right for i18n and means the client must ship a string for every code — including codes you add later.
- Collecting all errors requires validation that does not throw on the first failure, which is easy for transport rules and often impossible for business rules that depend on each other.
Where this applies
Backend advice is context-sensitive. These labels say what each claim is specific to, and where a different stack or scale would differ.
- GENERALStable codes, a field path, one shape across endpoints, and no internals in the body — true of any backend and any protocol.
- PROTOCOL-SPECIFICThe status-code vocabulary is HTTP's. gRPC has its own smaller set —
INVALID_ARGUMENTcovers both 400 and 422,ALREADY_EXISTSmaps to 409,FAILED_PRECONDITIONto a state conflict — so a service exposing both needs an explicit mapping and cannot assume a 1:1 correspondence. GraphQL returns 200 with anerrorsarray by convention, which means HTTP-level monitoring sees no failures at all and error rates must be measured from the payload. - SCALE-SPECIFICFlips on number and independence of clients. With one first-party client shipped alongside the API, a message and a status code are genuinely enough — you can change both together. Past a handful of independently deployed clients (a mobile build you cannot force-update, partner integrations), every code becomes a contract you cannot change, and the cost of not having designed one is paid on every future change.
Where the depth lives
This domain teaches the application-side mechanism and hands the rest off.