skip to content

Why does a Django site's CSS load under runserver with DEBUG = True but return 404 as soon as DEBUG is set to False?

level: middleimportance: must knowfreq 66%

answer

  1. who was serving the files
  2. a development-only handler
  3. finders, not STATIC_ROOT
  4. collect, then serve elsewhere

basics

~20 s

With DEBUG on, staticfiles' runserver serves files straight from the finders. With DEBUG off that handler is not installed and nothing serves STATIC_URL, so run collectstatic and serve STATIC_ROOT from a web server, CDN or static-serving middleware.

solid answer

~40 s

`django.contrib.staticfiles` overrides `runserver`: when `DEBUG` is true, or you pass `--insecure`, it wraps the WSGI handler in `StaticFilesHandler`, which intercepts requests under `STATIC_URL` and serves them through the finders, straight from each app's `static/` folder and `STATICFILES_DIRS`. Set `DEBUG = False` and that wrapper is not installed; the staticfiles `serve()` view also raises `Http404` when `DEBUG` is off. Django itself then serves no static files, so every `/static/...` request falls through to the URLconf and 404s. The production fix is to run `collectstatic` into `STATIC_ROOT` and serve that directory from a web server, a CDN, or middleware such as WhiteNoise. `runserver --insecure` brings the handler back for local testing only; the documentation calls it grossly inefficient and probably insecure.

code

bash · 5 lines
bash
# Development only: serve static files through the finders with DEBUG = False
python manage.py runserver --insecure

# Production: gather everything into STATIC_ROOT for a real static server
python manage.py collectstatic --noinput

go deeper

for a junior

Know that runserver only serves static files while DEBUG is True, and that production needs collectstatic plus a real static server.

for a middle

Explain StaticFilesHandler, the --insecure and --nostatic options, and why the development path reads from finders rather than STATIC_ROOT.

for a senior

Choose and justify the production serving path, and walk the checklist that separates a missing collectstatic from a URL mismatch or a host-validation 400.

for a principal

Set the team's standard for static delivery across environments so development and production differ only where they must.

## Who serves static files in development A plain Django project does not serve CSS at all; it renders views. The convenience you see in development comes from **`django.contrib.staticfiles`**, which ships its own **`runserver`** command that overrides the core one: - It adds two options, `--nostatic` (do not serve static files) and `--insecure` (serve them even if `DEBUG` is `False`). - In `get_handler()` it wraps the normal handler in **`StaticFilesHandler`**, but only when static serving is enabled **and** `settings.DEBUG or insecure_serving` is true. - `StaticFilesHandler` checks whether the request path starts with `STATIC_URL`, and only for a local `STATIC_URL`, not a full `https://cdn...` URL. If it does, it serves the file through `django.contrib.staticfiles.views.serve`, which calls **`finders.find()`**. Because the handler uses the finders, development reads files **directly from their sources**: `exhibits/static/exhibits/gallery.js`, `assets/brand/museum.css` and so on. `STATIC_ROOT` is not involved, which is why a developer can go months without running `collectstatic`. ## What changes when DEBUG goes off Setting `DEBUG = False` flips two guards at once: 1. `runserver` no longer installs `StaticFilesHandler`, unless `--insecure` is passed. 2. The `serve()` view itself raises `Http404` when `DEBUG` is false and it was not called with `insecure=True`. The request for `/static/brand/museum.css` then reaches the URLconf, matches nothing, and returns a **404**. The same happens under a production application server: it runs the Django application, and nothing in a default Django project serves static files there, whatever `DEBUG` says. | Situation | Who serves `/static/...` | Reads from | |---|---|---| | `runserver`, `DEBUG = True` | `StaticFilesHandler` | finders (app `static/` folders, `STATICFILES_DIRS`) | | `runserver --insecure`, `DEBUG = False` | `StaticFilesHandler` | finders | | `runserver --nostatic` | nobody | nothing | | production server, default project | nobody, until you add a server or middleware | nothing | | production after `collectstatic` plus a static server | web server, CDN or middleware | `STATIC_ROOT` | ## The production fix 1. Set `STATIC_ROOT` to an output directory. 2. Run `python manage.py collectstatic --noinput`, which copies every file the finders can see into `STATIC_ROOT`. 3. Serve `STATIC_ROOT` at `STATIC_URL` with something built for it: a reverse proxy or web server location, an object store behind a CDN, or a middleware such as WhiteNoise inside the Django process. ## URL-pattern helpers follow the same rule Some projects serve static files in development through the URLconf instead of `runserver`, for example under a different development server. Django's helpers are built to do nothing outside debug mode: - `django.contrib.staticfiles.urls.staticfiles_urlpatterns()` returns a pattern for `STATIC_URL` that uses the staticfiles `serve()` view. - It is built on `django.conf.urls.static.static()`, which returns an **empty list** when `DEBUG` is false or when the prefix is a full URL with a host. So adding these helpers does not rescue a production deployment either: with `DEBUG = False` they contribute no patterns, by design. ## Why not just keep --insecure The documentation is blunt: `--insecure` is **grossly inefficient** and probably **insecure**, intended for local development only and never for production. Every asset request goes through Python and a finder lookup, with none of the caching and compression a static server provides. The documentation also notes that `--insecure` does not work with `ManifestStaticFilesStorage`. ## Debugging checklist - Is `django.contrib.staticfiles` in `INSTALLED_APPS`? Without it, the development server does not serve static files automatically, even with `DEBUG = True`. - Does the rendered HTML point at the URL you expect? Check `{% static %}` output against `STATIC_URL`. - Did `collectstatic` run in this environment, and does the file exist under `STATIC_ROOT`? - Is the static server's URL prefix the same as `STATIC_URL`? - Remember a separate cause of blanket errors when `DEBUG` goes off: with the default middleware, an empty `ALLOWED_HOSTS` returns 400 for every request Django handles.

  • Why does the development server not need collectstatic?
    `StaticFilesHandler` serves each request through `django.contrib.staticfiles.views.serve`, which calls `finders.find()` to locate the file in app `static/` folders and `STATICFILES_DIRS`. It never looks at `STATIC_ROOT`, so the collected copy only matters to whatever serves files in production.
  • If STATIC_URL is a full CDN URL, does runserver still serve static files?
    No. `StaticFilesHandler` only handles paths when `STATIC_URL` is local; with a network location such as `https://cdn.example.org/static/`, it passes requests through, and the browser fetches assets from the CDN directly.

saying these in an interview costs you the question

  • Running collectstatic makes Django itself serve STATIC_ROOT when DEBUG is False
  • The fix is to keep runserver --insecure running in production
  • runserver in development serves files from STATIC_ROOT
  • The production application server serves /static/ automatically once collectstatic has run
  • Adding STATIC_ROOT to the URLconf with a catch-all view is the recommended production setup