A polished error page is a small thing that quietly protects your brand. When something goes wrong, your visitors should still see your design, your navigation, and a way back—not a bare "Server Error (500)" served by Nginx. This guide shows how to build clean custom 404, 500, 403 and 400 pages in Django 5.x, how Django actually resolves these handlers, the gotchas that trip people up (especially on the 500 page), and how to test it all locally before you ship.
How Django resolves error handlers
When a view raises an exception or returns a 404, Django doesn't fail blindly. It maps each error condition to a named handler view:
- 404 — Not Found →
handler404(django.views.defaults.page_not_found) - 500 — Server Error →
handler500(django.views.defaults.server_error) - 403 — Permission Denied →
handler403(django.views.defaults.permission_denied) - 400 — Bad Request →
handler400(django.views.defaults.bad_request)
These defaults are wired in automatically. The key detail: the default 404/403/400 handlers already render 404.html, 403.html and 400.html if those templates exist, and the default 500 handler renders 500.html. So for most sites you do not need to write any Python at all—you just need to provide the templates. You only override the handler views when you want extra behaviour (custom context, logging, a different template per section, etc.).
One more thing worth knowing: these error responses are produced after your request has passed through the middleware stack, so middleware that wraps the response still runs. If you are new to that flow, our walkthrough of Django middleware explains exactly where exception handling sits.
The simplest path: drop in template files
The fastest way to get branded error pages is to create four templates at the root of your templates directory:
templates/
├── 400.html
├── 403.html
├── 404.html
└── 500.html
Django finds these by name, not by path, so they must sit at the top level of a directory listed in TEMPLATES['DIRS'] (or the root templates/ folder of an app when APP_DIRS is True). Two conditions must be true for Django to use them:
DEBUG = False— withDEBUG = Trueyou get the yellow debug page instead, by design.ALLOWED_HOSTSis set — Django refuses to serve requests (returning a 400) until you list your hostnames.
Point your template loader at the directory and flip the production switches:
# settings.py
from pathlib import Path
BASE_DIR = Path(__file__).resolve().parent.parent
# Custom error templates are only used when DEBUG is False.
DEBUG = False
ALLOWED_HOSTS = ["yourdomain.com", "www.yourdomain.com"]
TEMPLATES = [
{
"BACKEND": "django.template.backends.django.DjangoTemplates",
# 400.html, 403.html, 404.html and 500.html live at the root of this dir.
"DIRS": [BASE_DIR / "templates"],
"APP_DIRS": True,
"OPTIONS": {
"context_processors": [
"django.template.context_processors.request",
"django.contrib.auth.context_processors.auth",
"django.contrib.messages.context_processors.messages",
],
},
},
]A minimal 404.html can be a normal template—it is rendered with a request context, so tags like {% url %} and your base layout work as expected:
{% load static %}
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Page not found (404)</title>
<link rel="stylesheet" href="{% static 'css/errors.css' %}">
</head>
<body>
<main class="error">
<h1>404 — Page not found</h1>
<p>The page you asked for doesn't exist or has moved.</p>
<a href="{% url 'home' %}">Back to home</a>
</main>
</body>
</html>The 500.html gotcha: no request context
This is the single most common surprise. Django's default handler500 renders 500.html without a request context. That is deliberate: a 500 often means something is already badly broken (a context processor might be the very thing throwing), so Django renders the page with an empty context to avoid a cascading failure that would leave the user with nothing.
The practical consequences for 500.html:
- Variables injected by context processors are not available—
{{ request }},{{ user }}and the{{ STATIC_URL }}variable will all be empty. - Avoid template tags that depend on the request context. Keep the page essentially static.
- For assets, use
{% load static %}with the{% static %}tag (it resolves from settings, not from context) rather than the{{ STATIC_URL }}variable.
A safe 500.html looks like this—self-contained, no context-dependent logic:
{% load static %}
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Something went wrong (500)</title>
<link rel="stylesheet" href="{% static 'css/errors.css' %}">
</head>
<body>
<main class="error">
<h1>500 — Server error</h1>
<p>We hit an unexpected problem. Our team has been notified.</p>
<a href="/">Back to home</a>
</main>
</body>
</html>Because a 500 means "we have no idea what just happened", you should also be alerting on it. Wiring up error tracking so each 500 reaches you with a stack trace is a five-minute job—see our guide on monitoring Django with Sentry.
Custom handler views
When templates alone aren't enough—say you want to log the failing path, add lightweight context, or branch on the request—override the handler views. Use function-based views with the correct signatures. Note that handler500 takes only request (no exception argument), while the others receive the exception that was raised:
# myapp/views.py
from django.shortcuts import render
def handler404(request, exception):
"""Render the custom 404 (Page Not Found) page."""
return render(request, "404.html", status=404)
def handler500(request):
"""Render the custom 500 (Server Error) page.
There is no `exception` argument and no guaranteed request context,
so keep 500.html static and free of context-processor variables.
"""
return render(request, "500.html", status=500)
def handler403(request, exception):
"""Render the custom 403 (Permission Denied) page."""
return render(request, "403.html", status=403)
def handler400(request, exception):
"""Render the custom 400 (Bad Request) page."""
return render(request, "400.html", status=400)Then register them in your root urls.py (the module named in ROOT_URLCONF). These are module-level assignments, not entries in urlpatterns, and Django accepts either a dotted import string or the callable itself:
# project/urls.py (the module set as ROOT_URLCONF)
from django.contrib import admin
from django.urls import path
urlpatterns = [
path("admin/", admin.site.urls),
# ... your app URLs ...
]
# Error handlers: a dotted path to the view, set at module level.
handler404 = "myapp.views.handler404"
handler500 = "myapp.views.handler500"
handler403 = "myapp.views.handler403"
handler400 = "myapp.views.handler400"Prefer class-based views? You can point a handler at MyErrorView.as_view()—the same signature rules apply. If you are leaning into CBVs across the project, our introduction to Django class-based views is a good companion read.
Testing your error pages locally
Because the custom pages only appear when DEBUG = False, you have to simulate production to see them. Three steps:
- Set
DEBUG = Falseand add local hosts toALLOWED_HOSTS, e.g.["localhost", "127.0.0.1", "testserver"]. (testserveris the host Django's test client uses, handy for automated tests.) - With
DEBUG = False, the dev server stops serving static files, so your error pages look unstyled. Runpython manage.py runserver --insecureto serve static during this check (development only—never use--insecurein production). - Trigger each error. A 404 is easy: just visit a URL that doesn't exist. For the others, temporarily wire up throwaway views that raise the matching exception, hit them once, then delete them.
# myapp/dev_error_probes.py (TEMPORARY — remove after testing)
from django.core.exceptions import PermissionDenied, SuspiciousOperation
from django.http import Http404
def trigger_404(request):
raise Http404("Intentional 404 to verify 404.html")
def trigger_500(request):
raise RuntimeError("Intentional crash to verify 500.html")
def trigger_403(request):
raise PermissionDenied("Intentional 403 to verify 403.html")
def trigger_400(request):
raise SuspiciousOperation("Intentional 400 to verify 400.html")You can also assert on them in tests with Django's test client: set DEBUG=False (or use client.raise_request_exception = False) and check response.status_code and response.templates. This keeps your error pages from silently breaking during a refactor.
Serving static assets on error pages
In real production, neither the Django dev server nor --insecure is in play, so your CSS, fonts and images must be served by something that runs regardless of application state:
- Run
python manage.py collectstaticand serve the collected files via your web server (Nginx) or a CDN. - Or use WhiteNoise, which serves hashed static files straight from the WSGI/ASGI app with no extra server config—convenient and 500-safe because it doesn't depend on a successful view render.
- Keep references in error templates simple:
{% static 'css/errors.css' %}. Even better for the 500 page, inline the critical CSS so it renders correctly even if static serving itself is the thing that failed.
What about Django REST Framework APIs?
Those HTML templates are for browser-facing (HTML) routes. Django REST Framework intercepts exceptions inside its own views and returns JSON, so an API client should get a structured body, not a rendered 404.html. DRF routes errors through a single configurable exception handler:
# myapp/exceptions.py
from rest_framework.views import exception_handler
def custom_exception_handler(exc, context):
"""DRF's default response, augmented with the numeric status code."""
response = exception_handler(exc, context)
if response is not None:
response.data["status_code"] = response.status_code
return response# settings.py
REST_FRAMEWORK = {
"EXCEPTION_HANDLER": "myapp.exceptions.custom_exception_handler",
}Note that DRF's handler covers its own APIException family (plus Http404 and PermissionDenied). A genuinely unexpected exception in API code still bubbles up to Django and becomes a 500 handled by handler500—so keep both layers in good shape. If your error pages need shared dynamic snippets (a status banner, a localized message), custom template tags and filters are the clean way to add them—on the 404/403/400 pages, which do get a request context.
Wrapping up
Branded error pages take minutes: drop 404.html, 500.html, 403.html and 400.html at your templates root, set DEBUG = False with a proper ALLOWED_HOSTS, and Django does the rest. Reach for custom handler views only when you need extra logic, keep 500.html resilient and context-free, and always verify the pages under production settings before you deploy. If you'd like a team to harden and modernize your Django stack end to end, our Django development services can help.
Frequently Asked Questions
Why aren't my custom 404 and 500 pages showing up?
The most common reason is DEBUG = True. Django only serves your custom error templates when DEBUG = False and ALLOWED_HOSTS is populated; with debug on you get the interactive yellow error page instead. Also confirm the files sit at the root of a directory listed in TEMPLATES['DIRS'] and are named exactly 404.html, 500.html, 403.html and 400.html.
Do I need custom handler views, or are templates enough?
For most sites, templates alone are enough. Django's built-in handlers automatically render 404.html, 500.html, 403.html and 400.html when they exist. Write custom handler404/handler500 views only when you need extra behaviour—logging, custom context, or choosing a template based on the request.
Why does my 500 page look broken or unstyled?
The 500 page is rendered without a request context on purpose, so context-processor variables like {{ STATIC_URL }}, {{ user }} and {{ request }} are unavailable, and the dev server doesn't serve static files when DEBUG = False. Use the {% static %} tag (not the variable), serve assets via WhiteNoise, Nginx or a CDN, and consider inlining critical CSS in 500.html.
How do I test error pages on my local machine?
Set DEBUG = False, add localhost, 127.0.0.1 (and testserver for tests) to ALLOWED_HOSTS, and run python manage.py runserver --insecure so static files are served during the check. Visit a non-existent URL to trigger a 404, and wire up temporary views that raise Http404, PermissionDenied, SuspiciousOperation or a plain exception to exercise the other pages.
What's the difference between handler404 and handler500 signatures?
handler404, handler403 and handler400 receive two arguments—request and the exception that was raised (def handler404(request, exception):). handler500 receives only request (def handler500(request):) because a server error is generic and not tied to a specific caught exception. Using the wrong signature is a frequent cause of "handler not working" reports.
How do custom error pages work with Django REST Framework?
DRF handles exceptions inside its own views and returns JSON rather than rendering your HTML templates, so API consumers get a structured error body. Configure REST_FRAMEWORK['EXCEPTION_HANDLER'] to customize that JSON. Your 404.html/500.html templates still apply to regular (non-DRF) Django views, and truly unexpected API errors fall through to Django's handler500.