Your country

Tools that support it use your country for local currency, number formats, units and paper size. Your choice is saved only in this browser.

Type a name or a two-letter code. Use the up and down arrow keys to move through the countries, Enter to choose one and Escape to close.

Django  Module 2 – URLs, views, templates and static files

Static files in development and production

Organise Django static files by app, link them with the static tag, collect hashed copies with collectstatic, and serve them with WhiteNoise, nginx or a CDN.

  • Beginner
  • 15 minutes
  • Examples run with Python 3.14.8, Django 6.1.2, Pyodide 314.0.7 and whitenoise 6.12.0
  • By MySmartCoPilot
Free lesson, Pro answers: 15 minutes
Free: the whole lesson. With a pass: the model answers to its 2 interview questions.

What you will learn

  • Organise app and project static files and reference them with the static tag
  • Run collectstatic and explain hashed file names, the manifest and the cache headers they allow
  • Choose between WhiteNoise, a web server and a CDN for serving static files in production
  • Separate user uploads, the media files, from static files

Before you start

What you’ll learn · Before you start

What you will learn

  • Organise app and project static files and reference them with the static tag
  • Run collectstatic and explain hashed file names, the manifest and the cache headers they allow
  • Choose between WhiteNoise, a web server and a CDN for serving static files in production
  • Separate user uploads, the media files, from static files

Before you start

On this page

Every page of Nivas loads the same stylesheet, and its browser copy should last for years, yet change on the day a release changes it. Django’s answer is a file name that changes with the content: collectstatic copied 133 files for Nivas and gave each a name with a hash of what it holds, and a server can then tell browsers to keep them for ten years.

This lesson organises the static files of Nivas, links them with the static tag, serves them while you develop, collects them into hashed copies for production, and serves those with WhiteNoise, then compares that with a web server and a CDN. Last, it keeps residents’ uploads, the media files, apart from all of them.

Each app keeps its static files in a folder of its own name

static files: Files a site serves as they are, the same for every visitor, such as stylesheets, scripts, fonts and the images of the layout. In Django they live in static/ folders and collectstatic gathers them. are the files a site sends as they are: stylesheets, scripts, fonts and the images of its layout. Nivas has them in two kinds of places. Files of the whole project, css/base.css and its logo, sit in the project’s static/ folder, which STATICFILES_DIRS names. Files of one app sit in that app’s static/ folder, under an inner folder with the app’s name: the notices app keeps notices/static/notices/notices.css. A template asks for a file by its name, notices/notices.css, and two finders search for it in a fixed order:

Interactive · stage by stage Where Django finds a static file's name Step through the search for notices/notices.css, then for notices.css, a name that leaves out the app's folder.
Where Django finds a static file's name, step 5 of 5. Showing notices/notices.css (notices.css), STATICFILES_DIRS: static/ (no such file), admin/static/ (no such file), notices/static/ (not under this name).nowhere: not foundnotices/notices.cssnotices.cssSTATICFILES_DIRS: static/no such fileadmin/static/no such filenotices/static/not under this namenot found

The drawing shows the whole scene; its 5 steps are listed below.

Every step as text
  1. A name, not a path. A template asks for notices/notices.css with the static tag. Like a template name, it is a name to search for, and the finders try folders in a fixed order.
  2. The project's folders first. The first finder reads STATICFILES_DIRS, here the project's static/ folder, which holds css/base.css and img/logo.svg but no notices/notices.css.
  3. Then each app. The second finder tries the static/ folder of each installed app, in the order of INSTALLED_APPS. The admin's folder has only admin/…, so the search goes on.
  4. Found in the notices app. notices/static/notices/notices.css exists, so the search stops: the first file found under a name is the one Django uses.
  5. A name without its folder. notices.css alone matches no file, because the app keeps it under an inner notices/ folder. That inner folder is what keeps two apps' files apart: without it, the first app's file would win.

The program below asks the finders about five names and prints, relative to the project, the file each one found:

Which file each static name finds Python · nivas/find_static.py
"""Where the static files of Nivas are found, and the URL the static tag builds for them while you develop.

Run it next to manage.py. The finders look in STATICFILES_DIRS first, then in each installed app's static/ folder.
"""

import os
from pathlib import Path

os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings')

import django  # noqa: E402

django.setup()

from django.conf import settings  # noqa: E402
from django.contrib.staticfiles import finders  # noqa: E402
from django.template import Context, Template  # noqa: E402


def where(path):
    path = Path(path)
    if path.is_relative_to(settings.BASE_DIR):
        return str(path.relative_to(settings.BASE_DIR))
    return 'django/' + str(path.relative_to(Path(django.__file__).parent))


for name in ['css/base.css', 'img/logo.svg', 'notices/notices.css', 'admin/css/base.css', 'notices.css']:
    found = finders.find(name)
    print(f'{name:21} -> {where(found) if found else "not found"}')

print('Folders searched for the last name:')
for folder in finders.searched_locations:
    print('   ', where(folder))

template = Template("{% load static %}{% static 'css/base.css' %} {% static 'notices/notices.css' %}")
print('The static tag with DEBUG = True:', template.render(Context()))

Output

css/base.css          -> static/css/base.css
img/logo.svg          -> static/img/logo.svg
notices/notices.css   -> notices/static/notices/notices.css
admin/css/base.css    -> django/contrib/admin/static/admin/css/base.css
notices.css           -> not found
Folders searched for the last name:
    static
    django/contrib/admin/static
    notices/static
The static tag with DEBUG = True: /static/css/base.css /static/notices/notices.css

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 find_static.py

The project’s folder came first, then the apps in the order of INSTALLED_APPS: the admin’s, which holds the admin’s own admin/css/base.css, and the notices app’s. notices.css alone found nothing, because the app keeps it inside notices/. That inner folder is the point: names from every app share one space, and Django’s guide warns that with two files of one name, Django uses the first it finds and cannot tell them apart.

The same question can be asked from the command line: python manage.py findstatic notices/notices.css runs these finders and lists every file the name finds, the winner first; --first prints only the winner.

An app's files directly in its static folder

notices/static/style.css takes the name style.css from every other app’s style.css, and whichever app comes first in INSTALLED_APPS wins. Put it at notices/static/notices/style.css and ask for notices/style.css.

The static tag builds the URL, so templates never write one

A template never writes /static/… itself. {% load static %} and then {% static 'css/base.css' %} asks Django for the URL: with the development settings that is STATIC_URL and the name, /static/css/base.css, as the last line of the run above printed. The base template of Nivas links the project’s stylesheet this way, and the notices list adds the app’s own in a block:

The static tag in the base template and in the notices list

templates/base.html · nivas/templates/base.html

{% load static %}
<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <title>{% block title %}Nivas{% endblock %}</title>
  <link rel="stylesheet" href="{% static 'css/base.css' %}">
  {% block extra_head %}{% endblock %}
</head>
<body>
  <header><a href="{% url 'notices:list' %}">Nivas</a></header>
  <main>
    {% block content %}{% endblock %}
  </main>
</body>
</html>

nivas/notices/templates/notices/notice_list.html

{% extends "base.html" %}
{% load nivas_extras static %}

{% partialdef notice-card %}
  <li id="notice-{{ notice.pk }}">
    <h2>{{ notice.title }}</h2>
    {% resident_badge notice.posted_by notice.flat %}
    {% if notice.fee_paise %}<p>Fee: {{ notice.fee_paise|paise }}</p>{% endif %}
  </li>
{% endpartialdef %}

{% block title %}Notices · {{ block.super }}{% endblock %}

{% block extra_head %}<link rel="stylesheet" href="{% static 'notices/notices.css' %}">{% endblock %}

{% block content %}
  <h1>Notices</h1>
  {% fee_summary page_obj %}
  <ul id="notices">
    {% for notice in page_obj %}
      {% partial notice-card %}
    {% empty %}
      <li>No notices match these filters.</li>
    {% endfor %}
  </ul>
{% endblock %}

The tag matters most once the URL stops being that simple. With the production storage of a later section, the same tag writes /static/css/base.902a4c163d8a.css, and with a CDN it would write the CDN’s address: every template keeps working without a change, which is the same rule as building links from route names. A stylesheet cannot use the tag, so base.css finds its logo with a relative url("../img/logo.svg"), and collectstatic takes care of it.

Key idea

A static file has a name and the storage gives it a URL: keep each app’s files under static/<app>/, and build every URL with the static tag.

Checkpoint

Check: Static folders and the finders · URLs from the static tag

  1. Question 1 of 2 Two apps both keep a file at static/style.css, without an inner folder. A template uses {% static 'style.css' %}. Which file is served?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: The file of whichever app comes first in INSTALLED_APPS, with no warning

    Static file names share one space, like template names. notices/static/notices/notices.css is reached as notices/notices.css, a name no other app can take by accident.

  2. Question 2 of 2 With STATIC_URL = 'static/' and DEBUG on, what does {% static 'css/base.css' %} write into the page?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: /static/css/base.css

    The static tag builds each URL from STATIC_URL and the storage, so a change of prefix, a CDN or hashed names reach every page without editing a template.

runserver serves static files only while DEBUG is on

While you develop, nothing needs collecting: runserver finds each file in its app’s folder and sends it. But it does so only while DEBUG is True, and Django’s guide is blunt about why it stops there: the method “is grossly inefficient and probably insecure, so it is unsuitable for production”. This program asks runserver’s own command which handler it would use, without starting a server, and sends it a request for the stylesheet:

runserver and a stylesheet, with DEBUG on and off Python · nivas/runserver_static.py
"""runserver serves static files only while DEBUG is True. This asks runserver's own command for the handler it
would use, without starting a server, and sends it a request for a stylesheet."""

import io
import os
import sys

os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings')

import django  # noqa: E402

django.setup()

from django.contrib.staticfiles.management.commands.runserver import Command  # noqa: E402
from django.test import override_settings  # noqa: E402


def get(handler, path):
    environ = {'REQUEST_METHOD': 'GET', 'PATH_INFO': path, 'SERVER_NAME': 'localhost', 'SERVER_PORT': '8000',
               'wsgi.url_scheme': 'http', 'wsgi.input': io.BytesIO(), 'wsgi.errors': sys.stderr, 'HTTP_HOST': 'localhost'}
    status = []
    body = handler(environ, lambda line, headers: status.append(line))
    if hasattr(body, 'close'):
        body.close()
    return status[0]


for debug in [True, False]:
    with override_settings(DEBUG=debug, ALLOWED_HOSTS=['localhost']):
        handler = Command().get_handler(use_static_handler=True, insecure_serving=False)
        print(f'DEBUG = {debug}: runserver uses {type(handler).__name__}')
        print('    GET /static/css/base.css ->', get(handler, '/static/css/base.css'))

Output

DEBUG = True: runserver uses StaticFilesHandler
    GET /static/css/base.css -> 200 OK
DEBUG = False: runserver uses WSGIHandler
    GET /static/css/base.css -> 404 Not Found

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 runserver_static.py

With DEBUG = True, runserver wraps the project in StaticFilesHandler, which answers /static/… before the URLconf is tried, and the stylesheet came back 200. With DEBUG = False, it uses the plain WSGIHandler, the URLconf has no static route, and the answer is 404. That is why a page loses its styles when you switch DEBUG off on your own computer, and why production needs another way to serve the files.

collectstatic copies everything and names each file by its content

A production server should not search the folders of every app on each request. python manage.py collectstatic gathers every static file of the project and its apps into one folder, STATIC_ROOT: The folder into which collectstatic copies the static files of the project and of every app, for the production server to serve. Nobody edits files in it by hand; the next collectstatic writes it again., staticfiles/ in Nivas, for the server to send. The storage in the staticfiles key of the STORAGES setting decides how it copies them. Nivas’s production settings use WhiteNoise’s version of manifest storage: ManifestStaticFilesStorage and the storages built on it: collectstatic writes a copy of each file whose name holds a hash of its content, and staticfiles.json, the manifest the static tag reads.:

config/production.py: the static-file settings of production Python · nivas/config/production.py
"""Production settings for static files. The deployment module of this track adds the rest: secrets from the
environment, HTTPS and the mailer."""

from .settings import *  # noqa: F403

DEBUG = False
ALLOWED_HOSTS = ['nivas.example']

# WhiteNoise serves the collected files from the Django process itself: right after SecurityMiddleware.
MIDDLEWARE = [MIDDLEWARE[0], 'whitenoise.middleware.WhiteNoiseMiddleware', *MIDDLEWARE[1:]]  # noqa: F405

STORAGES = {
    'default': {'BACKEND': 'django.core.files.storage.FileSystemStorage'},
    # Copies with the content's hash in their names, a manifest, and a gzip copy of each file that shrinks.
    'staticfiles': {'BACKEND': 'whitenoise.storage.CompressedManifestStaticFilesStorage'},
}

This program runs collectstatic with those settings and shows what it wrote:

collectstatic with manifest storage, and what it wrote Python · nivas/collect.py
"""collectstatic with the production settings of Nivas: every static file copied into STATIC_ROOT, with a copy whose
name holds a hash of its content, a manifest and a gzip copy. Paths are printed relative to the project.

Run it next to manage.py.
"""

import io
import json
import os
from pathlib import Path

os.environ['DJANGO_SETTINGS_MODULE'] = 'config.production'

import django  # noqa: E402

django.setup()

from django.conf import settings  # noqa: E402
from django.core.management import call_command  # noqa: E402
from django.template import Context, Template  # noqa: E402

out = io.StringIO()
call_command('collectstatic', interactive=False, verbosity=1, stdout=out)
print('$ python manage.py collectstatic --noinput --settings=config.production')
print(out.getvalue().strip().replace(str(settings.STATIC_ROOT), 'staticfiles'))

root = Path(settings.STATIC_ROOT)
ours = sorted(p for p in root.rglob('*') if p.is_file() and p.relative_to(root).parts[0] in {'css', 'img', 'notices'})
print('The files of Nivas in staticfiles/:')
for path in ours:
    print('   ', path.relative_to(root))

manifest = json.loads((root / 'staticfiles.json').read_text())
print(f'staticfiles.json maps {len(manifest["paths"])} names, among them:')
for name in ['css/base.css', 'img/logo.svg', 'notices/notices.css']:
    print(f'    {name} -> {manifest["paths"][name]}')

hashed_css = root / manifest['paths']['css/base.css']
print('The url() inside the hashed base.css now names the hashed logo:')
print('   ', next(line.strip() for line in hashed_css.read_text().splitlines() if 'url(' in line))

template = Template("{% load static %}{% static 'css/base.css' %}")
print('The static tag with these settings:', template.render(Context()))

Output

$ python manage.py collectstatic --noinput --settings=config.production
133 static files copied to 'staticfiles', 397 post-processed.
The files of Nivas in staticfiles/:
    css/base.902a4c163d8a.css
    css/base.902a4c163d8a.css.gz
    css/base.css
    css/base.css.gz
    img/logo.13d8e578b301.svg
    img/logo.svg
    notices/notices.css
    notices/notices.css.gz
    notices/notices.da2d22682ee6.css
    notices/notices.da2d22682ee6.css.gz
staticfiles.json maps 133 names, among them:
    css/base.css -> css/base.902a4c163d8a.css
    img/logo.svg -> img/logo.13d8e578b301.svg
    notices/notices.css -> notices/notices.da2d22682ee6.css
The url() inside the hashed base.css now names the hashed logo:
    background: url("../img/logo.13d8e578b301.svg") no-repeat 1rem center / 1.5rem;
The static tag with these settings: /static/css/base.902a4c163d8a.css

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 collect.py

It copied 133 files, the admin’s and the three of Nivas, and post-processed 397: for each file whose content it read, it wrote a second copy whose name holds an MD5 hash of the content, base.902a4c163d8a.css, and a gzip copy of each file that compression made smaller. staticfiles.json, the manifest, maps every plain name to its hashed one, and the hashed base.css names the hashed logo, because the storage rewrote its url(). The static tag now reads the manifest and writes /static/css/base.902a4c163d8a.css.

The hash is what makes long caching safe. The same content always gives the same name, and changed content always gives a new name, so a browser that keeps a hashed file forever can never show a stale version.

A missing file stops the page

With manifest storage, the static tag raises ValueError for a name missing from the manifest, so a template that names a file nobody added fails with a server error. Run collectstatic as a release step, before the new code takes requests, and render the main pages in a test with the production storage.

Checkpoint

Check: Static files while you develop · collectstatic and hashed names

  1. Question 1 of 2 A developer sets DEBUG = False on their laptop to try the error pages, and the site loses all its styles. Why?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: runserver serves static files only while DEBUG is True, so /static/… now gets a 404

    Development serving is a convenience that ends with DEBUG. Django's guide calls it grossly inefficient and probably insecure, so production serves the collected files another way.

  2. Question 2 of 2 After collectstatic with manifest storage, base.css is also stored as base.902a4c163d8a.css. What does 902a4c163d8a depend on?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: The file's content, so it changes exactly when the content changes

    A name derived from the content never points at two versions of a file, so it can be cached forever, and a new version always gets a new name that no browser has cached.

In production, WhiteNoise, a web server or a CDN serves the files

Something has to send the files of STATIC_ROOT. WhiteNoise: A Python package that serves a Django project's collected static files from the Django process itself, with compressed copies and long cache headers for files whose names hold a hash. does it from the Django process itself: its middleware, right after SecurityMiddleware in the production settings above, answers requests for /static/… before the rest of Django runs. This program collects the files, as a release would, and requests the stylesheet the way a browser does:

The headers WhiteNoise sends for hashed and plain names Python · nivas/whitenoise_headers.py
"""WhiteNoise serving the collected files from the Django process: the headers a browser gets for a hashed file,
for the same file under its plain name, and for a name that does not exist.

Run it next to manage.py. It runs collectstatic first, quietly, as a release step would.
"""

import logging
import os

os.environ['DJANGO_SETTINGS_MODULE'] = 'config.production'

import django  # noqa: E402

django.setup()

from django.core.management import call_command  # noqa: E402
from django.templatetags.static import static  # noqa: E402
from django.test import Client  # noqa: E402

call_command('collectstatic', interactive=False, verbosity=0)
logging.getLogger('django.request').setLevel(logging.CRITICAL)
client = Client(HTTP_HOST='nivas.example')

hashed = static('css/base.css')
for path, encoding in [(hashed, 'gzip'), (hashed, None), ('/static/css/base.css', None), ('/static/css/missing.css', None)]:
    extra = {'HTTP_ACCEPT_ENCODING': encoding} if encoding else {}
    response = client.get(path, **extra)
    print(f'GET {path}' + (f'  (Accept-Encoding: {encoding})' if encoding else ''))
    print(f'    {response.status_code} {response.reason_phrase}')
    for name in ['Content-Type', 'Cache-Control', 'Content-Encoding', 'Vary']:
        if response.has_header(name):
            print(f'    {name}: {response[name]}')

Output

GET /static/css/base.902a4c163d8a.css  (Accept-Encoding: gzip)
    200 OK
    Content-Type: text/css; charset="utf-8"
    Cache-Control: max-age=315360000, public, immutable
    Content-Encoding: gzip
    Vary: Accept-Encoding
GET /static/css/base.902a4c163d8a.css
    200 OK
    Content-Type: text/css; charset="utf-8"
    Cache-Control: max-age=315360000, public, immutable
    Vary: Accept-Encoding
GET /static/css/base.css
    200 OK
    Content-Type: text/css; charset="utf-8"
    Cache-Control: max-age=60, public
    Vary: Accept-Encoding
GET /static/css/missing.css
    404 Not Found
    Content-Type: text/html; charset=utf-8

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 whitenoise_headers.py

The hashed name came back with Cache-Control: max-age=315360000, public, immutable: 315,360,000 seconds is ten years, and immutable, from RFC 8246, says the file will not change, so the browser need not even check. A browser that accepts gzip got the compressed copy, with Content-Encoding: gzip and Vary: Accept-Encoding for caches. The plain name got max-age=60, because its content may change under the same name. Step through a release, two visits and the next release:

Interactive · message by message Hashed names let a browser keep a file for years Step through a release, a first visit, a second visit and the next release that changes the stylesheet.
Hashed names let a browser keep a file for years, step 6 of 6. Messages so far: The release to Django and WhiteNoise: 133 files and a manifest; Browser to Django and WhiteNoise: GET base.902a4c163d8a.css; Django and WhiteNoise to Browser: 200, immutable; The release to Django and WhiteNoise: base.<new hash>.css.The releasecollectstaticDjango and WhiteNoisea new name, a new linkBrowserkeeps base.902a…css133 files and a manifestGET base.902a4c163d8a.css200, immutablebase.<new hash>.cssthe old copy is never linked again

The drawing shows the whole scene; its 6 steps are listed below.

Every step as text
  1. Copies with a hash. collectstatic copies every file into STATIC_ROOT, writes a copy whose name holds a hash of its content, css/base.902a4c163d8a.css, a gzip copy beside it, and staticfiles.json, the manifest.
  2. The page names the hashed file. A page renders {% static 'css/base.css' %}. The storage looks the name up in the manifest, so the link is /static/css/base.902a4c163d8a.css.
  3. The first visit. The browser asks for the hashed file. WhiteNoise answers it from the Django process, gzip-compressed, with Cache-Control: max-age=315360000, public, immutable.
  4. Kept for ten years. 315,360,000 seconds is ten years, and immutable says the file will never change under this name, so the browser stores it and does not even ask again.
  5. The second visit. On the next page the browser finds the same name in its cache and uses it without a request. The plain name, css/base.css, would have been cached for 60 seconds only.
  6. The next release. A change to base.css changes its content, so collectstatic gives it a new hash and the pages link the new name. The browser has never seen that name and fetches it at once.

WhiteNoise is one of three common ways. Django’s deployment guide describes the other two: a web server such as nginx or Apache serving STATIC_ROOT itself, which most larger sites do with a server of its own, and a cloud storage service with a CDN: A content delivery network: servers in many places that keep copies of a site's files and answer each visitor from one near them., to which collectstatic uploads the files through a storage backend. In the 2026 Django Developers Survey, 50 % of respondents served static files with nginx or Apache, 32 % with cloud storage and a CDN and 31 % with WhiteNoise. Which fits depends on your site:

Interactive · answer and compare Which way of serving static files fits your site? Answer three questions about your site. The table marks the ways that fit, and the cells that decide.
Do you run your own server, with a web server such as nginx in front of Django?
Are many of your visitors far from where the site runs, on another continent?
Is the site small, with one server and no one to look after more infrastructure?

Answer the questions above: the table then marks what fits your case and says why the rest does not.

WhiteNoise, a web server and a CDN against four things a production site needs from its static files
Served by Work to set up Visitors far away Load on Django Fits a managed platform
WhiteNoise in Django Good one middleware line and one storage setting; compression and cache headers included Mixed every file comes from your own server, unless a CDN is put in front of it Mixed the Django process sends the files, cheaply, since browsers keep hashed files for years Good needs nothing outside Python, so it works where you cannot run a web server
nginx or Apache Mixed a web server to run and configure, with its own cache headers and compression Mixed files come from your own machine, so a reader far away waits for every first visit Good Django never sees a static request: the web server answers from STATIC_ROOT Poor needs a server whose web server you control
Cloud storage and a CDN Poor a storage backend for collectstatic, a bucket, a CDN and its settings Good files come from an edge close to each reader Good neither Django nor your server sends a static file Good works anywhere collectstatic can upload to the storage service
How the answers decide
  • “Do you run your own server, with a web server such as nginx in front of Django?” No, a managed platform runs the app: rules out nginx or Apache. A managed platform runs your app process and nothing in front of it that you configure, so there is no web server of yours to serve STATIC_ROOT.
  • “Are many of your visitors far from where the site runs, on another continent?” Yes, they are spread worldwide: adds a caveat to WhiteNoise in Django and nginx or Apache. From your own server, every first visit waits for the round trips to it. A CDN in front of WhiteNoise, or cloud storage behind a CDN, serves from an edge near the reader.
  • “Is the site small, with one server and no one to look after more infrastructure?” Yes, keep it to the app: adds a caveat to Cloud storage and a CDN. A bucket and a CDN are two more services to set up, secure and pay for; WhiteNoise keeps static files inside the app you already deploy.

In short: WhiteNoise when the app runs on a platform without a web server of your own, or when one server is all you want to run; the web server you already run when you have one; and a CDN, in front of either, when readers are far away.

Checkpoint

Check: Serving static files in production

  1. One question What Cache-Control did WhiteNoise send for the hashed file css/base.902a4c163d8a.css?

    Choose one answer.

    Show the answer: One question

    Answer: max-age=315360000, public, immutable

    WhiteNoise treats a file whose name holds a hash as immutable and lets browsers keep it for ten years; files without one get a short max-age.

Uploads are media files, not static files

A resident’s photo of a broken lift is a file too, but not a static one. media files: Files that users upload, such as photos and documents. Django keeps them in MEDIA_ROOT under MEDIA_URL, apart from static files, because they need their own checks and permissions. come from users while the site runs, so they need size limits, type checks and permissions, and they must never be served as if they were the site’s own code. Django keeps them in MEDIA_ROOT, under MEDIA_URL, and refuses settings that mix the two kinds:

Settings that mix media and static files Python · media_vs_static.py
"""Uploads are media, not static files. Django refuses settings that would mix the two folders or the two URLs."""

import django
from django.conf import settings

settings.configure(
    INSTALLED_APPS=['django.contrib.staticfiles'],
    STATIC_URL='static/',
    STATIC_ROOT='/srv/nivas/staticfiles',
    MEDIA_URL='media/',
    MEDIA_ROOT='/srv/nivas/media',
)
django.setup()

from django.contrib.staticfiles.storage import StaticFilesStorage  # noqa: E402
from django.core.exceptions import ImproperlyConfigured  # noqa: E402
from django.test import override_settings  # noqa: E402

print('STATIC_ROOT and MEDIA_ROOT apart:', StaticFilesStorage().location)
for name, changes in [('MEDIA_ROOT = STATIC_ROOT', {'MEDIA_ROOT': '/srv/nivas/staticfiles'}),
                      ('MEDIA_URL = STATIC_URL', {'MEDIA_URL': 'static/'})]:
    with override_settings(**changes):
        try:
            StaticFilesStorage()
        except ImproperlyConfigured as error:
            print(f'{name}: ImproperlyConfigured: {error}')

Output

STATIC_ROOT and MEDIA_ROOT apart: /srv/nivas/staticfiles
MEDIA_ROOT = STATIC_ROOT: ImproperlyConfigured: The MEDIA_ROOT and STATIC_ROOT settings must have different values
MEDIA_URL = STATIC_URL: ImproperlyConfigured: The MEDIA_URL and STATIC_URL settings must have different values

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 media_vs_static.py

Both mixes stopped with ImproperlyConfigured: the two folders must differ, and so must the two URLs. Keeping them apart also keeps collectstatic, which rewrites STATIC_ROOT on every release, away from residents’ files. The file uploads lesson of this track stores and checks uploads.

Checkpoint

Check: Media files are not static files

  1. One question To keep the setup simple, a project sets MEDIA_ROOT to the same folder as STATIC_ROOT. What happens?

    Choose one answer.

    Show the answer: One question

    Answer: Django refuses the settings with ImproperlyConfigured, because the two folders must differ

    Static files come from your code and can be cached for years; uploads come from users, need size checks and permissions, and must never be mixed with your own files.

Practise: the manifest lookup

The static tag’s lookup is small enough to write yourself. Write resolve_static(name, manifest, strict=True): the hashed name for a name, its query string and fragment kept, and Django’s own error, or the name unchanged, for a name the manifest does not have. Its six sample tests use the hashes of the recorded run, and they run in your browser:

Exercise · Easy · Python

Find a file's hashed name in the manifest

With manifest storage, the static tag looks every name up in staticfiles.json, the manifest that collectstatic wrote, and links the hashed copy. Write resolve_static(name, manifest, strict=True), which does that lookup:

  • manifest is the "paths" part of the manifest: a dictionary from a file's name to its hashed name, such as {'css/base.css': 'css/base.902a4c163d8a.css'}.
  • name is what a template passes to the static tag, such as 'css/base.css'. Return its hashed name.
  • A name may end in a query string, a fragment or both, as in 'img/icons.svg#lift'. Look up the path alone and keep what follows it: 'css/base.css?v=2' gives 'css/base.902a4c163d8a.css?v=2'.
  • A name the manifest does not have raises ValueError with the message Django uses, Missing staticfiles manifest entry for 'css/missing.css', which names the path without its query or fragment. With strict=False, return the name unchanged instead.

Starter code · resolve.py

def resolve_static(name, manifest, strict=True):
    """The hashed name of a static file, from the manifest that collectstatic wrote."""
    return manifest[name]
The sample tests · test_resolve.py
from resolve import resolve_static

MANIFEST = {
    'css/base.css': 'css/base.902a4c163d8a.css',
    'img/logo.svg': 'img/logo.13d8e578b301.svg',
    'notices/notices.css': 'notices/notices.da2d22682ee6.css',
    'admin/css/base.css': 'admin/css/base.6398cd16ee3d.css',
}


def test_a_name_in_the_manifest():
    assert resolve_static('css/base.css', MANIFEST) == 'css/base.902a4c163d8a.css'


def test_names_with_the_same_file_name_in_other_folders():
    assert resolve_static('admin/css/base.css', MANIFEST) == 'admin/css/base.6398cd16ee3d.css'
    assert resolve_static('notices/notices.css', MANIFEST) == 'notices/notices.da2d22682ee6.css'


def test_a_missing_name_raises_with_djangos_message():
    try:
        resolve_static('css/missing.css', MANIFEST)
    except ValueError as error:
        assert str(error) == "Missing staticfiles manifest entry for 'css/missing.css'"
    else:
        raise AssertionError('a missing name must raise ValueError')


def test_a_missing_name_stays_as_it_is_when_not_strict():
    assert resolve_static('css/missing.css', MANIFEST, strict=False) == 'css/missing.css'


def test_the_query_string_and_the_fragment_are_kept():
    assert resolve_static('css/base.css?v=2', MANIFEST) == 'css/base.902a4c163d8a.css?v=2'
    assert resolve_static('img/logo.svg#mark', MANIFEST) == 'img/logo.13d8e578b301.svg#mark'
    assert resolve_static('img/logo.svg?x=1#mark', MANIFEST) == 'img/logo.13d8e578b301.svg?x=1#mark'


def test_the_message_names_the_path_alone():
    try:
        resolve_static('css/missing.css?v=2#top', MANIFEST)
    except ValueError as error:
        assert str(error) == "Missing staticfiles manifest entry for 'css/missing.css'"
    else:
        raise AssertionError('a missing name must raise ValueError')
    assert resolve_static('css/missing.css?v=2#top', MANIFEST, strict=False) == 'css/missing.css?v=2#top'
A hint

urllib.parse.urlsplit(name) splits a name into parts: .path is the file's name, and ._replace(path=…) with urlunsplit() puts a new path back in front of the same query and fragment.

The sample tests run on this device, in your browser (Pyodide): nothing is sent to mysmartcopilot.com. The first run downloads Python (about 13.5 MB), which is kept for the next runs. A check in your browser is feedback for you, not proof that the code is right for every input.

Hash Generator Make an MD5 hash of your own text, the kind of hash that names a collected file.

Then fourteen questions on static files, from folders and finders to media, the weakest idea first, each explained as soon as you answer:

Practice that adapts to you

Practise this lesson

14 questions about 6 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.

  1. Question 1 of 14 Where does the notices app keep its stylesheet notices.css?

    Choose one answer.

    Show the answer: Question 1 of 14

    Answer: notices/static/notices/notices.css

    An app's files live in its static/<app>/ folder, the project's own files in the folders STATICFILES_DIRS lists, and both are found by name.

  2. Question 2 of 14 The project's static/ folder, listed in STATICFILES_DIRS, and the admin app both have admin/css/base.css. Which one does collectstatic copy?

    Choose one answer.

    Show the answer: Question 2 of 14

    Answer: The project's, because STATICFILES_DIRS is searched before the apps

    Overriding works the same way for static files as for templates: the project's folders come first, then each app in INSTALLED_APPS order.

  3. Question 3 of 14 A stylesheet does not load and you suspect the wrong file is found. Which command shows the file Django uses for a name?

    Choose one answer.

    Show the answer: Question 3 of 14

    Answer: python manage.py findstatic css/base.css

    findstatic and the finders API, as in find_static.py, answer "which file is this name?" without a browser.

  4. Question 4 of 14 Why write {% static 'css/base.css' %} rather than /static/css/base.css in a template?

    Choose one answer.

    Show the answer: Question 4 of 14

    Answer: The tag asks the storage for the URL, so hashed names, another prefix or a CDN reach every page

    Building URLs with the tag keeps them in one place: STATIC_URL and the storage. The same rule as reversing URLs from route names.

  5. Question 5 of 14 base.css uses url("../img/logo.svg"). After collectstatic with manifest storage, what does the hashed copy of base.css contain?

    Choose one answer.

    Show the answer: Question 5 of 14

    Answer: url("../img/logo.13d8e578b301.svg"), rewritten to the logo's hashed name

    CSS files cannot use the static tag, so manifest storage rewrites their references to other static files during collectstatic, which keeps relative url() paths correct.

  6. Question 6 of 14 What does Django's guide say about serving static files with runserver?

    Choose one answer.

    Show the answer: Question 6 of 14

    Answer: It is grossly inefficient and probably insecure, so unsuitable for production

    runserver itself is a development server, and its static serving is a convenience that switches off with DEBUG.

  7. Question 7 of 14 With DEBUG = True, which handler did runserver's command choose, and why does that matter?

    Choose one answer.

    Show the answer: Question 7 of 14

    Answer: StaticFilesHandler, which answers /static/… itself before Django's URLconf is tried

    Development serving is a wrapper around the WSGI handler that runserver adds only while DEBUG is on, so nothing in your URLconf mentions static files.

  8. Question 8 of 14 In the recorded collectstatic run of Nivas, how many static files were copied into STATIC_ROOT?

    Type a number.

    A hint

    Read the summary line that collectstatic printed.

    Show the answer: Question 8 of 14

    Answer: 133 files

    collectstatic printed "133 static files copied to 'staticfiles', 397 post-processed": the admin's files and the three of Nivas, each with hashed and compressed copies made in post-processing.

  9. Question 9 of 14 A template uses {% static 'css/print.css' %}, a file someone forgot to add. With manifest storage in production, what happens?

    Choose one answer.

    Show the answer: Question 9 of 14

    Answer: The page fails with ValueError: Missing staticfiles manifest entry for 'css/print.css'

    Strict manifest storage turns a missing static file into a loud error. Rendering the main pages in a test with the production storage catches it before the release.

  10. Question 10 of 14 A release copies the new code to the servers, restarts Django, and only then runs collectstatic. Pages that changed fail for a minute. Why, and what is the fix?

    Choose one answer.

    Show the answer: Question 10 of 14

    Answer: The new templates look up names the old manifest does not have; run collectstatic before the new code starts serving

    Collect the files as a build step, before the new version takes traffic. The deployment module of this track puts it in the release steps.

  11. Question 11 of 14 The browser sent Accept-Encoding gzip for the hashed stylesheet. What did WhiteNoise answer?

    Choose one answer.

    Show the answer: Question 11 of 14

    Answer: The gzip copy collectstatic made, with Content-Encoding gzip and Vary Accept-Encoding

    Compressed copies are made at release time; WhiteNoise picks the copy each browser accepts and says so in Vary, so caches keep the two apart.

  12. Question 12 of 14 Nivas runs on a managed platform that starts the Django process and nothing else, and most residents live in one city. Which way of serving static files fits best?

    Choose one answer.

    Show the answer: Question 12 of 14

    Answer: WhiteNoise, which needs nothing but the app; a CDN can come later

    Choose by where the site runs and where its readers are. The trade-off table above answers the same three questions for your own site.

  13. Question 13 of 14 Residents upload photos of a broken lift. Where do the photos belong?

    Choose one answer.

    Show the answer: Question 13 of 14

    Answer: In MEDIA_ROOT, served under MEDIA_URL, apart from the static files

    Media files come from users at run time; static files come from your code at release time. The file uploads lesson of this track covers storing and checking uploads.

  14. Question 14 of 14 Why does Django refuse MEDIA_URL = STATIC_URL?

    Choose one answer.

    Show the answer: Question 14 of 14

    Answer: An upload could then take the address of one of your own static files, or be served as if it were trusted

    Separate folders and separate URLs make it impossible for an upload to stand in for a file of the site, and let each kind get the caching and the checks it needs.

Interview questions

Warm-up (fresher): What does collectstatic do, and when do you run it?

python manage.py collectstatic copies the static files of the project’s STATICFILES_DIRS and of every installed app’s static/ folder into one folder, STATIC_ROOT, from which the production server sends them. When two files have the same name, the first one the finders find is copied. With manifest storage it also writes copies whose names hold a hash of their content, and the manifest staticfiles.json. You run it as part of every release, before the new code starts serving requests, and not while you develop, because runserver serves the app folders directly while DEBUG is on.

Two design questions of this module start from these files: a release that leaves some users with the old stylesheet, and the choice of how to serve static files as a site grows.

Model answers open with any pass: ₹79 for 1 week ($2.99), one payment that never renews.

  1. Fresher to senior

    After a release, some residents see the new pages with the old stylesheet for hours, and for one minute every page with a changed template failed with a server error. Explain both problems and change the release so neither can happen again.

    Model answer, Pro. Model answers open with any pass; prices are in the notice on this page. First step and follow-ups

  2. Mid to senior

    Nivas serves 2 million page views a month from one server in India, and a first visit loads 3 MB of static files. It now signs up societies in Europe and South-East Asia. How would you serve static files now, and what changes as it grows? Use numbers.

    Model answer, Pro. First step and follow-ups

Key takeaways

  • Keep an app’s static files under static/<app>/ and the project’s in the folders STATICFILES_DIRS lists; the finders search those first, then each app in INSTALLED_APPS order, and the first file found wins.
  • Build every URL with {% static %}, which asks the storage, so prefixes, hashed names and CDNs reach every page.
  • runserver serves static files only while DEBUG is True, a method Django’s guide calls unsuitable for production.
  • collectstatic copies everything into STATIC_ROOT; manifest storage adds copies named by a hash of their content and staticfiles.json, so hashed files can be cached for years and a missing name raises ValueError.
  • WhiteNoise serves the collected files from Django with ten-year immutable headers for hashed names and gzip copies; a web server or a CDN takes the work off Django where you run one or where readers are far away.
  • Uploads are media files, kept in MEDIA_ROOT under MEDIA_URL, which Django refuses to share with the static ones.

Terms in this lesson

CDN
A content delivery network: servers in many places that keep copies of a site's files and answer each visitor from one near them.
manifest storage
ManifestStaticFilesStorage and the storages built on it: collectstatic writes a copy of each file whose name holds a hash of its content, and staticfiles.json, the manifest the static tag reads.
media files
Files that users upload, such as photos and documents. Django keeps them in MEDIA_ROOT under MEDIA_URL, apart from static files, because they need their own checks and permissions.
static files
Files a site serves as they are, the same for every visitor, such as stylesheets, scripts, fonts and the images of the layout. In Django they live in static/ folders and collectstatic gathers them.
STATIC_ROOT
The folder into which collectstatic copies the static files of the project and of every app, for the production server to serve. Nobody edits files in it by hand; the next collectstatic writes it again.
WhiteNoise
A Python package that serves a Django project's collected static files from the Django process itself, with compressed copies and long cache headers for files whose names hold a hash.

References

Related tools

Report a problem with this lesson

Quick answers and tool search

Type to search tools or to get a quick answer, for example 18% of 2500. Use the up and down arrow keys to move through the results, Enter to choose, and Escape to close.