Filter concepts by levelShowing all levels.

Django · API Error Handling

API Error Handling

The 4xx and 5xx families, one global exception handler, stable payloads, correlation IDs, and safe messages

  • Pick the code by the remedy it implies400 fix input · 401 authenticate · 403 give up · 409 state · 429 wait
  • 500 is yours; 502/503/504 are the proxy'sonly one of the four leaves a traceback in your log
  • One EXCEPTION_HANDLER, one error shapecall DRF's default first, then rewrite only response.data
  • A correlation id joins the report to the logecho it on successes too, not only failures
  • Never a traceback, SQL, credential, or secretand never two responses that differ when they must not

1.Validation errors and the 4xx status codes

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

Key features

  • DRF renders `ValidationError` as 400 with a per-field mapping; the exception is what you raise, the code is what the framework sets
  • 401 requires a `WWW-Authenticate` header, which DRF takes from the first authentication class — without one it must answer 403 instead
  • 403 confirms the resource exists; where that is sensitive, filter in `get_queryset()` so both cases return 404
  • DRF ships no 409 exception — subclass `APIException` with `status_code = 409` for a duplicate, a stale version, or an order that has already shipped
  • 429 must carry `Retry-After`; DRF's `Throttled` sets it from the throttle's `wait()`
  • An empty collection is 200 with `[]`, never 404 — the request succeeded and the answer is "nothing matched"

Choosing a 4xx by what the client should do next

Choosing a 4xx by what the client should do next
CodeMeaningClient's next moveDRF exception
400malformed or fails validationfix the request, retry`ValidationError`, `ParseError`
401no valid credentialsauthenticate, retry`NotAuthenticated`, `AuthenticationFailed`
403identified, and refuseddo not retry`PermissionDenied`
404no such resource (or you may not know)do not retry`NotFound`, `Http404`
405method not allowed on this URLuse a different method`MethodNotAllowed`
409conflicts with current stateretry only if state changesdefine your own `APIException`
422well-formed, fails a business rulechange the values, retrya subclass with `status_code = 422`
429rate limitedwait `Retry-After`, retry unchanged`Throttled`
The 4xx family, grouped by the remedy each one implies

Fix the request

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

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

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

  • 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

Command

python
class Conflict(APIException):
    status_code = 409
    default_detail = "Conflicts with the current state of the resource."
    default_code = "conflict"

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.

2.500, and the 502 / 503 / 504 family

A 5xx says the failure is on your side. **500** is your own code raising something it did not handle — it came from Django, so it appears in your logs with a traceback. The other three usually did not come from your code at all: **502 Bad Gateway** means the proxy reached your application and got something unusable, typically because the worker died mid-request; **503 Service Unavailable** means nothing was there to serve it, such as every worker being busy or the app being down; **504 Gateway Timeout** means the application took longer than the proxy was willing to wait. Recognising which of the four you are looking at tells you where to go looking, and only one of them has a traceback waiting for you.

Key features

  • 500 comes from your code and Django logs it with a traceback; 502/503/504 usually come from the proxy and appear only in *its* log
  • 502 means the upstream returned something unusable (commonly a worker killed mid-response); 503 means nothing was available to serve; 504 means the app exceeded the proxy's read timeout — and the request often finishes successfully afterwards, invisibly
  • Layer the timeouts: `statement_timeout` < Gunicorn `--timeout` < proxy read timeout, so slowness surfaces where it can be diagnosed
  • With `DEBUG = False` Django returns a plain 500 and emails `ADMINS`; with `DEBUG = True` it returns a full traceback page — which is why `DEBUG` must never be on in production
  • Never return 5xx for a client mistake: a 5xx is the one class that well-behaved clients retry, so a misused 500 turns one bad request into a storm

Four server-side codes, and where to look

Four server-side codes, and where to look
CodeWho produced itTypical causeWhere the evidence is
500Djangoan unhandled exception in your codethe application log — traceback + correlation id
502the proxyworker died mid-request; OOM kill; a bad responseproxy log + kernel/OOM log; no Django traceback
503the proxy or app serverall workers busy, app down, health check failingproxy log, worker saturation metrics
504the proxythe app exceeded the proxy read timeoutproxy log; the slow query is often in the DB log
Where each 5xx is generated on the way to your view

Client

sees only the status code — and retries 5xx, which is why misusing one is expensive

CDN / load balancer

503 when no healthy backend is available; the first place a request can fail without touching you

Reverse proxy (nginx)

504 when the app exceeds proxy_read_timeout · 502 when the worker returns something unusable

Gunicorn master + workers

--timeout kills a worker mid-request, which the proxy then reports as 502

Django + DRF

500 — the only one of the four with a traceback in your log and a correlation id in the response

PostgreSQL

statement_timeout cancels a slow query, surfacing as a clean 500 rather than a mysterious 504

  1. Client — sees only the status code — and retries 5xx, which is why misusing one is expensive
  2. CDN / load balancer — 503 when no healthy backend is available; the first place a request can fail without touching you
  3. Reverse proxy (nginx) — 504 when the app exceeds proxy_read_timeout · 502 when the worker returns something unusable
  4. Gunicorn master + workers — --timeout kills a worker mid-request, which the proxy then reports as 502
  5. Django + DRF — 500 — the only one of the four with a traceback in your log and a correlation id in the response
  6. PostgreSQL — statement_timeout cancels a slow query, surfacing as a clean 500 rather than a mysterious 504

Command

python
def handler(exc, context):
    response = exception_handler(exc, context)
    if response is None:                      # DRF did not recognise it -> a 500
        logger.exception("unhandled", extra={"correlation_id": cid})
    return response

A 500 is yours — Django raised it, so there is a traceback and a correlation id. A 502, 503 or 504 is usually the proxy answering because your application died, was unavailable, or was too slow, so the evidence is in the proxy log rather than Django's. Layer timeouts innermost first — `statement_timeout` < Gunicorn `--timeout` < proxy read timeout — so slowness surfaces as an attributable 500. And never return a 5xx for a client mistake: 5xx is the class clients retry.

3.Global exception handlers, stable payloads, and correlation IDs

DRF routes every exception raised inside a view through one function, named by the `EXCEPTION_HANDLER` setting. Replacing it is how you give an API a single error shape without touching a single view. The handler receives the exception and a context dict, and DRF's own default already maps its exception classes to the right status codes — so the pattern is to call the default first, then reshape its body. When the default returns `None`, the exception was one DRF does not recognise and is on its way to becoming a 500: that is the hook where you log it. A correlation id ties the three together — echoed in the response, attached to every log line for that request, so a support ticket containing one id leads straight to the traceback.

Key features

  • `REST_FRAMEWORK["EXCEPTION_HANDLER"]` names one function; it applies to every DRF view in the project
  • Calling `rest_framework.views.exception_handler` first keeps DRF's status-code mapping and lets you rewrite only the body
  • A `None` return from the default means DRF did not recognise the exception — it becomes a 500, and this is the place to log it
  • The handler only sees exceptions raised inside DRF views; Django middleware errors and URL-resolution 404s bypass it entirely
  • Generate the correlation id in middleware so it exists for successful requests too, and echo it in a response header as well as the body
  • A stable payload means the top-level keys and the `code` values never change — those are the parts clients branch on

What reaches the handler, and what does not

What reaches the handler, and what does not
Raised byReaches `EXCEPTION_HANDLER`?Notes
`raise ValidationError` in a serializeryesdefault maps it to 400 with the field mapping
`PermissionDenied` from a permission classyes403, or 401 if unauthenticated and a challenge exists
`Http404` / `get_object_or_404`yesDRF converts it to `NotFound`
An unhandled `KeyError` in a viewyes — as `None` from the defaultlog it here; it becomes a 500
An exception in Django middleware**no**outside DRF — use `process_exception` or Django's handler500
A URL that matches no pattern**no**resolved before any view; Django returns the 404
One exception, from the view to the client and the log
View
DRF dispatcher
Your handler
Logger
Client
  1. 1raise Conflict("already shipped")
  2. 2handler(exc, context)context carries view, args, kwargs, request
  3. 3exception_handler(exc, context)the default first — it already knows the status code
  4. 4Response(status=409)
  5. 5warning · code=conflict · correlation_id
  6. 6409 {type, detail, correlation_id}
  7. 7an unhandled KeyError
  8. 8handler(exc, context)
  9. 9exception_handler → Nonenot an API exception — this is the 500 path
  10. 10logger.exception — full traceback + correlation_id
  11. 11500 {type: internal_error, correlation_id}generic body; the traceback stays in the log
  1. View → DRF dispatcher: raise Conflict("already shipped")
  2. DRF dispatcher → Your handler: handler(exc, context) (context carries view, args, kwargs, request)
  3. Your handler → DRF dispatcher: exception_handler(exc, context) (the default first — it already knows the status code)
  4. DRF dispatcher → Your handler: Response(status=409)
  5. Your handler → Logger: warning · code=conflict · correlation_id
  6. Your handler → Client: 409 {type, detail, correlation_id}
  7. View → DRF dispatcher: an unhandled KeyError
  8. DRF dispatcher → Your handler: handler(exc, context)
  9. Your handler → DRF dispatcher: exception_handler → None (not an API exception — this is the 500 path)
  10. Your handler → Logger: logger.exception — full traceback + correlation_id
  11. Your handler → Client: 500 {type: internal_error, correlation_id} (generic body; the traceback stays in the log)

Command

python
def handler(exc, context):
    response = exception_handler(exc, context)   # DRF's default first
    if response is None:
        ...                                       # unrecognised -> log, then 500
    return response

One `EXCEPTION_HANDLER` gives the whole API one error shape without touching a view. Call DRF's default first — it already maps exceptions to status codes and adds `Retry-After` and `WWW-Authenticate` — then rewrite only `response.data`. A `None` return means an unrecognised exception heading for a 500: log it there with the traceback, and return a generic body. Generate a correlation id in middleware so it exists on successful requests too, echo it in a header, and bind it to every log line.

4.Safe error messages, and what must never leave the server

An error response should tell the caller what they can act on, and nothing about how the server is built. Five things must never appear in one: **stack traces** (they name your files, packages, and versions), **SQL statements** (they hand over your schema), **credentials** and **secrets** (a connection string in an exception message is a leaked password), and **sensitive internal state** (hostnames, private IPs, queue names, other users' data). All five belong in the log instead, keyed by a correlation id the response does echo. There is a second, quieter rule: an error message must not *distinguish* things the caller should not be able to distinguish — "no such user" and "wrong password" have to read identically, or the endpoint becomes an account-enumeration tool.

Key features

  • `DEBUG = True` in production renders a full traceback page including settings, local variables, and the SQL that ran — the single highest-impact configuration mistake in Django
  • `str(exception)` frequently contains hostnames, ports, file paths, and query fragments — never put it in a response body
  • Login, signup, and password reset must return the same response and status whether or not the account exists
  • Comparing secrets with `hmac.compare_digest` rather than `==` closes the timing side channel that reveals how many leading bytes matched
  • Validation messages should describe the rule, not the data — "must be a valid coupon" rather than "coupon PROMO50 has 0 uses left"
  • The correlation id is what makes redaction affordable: the caller gets an id, the operator gets the traceback, and the two are joinable

The five things that must never appear in a response body

The five things that must never appear in a response body
Never exposeHow it usually escapesWhere it belongs
Stack traces`DEBUG = True`, or returning `str(exc)`the application log, at `exception` level
SQL statementsan `OperationalError` message passed throughthe log; the database's own slow-query log
Credentialsa connection string inside a driver exceptionnowhere — the log too should mask them
Secrets / API keysan integration error echoing the request it sentthe log, masked, or not logged at all
Sensitive internal statehostnames, private IPs, queue names, other users' rowsthe log and your metrics, never the response
The same failure, leaked and contained

Leaked — what the caller should never see

  • Names the database host, its private IP, and the port.
  • Reveals the ORM, the driver, and their versions — a version-specific CVE becomes a targeted attempt.
  • The SQL discloses table and column names.
  • "No account with that email" turns login into an account directory.
  • Now stored in the caller's logs, error tracker, and support screenshots.

Contained — actionable, and nothing more

  • One generic sentence; the traceback is in the log only.
  • A correlation id joins the caller's report to the exact log line.
  • A stable `type` the client can branch on without parsing prose.
  • Login answers identically whether or not the account exists.
  • Nothing in the body describes the server's shape.
  • Leaked — what the caller should never see
    • Names the database host, its private IP, and the port.
    • Reveals the ORM, the driver, and their versions — a version-specific CVE becomes a targeted attempt.
    • The SQL discloses table and column names.
    • "No account with that email" turns login into an account directory.
    • Now stored in the caller's logs, error tracker, and support screenshots.
  • Contained — actionable, and nothing more
    • One generic sentence; the traceback is in the log only.
    • A correlation id joins the caller's report to the exact log line.
    • A stable `type` the client can branch on without parsing prose.
    • Login answers identically whether or not the account exists.
    • Nothing in the body describes the server's shape.

Command

python
logger.exception("charge_failed", extra={"correlation_id": cid})
return Response(
    {"type": "internal_error", "detail": "An unexpected error occurred.",
     "correlation_id": cid},
    status=500,
)

An error body says what the caller can act on and nothing about how the server is built: never a stack trace, SQL, a credential, a secret, or internal state. Log all of it with a correlation id and return a generic sentence plus that id. Then close the quieter leak — responses that *differ* when they should not: login and password reset must be identical in body, status, and timing, secrets compare with `compare_digest`, and a 403 becomes a 404 wherever existence is the secret. `DEBUG` must be `False` in production; nothing else on this list matters if it is not.

Remember

  • In DRF you raise the exception and the framework sets the code — but it ships none for 409, so define an APIException subclass with status_code = 409.
  • An empty collection is 200 with [], never 404: the request was valid and the answer is "nothing matched".
  • A handler that skips DRF's default loses the status-code mapping and the Retry-After / WWW-Authenticate headers those codes require.
  • Layer timeouts innermost first — statement_timeout < Gunicorn --timeout < proxy read timeout — so slowness surfaces as an attributable 500.

Pro tip

  • Log unrecognised exceptions in the handler's `response is None` branch — it is the last point where both the exception and the request are in hand.
  • Login, signup and password reset must be identical in body, status AND timing: hash a password even for an unknown email, or the clock leaks which accounts exist.

Next up

  • OpenAPI and API Documentation
  • Django Security
  • Observability

Sources

  • RFC 9110 § 15.5–15.6 — rfc-editor.org/rfc/rfc9110.html
  • DRF Exceptions — django-rest-framework.org/api-guide/exceptions/
  • Every claim checked against RFC 9110, Django 5.2 LTS and DRF 3.15 docs · checked 2026-09-04

Spotted a mistake on this page? Tell us.