backend / web frameworks / django / drf / 14_exception_handling.md

DRF Exception Handling

5 interview angles 4 min read source

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. Model clean() errors bubble to 500.
  • Leaking exception text on 500. str(exc) may include SQL, file paths, secrets. Strip in production.
  • Handler returns None for unknown exceptions by default, so a KeyError in 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_HANDLER setting points to a function; DRF calls it on every uncaught exception in dispatch. Default handler maps DRF exception classes to status codes; everything else → 500.
  • “Difference between rest_framework.exceptions.ValidationError and django.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}}?” — custom EXCEPTION_HANDLER that wraps response.data.
  • “How do you turn a database IntegrityError into a 409 Conflict?” — handle it explicitly in the custom exception handler before delegating to DRF’s default.
  • “Why is raising APIException subclasses better than returning Response({}, status=...) from views?” — centralized translation, consistent format, easier to refactor envelope, plays nicely with logging/observability.