Custom 404 and 500 Error Pages for Django in Production
Create custom 404, 500, 403, and 400 pages for a Django site in production: templates and handler views, a 500 page that cannot fail, static assets that load, Nginx error pages for when Django is down, and testing them with DEBUG off.
Problem statement
With DEBUG = False, visitors who hit a missing page or an unexpected error see Django's bare "Not Found" or "Server Error (500)" text, which looks broken and offers no way forward. Custom error pages keep your branding, help users recover (search, home link, support contact), and can show a reference ID for support. They need care: a 500 page that itself raises an error, uses the database, or depends on missing static files makes things worse, and when Gunicorn is down entirely, Django cannot render anything, so the proxy needs its own error page.
- Add templates named
404.html,500.html,403.html, and400.htmlin a template directory Django searches. - Keep
500.htmlself-contained: no database queries, no context processors, inline critical CSS. - Django renders
500.htmlwithout a request context by default; do not rely on{{ user }}or custom tags that query. - Add Nginx
error_page 502 503 504 /50x.htmlwith a static file for when Django is down. - Test with
DEBUG = Falselocally or on staging, including a deliberate 500. - Optionally show a request ID on the 500 page with a custom
handler500view.
Step-by-step solution
1. Create the templates
templates/
├── 404.html
├── 500.html
├── 403.html
└── 400.html
With DIRS including BASE_DIR / "templates" in TEMPLATES, Django's default handlers find them automatically when DEBUG = False.
{# templates/404.html #}
{% extends "base.html" %}
{% block title %}Page not found{% endblock %}
{% block content %}
<h1>We could not find that page</h1>
<p>The link may be old or mistyped. Try the <a href="/">home page</a> or search below.</p>
{% include "partials/search_form.html" %}
{% endblock %}
The 404 handler renders with a request context, so extending the base template is fine as long as the base template does not do anything that can fail.
2. Make the 500 page unbreakable
<!-- templates/500.html: no extends, no queries, inline styles -->
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
<title>Something went wrong</title>
<style>body{font-family:system-ui,sans-serif;max-width:36rem;margin:15vh auto;padding:0 1rem;color:#1f2933}</style>
</head>
<body>
<h1>Something went wrong on our side</h1>
<p>We have been notified. Please try again in a few minutes.</p>
<p><a href="/">Back to the home page</a></p>
</body>
</html>
The default server_error view renders 500.html with an empty context precisely because the error might be in the database, a context processor, or the base template. If your 500 page extends base.html and the base template queries the database during a database outage, the error page itself fails.
3. Show a reference ID (optional)
# config/urls.py
handler500 = "core.views.server_error"
# core/views.py
from django.shortcuts import render
from core.request_id import request_id_var
def server_error(request):
return render(request, "500.html", {"request_id": request_id_var.get()}, status=500)
Keep the view trivial; any exception here falls back to Django's plain response.
4. Nginx pages for when Django is down
server {
error_page 502 503 504 /50x.html;
location = /50x.html {
root /srv/app/error-pages;
internal;
}
location / {
proxy_pass http://unix:/run/gunicorn/gunicorn.sock;
proxy_intercept_errors off;
include proxy_params;
}
}
Leave proxy_intercept_errors off so Django's own 404 and 500 pages pass through; Nginx then only replaces errors it generates itself (502 when Gunicorn is down, 504 on timeout). The same static page can serve maintenance windows.
5. Test them
DJANGO_SETTINGS_MODULE=config.settings.production DEBUG=0 python manage.py runserver --insecure
curl -s -o /dev/null -w "%{http_code}\n" http://127.0.0.1:8000/no-such-page/
Add a staff-only URL that raises an exception on staging to see the real 500 page, and stop Gunicorn on staging to see Nginx's 502 page.
Verification check: a missing URL returns status 404 with your page, a deliberate exception returns 500 with the minimal page and appears in Sentry, and stopping Gunicorn shows the Nginx page with status 502.
Explanation
Django routes uncaught exceptions and Http404 to handler views (handler404, handler500, and others), which render templates with the matching names. The 500 handler intentionally avoids request context processors and gets an empty context so that it can still render when the error originated in those components. Errors that happen before Django runs, such as a stopped Gunicorn or a timeout, are produced by the proxy, which is why a static fallback page at the Nginx (or CDN) layer completes the picture.
Edge cases / notes
- Static assets on error pages should be inline or from a CDN; a 404 page whose CSS also 404s looks broken.
- APIs should return JSON errors, not HTML; DRF handles this for its views, and you can branch on
Acceptin custom handlers. - Search engines need real 404 status codes for missing pages; avoid soft 404s.
- Translations in error templates work for 404 (request context) but need care in 500.
Next steps
- When errors happen, debug them with Fix Django 500 Internal Server Error in Production.
- Reuse the static page for deploys with Django Maintenance Mode During Deploys.
FAQ
How do I make a custom 404 page in Django?
Create a 404.html template in a template directory and set DEBUG = False; Django uses it automatically.
Why does my custom 500 page not show?
It may extend a base template that fails, or depend on context the 500 handler does not provide. Keep it self-contained.
Can Django show an error page when Gunicorn is down?
No. Configure an Nginx error_page with a static file for 502, 503, and 504.
How do I test error pages locally?
Run with DEBUG = False and runserver --insecure, request a missing URL, and trigger a deliberate exception.