ValidationGENERALPROTOCOL-SPECIFICSCALE-SPECIFIC

Reporting Validation Failures

Three layers reject for three reasons, so one 400 with a sentence in it is the wrong answer to all three.

What actually happensHow to build it

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.

The question

What should a rejected request actually return, and how much can you safely say?

The requirement

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.

The obvious build

Return 400 with { "error": "Invalid email" }. It is honest, it is readable, and the client can display it.

Why it breaks

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.

How it breaks in production
  • 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)).
RequirementAPI ContractApplication LogicData AccessExternal DepsConcurrencyFailureSecurityObservabilityDeploymentScale

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.

A field-error response, and a conflict response
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": false
26}
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.

SituationStatusLayer that rejectedWhat the client should do
Body is not valid JSON400Transport (parse)Fix the request; do not retry unchanged
Field wrong type, missing, out of range422 (or 400 by house style)Transport (schema)Show errors per field; resubmit corrected
Body larger than the limit413Transport (pre-parse)Send less; chunk or use an upload URL (Presigned URLs)
Not authenticated, or credentials invalid401AuthRe-authenticate; do not report which part was wrong
Authenticated but not permitted403, or 404 to hide existenceAuthorizationNothing — this is not a client bug (Object-Level Authorization)
Forbidden state transition409 (or 422)BusinessRe-fetch the entity; the state moved
Unique constraint violated409DatabaseChoose a different value; retrying is pointless
Optimistic-concurrency conflict409DatabaseRe-read, re-apply, retry — this one *will* likely succeed (Optimistic Concurrency)
Rate limit exceeded429MiddlewareBack off per Retry-After (Backoff and Jitter)
Unmapped constraint or unexpected error500Nobody — a gapNothing. This is your bug; alert on it (Error Boundaries: Three Translations, Not One)

What you can safely say

GENERALThe tradeoff is protocol- and stack-independent; only the mechanism for expressing it (status code, gRPC status, GraphQL error extension) changes.

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.

How specific should this rejection be?

Does the precise reason reveal something the caller should not be able to learn?

Fully specific

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.

Specific, existence accepted

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).

Deliberately uniform

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.

404 instead of 403

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.

Generic, detail logged only

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

Failure modes
  • 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 — email for 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.
What can race
  • 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_taken cannot, concurrently_modified can (Retries).
Security
  • 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).
Misreads
  • "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

How you see it in production
  • Count by code and field, 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).
What changes at 10x and 100x
  • 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).
What this costs
  • 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_ARGUMENT covers both 400 and 422, ALREADY_EXISTS maps to 409, FAILED_PRECONDITION to a state conflict — so a service exposing both needs an explicit mapping and cannot assume a 1:1 correspondence. GraphQL returns 200 with an errors array 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.