Choosing Between 400 and 422 for Validation Errors
This decision guide belongs to Validation Error Payloads, part of the Error Contracts & Resilience Mapping reference. The argument recurs in every API review, usually without either side citing the specification. This page settles it with the actual clauses, then gives a rule you can lint for.
The decision trigger
| Situation | Status code |
|---|---|
| Body is not valid JSON | 400 |
Content-Type is text/plain on a JSON endpoint |
415, not 400 |
| Required query parameter absent | 400 |
| Path parameter is not a UUID as declared | 400 |
| Body parses; a required property is missing | 422 |
quantity: -1 where the minimum is 1 |
422 |
ends_at earlier than starts_at |
422 |
Referenced sku does not exist |
422 (or 409 if it is a state conflict) |
| Request valid but the resource is locked | 409 |
What the specifications actually say
RFC 9110 §15.5.1 — 400 Bad Request
"the server cannot or will not process the request due to something
that is perceived to be a client error (e.g., malformed request syntax,
invalid request message framing, or deceptive request routing)"
RFC 9110 §15.5.21 — 422 Unprocessable Content
"the server understands the content type of the request content …,
and the syntax of the request content is correct, but it was unable to
process the contained instructions"
Read literally, 400 is about syntax and framing and 422 is about instructions the server understood but cannot carry out. A missing required property is not a syntax error — the JSON was perfectly well-formed — so it falls under §15.5.21. That is the whole argument, and it is unusually clear-cut for an HTTP semantics debate.
Spec snippet: documenting both
Both codes appear on the same operation, with different bodies, because they carry different information:
# openapi.yaml (OpenAPI 3.1.0)
paths:
/orders:
post:
operationId: createOrder
requestBody:
required: true
content:
application/json:
schema: { $ref: "#/components/schemas/OrderCreate" }
responses:
"201":
description: Order created.
"400":
description: >
The request could not be parsed or framed — invalid JSON,
a bad path parameter, or a missing required query parameter.
No field-level detail is available.
content:
application/problem+json:
schema: { $ref: "#/components/schemas/Problem" }
example:
type: "urn:api:errors:v1:malformed-request"
title: "Malformed request"
status: 400
detail: "Request body is not valid JSON (line 3, column 14)."
"422":
$ref: "#/components/responses/ValidationFailed"
"409":
description: The request is valid but conflicts with the resource state.
content:
application/problem+json:
schema: { $ref: "#/components/schemas/Problem" }
Note that the 400 example still carries a parser location rather than a field pointer. That is the honest distinction: at 400 time there is no object graph to point into, only a byte offset.
Step-by-step: applying the rule
Step 1 — Put the boundary in one place
// error-boundary.ts — a single translation from failure kind to status
export type FailureKind =
| { kind: "unsupported_media" } // 415
| { kind: "unparseable"; at?: { line: number; column: number } } // 400
| { kind: "missing_parameter"; name: string } // 400
| { kind: "rule_violation"; violations: Violation[] } // 422
| { kind: "state_conflict"; reason: string }; // 409
export function statusFor(f: FailureKind): number {
switch (f.kind) {
case "unsupported_media": return 415;
case "unparseable": return 400;
case "missing_parameter": return 400;
case "rule_violation": return 422;
case "state_conflict": return 409;
}
}
A single exhaustive mapping is what keeps the convention consistent as the codebase grows; scattering res.status(400) calls across handlers guarantees drift.
Step 2 — Fix the framework’s default
Most frameworks emit 400 for schema failures out of the box. Override once, centrally:
# main.py — FastAPI defaults schema failures to 422 already; make the body ours
from fastapi import FastAPI, Request
from fastapi.exceptions import RequestValidationError
from fastapi.responses import JSONResponse
app = FastAPI()
@app.exception_handler(RequestValidationError)
async def validation_handler(request: Request, exc: RequestValidationError):
violations = [
{
"pointer": "/" + "/".join(str(p) for p in err["loc"][1:]),
"code": err["type"].split(".")[-1],
"message": err["msg"],
}
for err in exc.errors()
]
return JSONResponse(
status_code=422,
media_type="application/problem+json",
content={
"type": "urn:api:errors:v1:validation-failed",
"title": "Request validation failed",
"status": 422,
"detail": f"{len(violations)} field(s) failed validation.",
"instance": str(request.url.path),
"violations": violations,
},
)
Step 3 — Lint the convention
# .spectral.yaml
rules:
validation-uses-422:
description: Operations with a request body must document 422, not only 400.
severity: warn
given: "$.paths[*][?(@.requestBody)]"
then:
field: "responses.422"
function: truthy
four-hundred-has-no-violations:
description: A 400 must not promise field-level violations.
severity: error
given: "$.paths[*][*].responses['400'].content['application/problem+json'].schema"
then:
field: "properties.violations"
function: falsy
Standard compliance
| Code | Clause | Meaning | Retry semantics |
|---|---|---|---|
400 |
RFC 9110 §15.5.1 | Malformed syntax or framing | Not without changing the request |
415 |
RFC 9110 §15.5.16 | Unsupported media type | Not without changing Content-Type |
422 |
RFC 9110 §15.5.21 | Understood, cannot process content | Not without changing the values |
409 |
RFC 9110 §15.5.10 | Conflict with current resource state | Possibly, after refetching state |
428 |
RFC 6585 §3 | Precondition required | After adding the precondition |
All four are non-retryable in the sense that repeating the identical request will fail identically — which places them squarely on the non-retryable side of the classification in Retryable vs Non-Retryable Errors. The exception is 409, where the state may change underneath and a refetch-then-retry loop is legitimate.
The practical consequence of the split is how much detail each response can carry:
Proxy and client behaviour
Two practical worries come up whenever 422 is proposed, and both are answerable.
Do clients handle it? RFC 9110 §15 requires a client that receives an unrecognised status code to treat it as the x00 of its class, so any conforming client treats an unknown 422 as 400. That is the correct degradation: the request failed and must not be repeated unchanged.
Do proxies pass it through? Ordinary reverse proxies forward 4xx responses untouched. The risk is not the code but your own gateway: some API gateways normalise or rewrite non-allow-listed status codes, and some WAFs replace error bodies wholesale. Verify with one request through the full production path before adopting.
SDK / codegen downstream effect
// Generated client, createOrder()
- } catch (e) {
- if (e.status === 400) {
- // Is this bad JSON or a bad field? The code alone cannot say.
- }
+ } catch (e) {
+ if (e instanceof BadRequestError) showToast("The request could not be read.");
+ if (e instanceof ValidationFailedError) bindFieldErrors(e.problem.violations);
+ }
Splitting the codes gives the generated client two distinct error classes, which lets the UI distinguish “our request builder has a bug” from “the user typed something wrong” — a distinction that is invisible when everything is 400.
Common mistakes
| Mistake | Correct approach |
|---|---|
400 for every client-side failure |
400 malformed, 422 rule violation, 409 state conflict |
422 for unparseable JSON |
Use 400; there are no fields to point at |
400 for an unsupported content type |
Use 415 — it names the actual problem |
| Mixing conventions across services | One estate-wide rule, enforced by lint |
Returning 200 with an errors array |
Use the status code; intermediaries and clients rely on it |
| Treating a missing required property as syntax | The JSON parsed — it is a rule violation, so 422 |
FAQ
Is 422 a valid status code for a plain REST API?
Yes. 422 Unprocessable Content is defined in RFC 9110 §15.5.21 as part of core HTTP semantics. It first appeared in WebDAV (RFC 4918) which gave rise to a persistent myth that it is WebDAV-specific, but that has not been true since HTTP semantics were consolidated: the code now sits alongside 400, 409 and 415 in the general specification, and mainstream frameworks — FastAPI, Rails, Laravel, Spring — emit it for validation failures by default.
Will intermediaries or clients mishandle a 422?
A conforming client that does not recognise 422 must treat it as 400, per RFC 9110 §15, which is precisely the degradation you want: the caller still learns the request failed and must not be repeated unchanged. Ordinary reverse proxies forward 4xx responses untouched. The one thing worth verifying is your own edge: some API gateways normalise unknown status codes to 400 or 500, and some WAF configurations replace error bodies with a generic page. Send one deliberately invalid request through the full production path and confirm the code and body arrive intact.
Should a missing required field be 400 or 422?
422. The body was well-formed JSON, so nothing about the request’s syntax or framing was wrong — the server understood it perfectly and found that it fails a rule. That is exactly the case §15.5.21 describes, and it is also the practical choice, because a 422 can carry a JSON Pointer to the missing property while a 400 cannot point at anything. Keep 400 for the genuinely unreadable: truncated bodies, invalid JSON, a path parameter that does not match its declared type, or a missing required query parameter the router needs before dispatch.
Related
- Validation Error Payloads — up-link: the section defining the violations contract
- Error Contracts & Resilience Mapping — up-link: the parent reference for failure contracts
- Field-Level Error Paths with JSON Pointer — how the
422body addresses fields - 409 Conflict vs 412 Precondition Failed — the neighbouring codes for state conflicts
- HTTP Status Code Mapping — the full mapping this decision fits into