DRF Exception Handling
DRF translates exceptions into HTTP responses through a single function: rest_framework.views.exception_handler. Every uncaught exception in a view (including in dispatch, auth, permissions, throttles) goes through it.
What DRF handles by default
| Exception | Response code |
|---|---|
Http404, ObjectDoesNotExist |
404 |
PermissionDenied (Django or DRF) |
403 |
NotAuthenticated |
401 |
AuthenticationFailed |
401 |
MethodNotAllowed |
405 |
NotAcceptable |
406 |
UnsupportedMediaType |
415 |
Throttled |
429 (with Retry-After) |
ValidationError |
400 |
APIException (base) |
500 (or status_code if subclassed) |
| Anything else | propagates → Django returns 500 (HTML in DEBUG, plain page in prod) |
That last row matters: KeyError, ValueError, IntegrityError are not caught — they 500. Either guard them in the view or extend the handler.
DRF exception classes
from rest_framework.exceptions import (
APIException, ValidationError, NotFound, NotAuthenticated,
AuthenticationFailed, PermissionDenied, Throttled, MethodNotAllowed,
ParseError, UnsupportedMediaType, NotAcceptable,
)
Each has status_code, default_detail, default_code. Subclass for custom errors:
class PaymentRequired(APIException):
status_code = 402
default_detail = "Subscription required."
default_code = "payment_required"
# usage
raise PaymentRequired()
raise PaymentRequired("Pro plan required for this endpoint.")
This is the right pattern for domain errors — don’t return Response({"error": ...}, status=402) from every view. Centralize via raise + handler.
Default response shape
{"detail": "Not found."}
For ValidationError it’s a dict per field:
{"email": ["Enter a valid email address."], "non_field_errors": ["..."]}
Custom exception handler
Reshape every error response in one place.
# myapp/exceptions.py
from rest_framework.views import exception_handler
def custom_exception_handler(exc, context):
response = exception_handler(exc, context) # delegate to DRF first
if response is None:
return None # not an exception DRF handles — let Django 500
response.data = {
"error": {
"code": getattr(exc, "default_code", "error"),
"message": str(exc.detail) if hasattr(exc, "detail") else str(exc),
"details": response.data,
}
}
return response
Wire it up:
# settings.py
REST_FRAMEWORK = {
"EXCEPTION_HANDLER": "myapp.exceptions.custom_exception_handler",
}
Now every error response has the same envelope.
Catching exceptions DRF doesn’t handle
from django.db import IntegrityError
def custom_exception_handler(exc, context):
if isinstance(exc, IntegrityError):
return Response(
{"error": {"code": "conflict", "message": str(exc)}},
status=409,
)
return exception_handler(exc, context)
This is how you turn a IntegrityError (unique constraint) into a 409 instead of a 500.
ValidationError — DRF vs Django
There are two ValidationError classes and they behave differently:
| Class | Status | Where to raise |
|---|---|---|
rest_framework.exceptions.ValidationError |
400 (auto via DRF handler) | inside serializer validate* methods, views |
django.core.exceptions.ValidationError |
not handled — bubbles to 500 | inside model clean(), save() |
If you call instance.full_clean() from a serializer’s create(), catch the Django one and re-raise as DRF’s:
from django.core.exceptions import ValidationError as DjangoValidationError
from rest_framework.exceptions import ValidationError as DRFValidationError
def create(self, validated_data):
instance = MyModel(**validated_data)
try:
instance.full_clean()
except DjangoValidationError as e:
raise DRFValidationError(e.message_dict)
instance.save()
return instance
context argument
The handler receives context = {"view": ..., "request": ..., "args": ..., "kwargs": ...}. Use it for logging:
def custom_exception_handler(exc, context):
response = exception_handler(exc, context)
if response is not None and response.status_code >= 500:
logger.exception("API 5xx", extra={
"view": context["view"].__class__.__name__,
"user_id": getattr(context["request"].user, "id", None),
})
return response
For Sentry, sentry_sdk already captures unhandled exceptions; use the handler for handled but logged ones (e.g. integrity errors you want visibility on).
Logging vs returning
Rule of thumb:
- 5xx errors: log with stacktrace, return generic message (“Internal server error”). Don’t leak SQL/exception text to the client.
- 4xx errors: don’t log (client’s fault), return helpful detail.
- 403/401: log only on suspicious patterns (many 401s from one IP), not on every miss — fills logs with bots.
raise_exception=True shortcut
In view methods, serializer.is_valid(raise_exception=True) raises DRFValidationError automatically — no need to write if not serializer.is_valid(): return Response(...).
Suppressing the browsable API HTML on errors
By default DRF can return HTML error pages when the client Accepts HTML. To force JSON-only:
# settings.py
REST_FRAMEWORK = {
"DEFAULT_RENDERER_CLASSES": [
"rest_framework.renderers.JSONRenderer",
],
}
Useful in production to prevent accidental HTML in API responses (and the slight info disclosure that comes with it).
Pitfalls
- Returning
{"error": ...}from individual views instead of raising. Inconsistent envelopes; impossible to refactor. - Forgetting that Django’s
ValidationError≠ DRF’s. Modelclean()errors bubble to 500. - Leaking exception text on 500.
str(exc)may include SQL, file paths, secrets. Strip in production. - Handler returns
Nonefor unknown exceptions by default, so aKeyErrorin your view becomes a Django 500 page (or DEBUG HTML traceback). Catch in the handler or in the view.
Interview angle
- “How does DRF translate exceptions into HTTP responses?” —
EXCEPTION_HANDLERsetting points to a function; DRF calls it on every uncaught exception indispatch. Default handler maps DRF exception classes to status codes; everything else → 500. - “Difference between
rest_framework.exceptions.ValidationErroranddjango.core.exceptions.ValidationError?” — DRF’s becomes 400 automatically; Django’s is not caught and becomes 500. Translate at the boundary. - “How would you make every error response use a consistent envelope like
{error: {code, message}}?” — customEXCEPTION_HANDLERthat wrapsresponse.data. - “How do you turn a database
IntegrityErrorinto a 409 Conflict?” — handle it explicitly in the custom exception handler before delegating to DRF’s default. - “Why is raising
APIExceptionsubclasses better than returningResponse({}, status=...)from views?” — centralized translation, consistent format, easier to refactor envelope, plays nicely with logging/observability.