Django error handling: views, middleware, and validation
Handle Django errors with clear HTTP semantics, domain exceptions, safe middleware responses, and form or serializer validation.
A Django application needs different responses for invalid input, a programming defect, and an unavailable dependency. Expected failures should produce useful responses; unexpected failures need diagnostic evidence and an alert policy tied to user impact. Retrying every exception or returning HTTP 200 for every failure makes both recovery and monitoring harder.
Views, domain exceptions, and exception middleware can share this work. The examples below target Django 6.0 and distinguish ordinary Django responses from Django REST framework (DRF) behavior.
Classify the failure before handling it
| Failure | Typical response | Operational action |
|---|---|---|
| Missing resource, invalid form, denied access | Appropriate 404, 400, or 403 response | Give the caller a useful, safe explanation; watch unusual patterns |
| Unexpected type error or broken application invariant | Generic 500 response | Preserve the traceback, investigate, and alert according to impact |
| Database or downstream API failure | A bounded failure or explicitly designed fallback | Apply deadlines, selective retries, circuit breaking, and recovery checks |
A bug deserves attention, but paging on every individual exception can overwhelm an on-call team. Alert on sustained error rates, important operations, or service objectives. A dependency failure is not automatically retryable: a timed-out write may already have taken effect.
Handle expected failures in the view
For a model lookup, use Django's established 404 behavior:
from django.http import JsonResponse
from django.shortcuts import get_object_or_404
from .models import Article
def article_detail(request, pk):
article = get_object_or_404(Article, pk=pk)
return JsonResponse({"title": article.title})
Article is the application's model. Restrict the queryset to records this caller can see before looking up a private or tenant-owned article. get_object_or_404() raises Http404; it does not guarantee a JSON error body just because the successful response uses JsonResponse. Django's default error handler normally renders an HTML response. An API that promises JSON errors needs its own handlers or an API framework.
Catch a domain error where the view has enough context to choose a useful response. The following is an integration fragment, not a payment endpoint:
# do_transfer, validated_amount, and these exceptions are application-defined.
try:
result = do_transfer(request.user, validated_amount)
except InsufficientBalance:
return JsonResponse({"error": "insufficient_balance"}, status=400)
except PaymentGatewayTimeout:
return JsonResponse({"error": "payment_status_unavailable"}, status=503)
return JsonResponse({"result": result})
The surrounding view must authenticate the caller, check transfer permission, validate the amount, and define a JSON-serializable result. A timeout does not prove the transfer failed. Use a durable operation ID, the gateway's idempotency contract, and reconciliation before offering a retry; a generic “try again” response can otherwise cause duplicate side effects.
Give domain failures stable codes
Put a small exception hierarchy in errors.py:
class AppError(Exception):
status = 400
code = "app_error"
class InsufficientBalance(AppError):
code = "insufficient_balance"
class RateLimitExceeded(AppError):
status = 429
code = "rate_limited"
A service can raise InsufficientBalance() without knowing how the HTTP response is rendered. A view can handle it locally, or the middleware below can turn it into the application's standard error response. These status and code values are developer-controlled; do not populate them directly from untrusted input.
A rate-limit response may also need a valid Retry-After value derived from the limiter's policy. The exception class alone does not implement a limiter.
Add an exception middleware fallback
Save this beside errors.py as middleware.py:
import logging
from django.core.exceptions import PermissionDenied, SuspiciousOperation
from django.http import Http404, JsonResponse
from .errors import AppError
logger = logging.getLogger(__name__)
class ExceptionMiddleware:
def __init__(self, get_response):
self.get_response = get_response
def __call__(self, request):
return self.get_response(request)
def process_exception(self, request, exc):
if isinstance(exc, (Http404, PermissionDenied, SuspiciousOperation)):
return None
if isinstance(exc, AppError):
return JsonResponse({"error": exc.code}, status=exc.status)
logger.error(
"Unhandled view exception: %s %s",
request.method,
request.path,
exc_info=(type(exc), exc, exc.__traceback__),
)
return JsonResponse({"error": "internal_error"}, status=500)
Add its dotted path, such as myapp.middleware.ExceptionMiddleware, to the project's MIDDLEWARE list while retaining the normal security, session, authentication, and CSRF middleware. Review its order alongside existing exception middleware: Django calls process_exception() in reverse middleware order, and a returned response stops later exception hooks from handling that exception.
The standard HTTP exceptions return None here so Django can preserve its 404, 403, and 400 semantics. Unexpected exceptions produce a generic response and a server-side traceback. The explicit exc_info tuple associates the original exception with the log record.
This hook handles exceptions raised by a view or by rendering a template response. It is not a universal wrapper around every middleware, background task, or streaming iterator. Once a response has started streaming, this hook cannot replace its already-sent headers and body. See Django's exception middleware contract.
Set DEBUG=False in production and test with that setting. If the API requires JSON for standard errors too, configure suitable handler400, handler403, handler404, and handler500 functions in the root URL configuration. The first three receive (request, exception); handler500 receives only request. Keep the error contract consistent across framework responses and application responses.
Return form errors where users can correct them
A form view should render the bound form again so its field errors remain available:
from django.contrib.auth.decorators import login_required, permission_required
from django.shortcuts import render, redirect
from django.views.decorators.http import require_POST
from .forms import ArticleForm
@login_required
@permission_required("myapp.add_article", raise_exception=True)
@require_POST
def create_article(request):
form = ArticleForm(request.POST)
if form.is_valid():
form.save()
return redirect("article-list")
return render(request, "form.html", {"form": form}, status=400)
Replace myapp with the actual app label. Define ArticleForm, the article-list route, and a template that renders field and non-field errors and includes a CSRF token. Model forms should explicitly allow editable fields. If ownership or tenant information must come from the authenticated request, assign it on the server rather than accepting it as an editable form field. Add object-level permissions for operations on existing objects.
For a DRF API, serializer validation is a different response path:
# Inside a DRF APIView or @api_view handler:
serializer = ArticleSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
article = serializer.save()
DRF's exception handler turns its ValidationError into a 400 response with useful validation details when it is raised inside a DRF-managed view. It does not install that behavior for an arbitrary Django view or background task. Keep authentication, permissions, and serializer field restrictions in place. See DRF exceptions.
Keep useful evidence without exposing it to clients
Record a request or trace ID, a normalized route where available, the failure category, and the traceback for unexpected errors. Include user or business identifiers only where policy permits, and avoid passwords, tokens, payment details, and unrestricted request bodies. Even path segments can contain sensitive data, so adapt the example's logging to the application's URL design.
When collecting application files through Guance DataKit logging, configure multiline handling for text tracebacks or encode the exception in a structured record. Trigger a controlled development exception and verify that one event contains the relevant stack and request context, while the response contains only the safe error code. Coordinate middleware logging with Django's logging configuration to avoid counting duplicate records as separate failures.
Common questions
How do I customize the 500 page when DEBUG=False?
Provide a 500.html template in the configured template search path, or define a root-URL handler500. Similar templates exist for 404 and 403 responses. Keep an emergency error page simple and independent of database queries or fragile external services. Test it with debugging disabled; development debug pages follow different behavior. See Django error views.
What belongs in a view, and what belongs in middleware?
Handle anticipated failures locally when that preserves the operation's meaning or allows a safe recovery. Use shared exceptions for consistent domain outcomes and middleware as a fallback. Catching everything in middleware cannot reconstruct the business context lost by an overly broad exception.
How much detail should an exception log contain?
Enough to identify the failing request and code path, with a traceback for unexpected errors. Inside an except block, logger.exception() includes the active traceback. Outside that context, pass explicit exception information as the middleware does. Redact sensitive context and keep client-facing messages separate from internal diagnostics.
These examples cover error-response patterns rather than a complete production application or payment integration. For related topics, see DuckDB versus SQLite and FastAPI error handling, currently in Chinese.
Contact us
Join the community
to join the community
Try Guance
Start online and pay only for what you use.
Get startedChoose a Guance plan