Validation errors and the 4xx status codes
coreintermediateA 4xx says the problem is on the client's side, and the specific code says what to do about it. **400** — the request is malformed or fails validation; fix it and retry. **401** — no valid credentials; authenticate and retry. **403** — credentials are fine, the action is refused; retrying will not help. **404** — no such resource, or you may not know it exists. **409** — the request is valid but conflicts with the current state, such as a duplicate or a stale version. **422** — well-formed and syntactically valid, but it fails a business rule (DRF returns 400 for this by default). **429** — you are rate limited; wait for `Retry-After` and try again. In DRF you rarely write these numbers: you raise the matching exception and the framework sets the code.
Think of it as
Pick the code by asking what the client should do next, because that is the only question the status line can answer. Retry after fixing input? 400 or 422. Retry after getting credentials? 401. Do not retry at all? 403. Wait then retry unchanged? 429. Retry only if state changes? 409. Read that way, the pairs that get confused separate cleanly. 401 versus 403 is "who are you?" versus "not you"; sending 403 to an unauthenticated caller tells a client to give up when refreshing a token would have worked. 403 versus 404 is a disclosure decision, not a correctness one — a 403 confirms the resource exists, so when existence itself is sensitive you filter the queryset and return 404 for both cases. 400 versus 409 is remedy again: a 400 says the request is wrong, a 409 says the world is. And the last discipline is that an empty result is not an error — `GET /orders/?status=paid` matching nothing is a 200 with an empty list, because the request was valid and the answer is "none".
What we're doing: One endpoint, five distinct failures, each with the code that tells the client what to do.
- 10–12
- Looking the order up inside a queryset already scoped to the caller means someone else's order id is a 404, not a 403 — the response cannot be used to confirm that the order exists.
- 14–15
- 409, because nothing about the request is wrong. Retrying with different input will not help; only the order's state changing would.
- 17–18
- `raise_exception=True` turns validation failures into a 400 with a per-field mapping, so the client can highlight the field rather than parse a sentence.
- 20–22
- A rule spanning two values is still a 400 here — the API uses 400 for every request-content failure rather than mixing 400 and 422, which would leave clients guessing.
- 24–25
- 403 and not 401: the caller is authenticated. Sending 401 would tell the client to refresh a token that is perfectly valid.
Why this works: Each code names a different remedy, so a client can act without reading prose: fix input (400), wait (429), refresh credentials (401), give up (403), or retry when state changes (409).
Returning 404 for an empty list
Wrong
Better
What you see: A dashboard shows an error banner for a brand-new account that simply has no orders yet, and the client has to special-case a 404 that means "success, nothing matched" against a 404 that means "this endpoint does not exist".
Why: 404 means the *resource* was not found, and a collection endpoint always exists — it just happens to contain nothing right now. Overloading the code makes two genuinely different situations indistinguishable to a client. An empty collection is a successful answer to a valid question, so it is 200 with an empty array.
- The request failed, and it is the client's side
- Fix the request — the body or the URL is wrong
- 400 Bad Request — malformed JSON, wrong type, missing required field
- 422 Unprocessable — valid shape, fails a business rule — DRF uses 400 unless told otherwise
- 405 Method Not Allowed — the URL exists; this verb does not
- Fix who you are — identity, not input
- 401 Unauthorized — authenticate and retry — needs a WWW-Authenticate header
- 403 Forbidden — we know who you are; retrying will not help
- 404 instead of 403 — when existence itself is the secret — filter the queryset
- Wait, or change the world — the request is fine; the state is not
- 409 Conflict — duplicate, stale version, already-shipped order
- 429 Too Many Requests — always with Retry-After — the client waits and repeats verbatim
- 200 with [] — an empty result is not an error — do not reach for 404
Choosing a 4xx by what the client should do next
Together
Remember: Choose the code by what the client should do next: 400/422 fix the input, 401 authenticate, 403 give up, 404 it is not there (or you may not know), 409 the state conflicts, 429 wait for `Retry-After`. In DRF you raise the exception and the framework sets the code — and it ships none for 409, so define one. 403 confirms existence, so return 404 for both cases when existence is the secret. An empty result is 200 with `[]`, never 404.
See also: server errors and gateway codes · global exception handlers and stable error payloads · http semantics and status codes · custom basepermission and object level checks

