Filter concepts by levelShowing all levels.

Django · Section 43

Static Files

Level
intermediate
Read
18 min
Concepts
2

STATIC_URL is a URL prefix, never a filesystem path; STATICFILES_DIRS adds extra source directories to search beyond each app's own static/ folder; STATIC_ROOT is the single, regenerated destination collectstatic copies every static file into for deployment — never used directly in development, where django.contrib.staticfiles serves files live via the finders instead. In production, ManifestStaticFilesStorage renames each file to include a content hash, which is what makes a far-future Cache-Control header safe: the URL changes when the content does, so there's no stale-cache window. WhiteNoise serves hashed, compressed static files efficiently from within the Django process itself, appropriate until traffic justifies a real CDN pushing static serving out to the edge — neither choice changes the hashing strategy underneath, since they answer different questions (what serves the bytes vs. how a cache is correctly invalidated).

This section

What is true here

  1. STATIC_URL is a URL prefix; STATICFILES_DIRS adds source directories; STATIC_ROOT is the single, regenerated collectstatic destination — three distinct roles, never overlapping.
  2. Development serves static files live via the finders; production serves only whatever STATIC_ROOT holds after the last collectstatic run.
  3. ManifestStaticFilesStorage content-hashes filenames, making a far-future Cache-Control header safe — the URL changes whenever the content does.
  4. WhiteNoise serves static files efficiently from within the Django process with no separate infrastructure; a CDN offloads serving to the edge at higher scale.

What you will be able to do

  • Configure STATIC_URL/STATICFILES_DIRS/STATIC_ROOT correctly, without conflating source and destination directories
  • Run collectstatic as a real deploy step and understand why development doesn't need it
  • Choose and configure a hashed-filename storage strategy that makes aggressive caching safe
  • Decide between WhiteNoise and a CDN based on actual traffic and infrastructure needs

Static settings, finders, and collectstatic

The three core settings, how finders locate files, and the collectstatic pipeline.

STATIC_URL, STATIC_ROOT, STATICFILES_DIRS, finders, and collectstatic

coreintermediate

STATIC_URL is the URL PREFIX static files are served under (e.g. "/static/") — never a filesystem path. STATICFILES_DIRS lists extra directories (beyond each app's own static/ folder) to also search for static files, typically a project-wide static/ directory. STATIC_ROOT is the single directory collectstatic copies EVERY static file into for deployment — never used in development, and never itself a source directory. Static finders are the pluggable lookup system (FileSystemFinder for STATICFILES_DIRS, AppDirectoriesFinder for each app's static/) that collectstatic (and the dev server's runserver static handling) uses to locate files across all these sources. collectstatic is the management command that runs the finders and copies everything they find into STATIC_ROOT, ready to be served by a real web server or CDN in production.

Think of it as

Static files in Django are deliberately split into "where they live during development" (scattered across every app's own static/ directory, plus STATICFILES_DIRS) versus "where they live in production" (one single STATIC_ROOT directory) — because development wants files organized per-app for maintainability, while production wants one flat, deployable directory a web server or CDN can serve directly without any Django-specific logic at request time. Finders are the abstraction that makes this split possible: rather than collectstatic (or the dev server) hardcoding "look in every app's static/ folder," it asks each configured finder to report what it can find, which is also why a custom finder (checking a CMS-managed directory, say) can participate in the exact same collection process. STATIC_URL being a URL prefix rather than a path is the other half of the same decoupling — application code and templates reference static files by URL ({% static "app.css" %}), never by filesystem location, so where those files physically live (STATIC_ROOT locally, an S3 bucket, a CDN origin) can change without touching a single template.

bash
python manage.py collectstatic

What we're doing: Configure static files for a project with both app-level and project-wide static assets, ready for a production deploy.

settings.pypython
STATIC_URL = "/static/"
STATICFILES_DIRS = [BASE_DIR / "static"]   # project-wide assets: base.css, logo.svg
STATIC_ROOT = BASE_DIR / "staticfiles"     # collectstatic's destination — not committed to git
2
STATICFILES_DIRS is for assets that don't belong to any single app — an app's OWN static files go in that app's own <app>/static/<app>/ directory instead, found automatically by AppDirectoriesFinder.
3
STATIC_ROOT should be .gitignored — it's a fully regenerated build artifact, not source content.

Why this works: Keeping app-specific static files inside each app (found by AppDirectoriesFinder) while using STATICFILES_DIRS only for genuinely project-wide assets keeps a reusable app's static files bundled with it — dropping the app into another project brings its CSS/JS along automatically.

Setting STATIC_ROOT and STATICFILES_DIRS to the SAME directory

Wrong

python
STATICFILES_DIRS = [BASE_DIR / "static"]
STATIC_ROOT = BASE_DIR / "static"   # same path as a SOURCE directory

Better

python
STATICFILES_DIRS = [BASE_DIR / "static"]
STATIC_ROOT = BASE_DIR / "staticfiles"   # a genuinely separate destination

What you see: collectstatic raises an error (Django explicitly detects and refuses this configuration) or, in looser setups, silently mixes source and collected files in one directory — running collectstatic repeatedly re-copies files into the same place they were found, corrupting the distinction between source and build output.

Why: STATIC_ROOT must be a directory collectstatic fully owns and regenerates — it is documented as a destination, never a source finders should also be scanning. Django validates against exactly this overlap because treating the same directory as both erases the entire point of separating development sources from a production build artifact.

Three distinct roles, easy to conflate

STATIC_URL = "/static/" STATICFILES_DIRS = [BASE_DIR / "static"] STATIC_ROOT = BASE_DIR / "staticfiles"

"/static/"

STATIC_URL — a URL prefix — never a filesystem path

[BASE_DIR / "static"]

STATICFILES_DIRS — extra SOURCE directories, beyond each app's own static/

BASE_DIR / "staticfiles"

STATIC_ROOT — the single collectstatic DESTINATION — regenerated, never a source

  • Whole: STATIC_URL = "/static/" STATICFILES_DIRS = [BASE_DIR / "static"] STATIC_ROOT = BASE_DIR / "staticfiles"
  • "/static/" — STATIC_URL: a URL prefix — never a filesystem path
  • [BASE_DIR / "static"] — STATICFILES_DIRS: extra SOURCE directories, beyond each app's own static/
  • BASE_DIR / "staticfiles" — STATIC_ROOT: the single collectstatic DESTINATION — regenerated, never a source

Static-file settings, what each is

Static-file settings, what each is
SettingIs
STATIC_URLa URL prefix — never a filesystem path
STATICFILES_DIRSextra source directories to search, beyond each app's own static/
STATIC_ROOTthe single collectstatic destination — production-only, regenerated, never a source

Together

python
STATIC_URL = "/static/"
STATICFILES_DIRS = [BASE_DIR / "static"]
STATIC_ROOT = BASE_DIR / "staticfiles"

Remember: STATIC_URL is a URL prefix, never a path. STATICFILES_DIRS adds extra SOURCE directories beyond each app's own static/; STATIC_ROOT is the single, regenerated collectstatic DESTINATION — never the same directory as a source, and never used directly in development. Re-running collectstatic is a required deploy step whenever new static files are added, since production serves only what STATIC_ROOT already contains.

See also: production static architecture · locale and file settings · built in tags

Advertisement

Production static architecture

Content hashing, cache headers, WhiteNoise, and CDN hosting.

Manifest hashing, CDN hosting, WhiteNoise, and cache headers

coreadvanced

ManifestStaticFilesStorage (a STATICFILES_STORAGE option) renames each collected file to include a content hash in its filename (app.a1b2c3.css) and rewrites every reference to match — so a far-future Cache-Control header is safe: the FILENAME changes when the content does, rather than needing to bust a cache by URL. A CDN sits in front of (or entirely replaces) Django serving static files directly — STATIC_URL points at the CDN's URL, and collectstatic's output gets pushed there as a deploy step. WhiteNoise is a WSGI-layer library letting a Django app serve its own static files efficiently without a separate CDN or web-server config, appropriate for smaller deployments where standing up a full CDN is more infrastructure than the traffic justifies. Cache headers (Cache-Control, ETag) tell browsers/CDNs how long to keep a file before re-checking — safe to set very long specifically BECAUSE hashed filenames mean a changed file gets a new URL, not a longer wait for an old cache to expire.

Think of it as

The entire "production static architecture" question is really one problem with several valid answers: static files rarely change per-request, so they should be cached AS AGGRESSIVELY AS POSSIBLE — but aggressive caching is only safe if there's a reliable way to know when a file's content has actually changed. Content-hashed filenames (Manifest storage) solve this at the root: instead of relying on a cache to correctly notice a file changed, the file's NAME changes whenever its content does, so a stale cache simply requests a URL that no longer matches anything meaningful — there is no "stale" state to worry about, only "not yet fetched." Once that's solved, WHERE the files are actually served from becomes a pure infrastructure/scale decision: WhiteNoise keeps everything inside the Django process (simplest to operate, fine until traffic is large enough that offloading static serving meaningfully helps), while a real CDN pushes static serving to edge infrastructure entirely separate from the app servers (best latency and offload at scale, more moving pieces to configure and deploy to). Neither choice changes the hashing strategy underneath — they're answers to different questions (how is a cache correctly invalidated vs. what serves the bytes) that happen to get discussed together because both matter for a real production static-file setup.

python
STATICFILES_STORAGE = "django.contrib.staticfiles.storage.ManifestStaticFilesStorage"

What we're doing: Configure hashed, long-cache static files served via WhiteNoise, appropriate for a small-to-medium production deployment with no separate CDN.

settings.pypython
STATICFILES_STORAGE = "whitenoise.storage.CompressedManifestStaticFilesStorage"

MIDDLEWARE = [
    "django.middleware.security.SecurityMiddleware",
    "whitenoise.middleware.WhiteNoiseMiddleware",
    "django.contrib.sessions.middleware.SessionMiddleware",
    ...
]
1
CompressedManifestStaticFilesStorage combines WhiteNoise's gzip/brotli pre-compression with Django's own content-hashing — both concerns solved by one storage backend.
5
WhiteNoiseMiddleware is placed immediately after SecurityMiddleware, per its own documented recommendation — early enough to serve a static-file request before most of the rest of the middleware stack even runs.

Why this works: For a project not yet at the traffic scale where a dedicated CDN's operational overhead is worth it, WhiteNoise gets nearly all the practical benefit (compression, long-cache hashed filenames, efficient serving) with zero additional infrastructure to deploy or maintain.

Setting a far-future Cache-Control header on static files WITHOUT content-hashed filenames

Wrong

python
STATICFILES_STORAGE = "django.contrib.staticfiles.storage.StaticFilesStorage"  # no hashing
# but Cache-Control: max-age=31536000 set anyway, e.g. via web-server config

Better

python
STATICFILES_STORAGE = "django.contrib.staticfiles.storage.ManifestStaticFilesStorage"
# NOW a far-future Cache-Control header is safe — filenames change with content

What you see: After deploying an updated app.css, some fraction of users continue loading the OLD stylesheet for up to a year (whatever max-age was set to) — because the cached copy is keyed by a URL that never changed, so the browser has no signal to re-fetch.

Why: A long Cache-Control header is only safe when the URL is guaranteed to change whenever the content does — without hashed filenames, the URL for app.css stays identical release after release, so an aggressive cache header actively prevents users from seeing an update, rather than merely optimizing repeat requests for unchanged content.

Why a far-future cache header is safe with hashed filenames
templates nowreference the new URL

app.css content changes

ManifestStaticFilesStorage

computes a new content hash

app.a1b2c3d4.css

a NEW filename

browser/CDN cache

old URL still cached, but nothing references it anymore

  • app.css content changes
    • leads to ManifestStaticFilesStorage
  • ManifestStaticFilesStorage — computes a new content hash
    • leads to app.a1b2c3d4.css
  • app.a1b2c3d4.css — a NEW filename
    • leads to browser/CDN cache (templates now reference the new URL)
  • browser/CDN cache — old URL still cached, but nothing references it anymore

Serving static files in production: two common answers

Serving static files in production: two common answers
ApproachFits
WhiteNoise (in-process WSGI middleware)simpler deploys, moderate traffic, no separate CDN infrastructure to manage
A real CDN (files pushed to an origin/bucket)high traffic, global audience, edge caching genuinely reduces latency and app-server load

Together

python
STATICFILES_STORAGE = "whitenoise.storage.CompressedManifestStaticFilesStorage"
MIDDLEWARE = [
    "django.middleware.security.SecurityMiddleware",
    "whitenoise.middleware.WhiteNoiseMiddleware",
    ...
]

Remember: Content-hashed filenames (ManifestStaticFilesStorage) are what make a far-future Cache-Control header safe — the URL changes when the content does, so there's no stale-cache window to worry about. WhiteNoise serves static files efficiently from within the Django process, no separate infrastructure; a real CDN offloads serving to the edge at higher traffic/scale. collectstatic must run as a real deploy step whenever Manifest storage is used, or template rendering fails outright.

See also: static settings finders and collectstatic

Advertisement