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.

Layers of error pagesDjango 404 / 403template, context okDjango 500minimal, no DBNginx 502 / 504static HTML fileCDN fallbackorigin unreachableEach layer needs its own page, because the layer that failed cannot render one.
  1. Add templates named 404.html, 500.html, 403.html, and 400.html in a template directory Django searches.
  2. Keep 500.html self-contained: no database queries, no context processors, inline critical CSS.
  3. Django renders 500.html without a request context by default; do not rely on {{ user }} or custom tags that query.
  4. Add Nginx error_page 502 503 504 /50x.html with a static file for when Django is down.
  5. Test with DEBUG = False locally or on staging, including a deliberate 500.
  6. Optionally show a request ID on the 500 page with a custom handler500 view.

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.

error pages by layerRendered bySafe content404, 403, 400Django, with contextbase template, search500!Django, no context!self-contained HTML502, 503, 504Nginx error_pagestatic file on diskorigin unreachableCDN or LBcustom error page settingEach page must work when everything below it has faileddjango-deployment.com
Brand all four layers so users see a consistent message.

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.

error pages that work when things break404, 403, 400 templates with navigationhelp users recover500 page self-containedno DB, inline CSSNginx 50x page as a static filewhen Gunicorn is downReference ID shown on 500support can find logsTested with DEBUG offincluding a real 500An error page that errors is worse than nonedjango-deployment.com
Return the correct status codes; a 404 page served with 200 confuses search engines.

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 Accept in 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

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.

Keep going