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.

Choosing the status code for a rejected request Four questions in sequence. Could the content type be handled? If not 415. Did the body parse? If not 400. Did it satisfy the schema and business rules? If not 422. Does the resource state permit it? If not 409. Otherwise the request proceeds. content type supported? Content-Type header no 415 yes body parses? JSON syntax, framing no 400 no field pointers available yes rules satisfied? schema + cross-field no 422 violations[] with JSON Pointers yes state permits it? resource lifecycle no 409 conflict, not input validation

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:

What each status code can tell the caller A 400 arises before an object graph exists, so it can carry only a parser location. A 422 arises after parsing, so it can point at every offending field. 400 Bad Request 422 Unprocessable Content body could not be read no field pointers exist yet best detail: line and column one problem reported body parsed cleanly JSON Pointer per violation stable code per rule every violation at once collapsing both into 400 discards the difference between a broken client and bad user input

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.

How a 422 travels and degrades The origin returns 422 with a problem body. The reverse proxy forwards it unchanged. A modern client branches on 422 while a legacy client treats the unknown code as 400, which is the specified fallback. origin 422 + problem proxy / gateway forwards 4xx as-is client that knows 422 binds violations to form fields client that does not treats it as 400 — RFC 9110 §15 fallback

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.