ZetCode

Django static files

last modified November 13, 2026 Python 3.15

In this article we work with static files in Django: stylesheets, scripts, and images. We configure STATIC_URL, STATICFILES_DIRS, and STATIC_ROOT, load files with the {% static %} tag, collect them with collectstatic, and create file names with content hashes. The examples use Django 6.1 and Python 3.15.

Static files

Static files are the files that the server sends as they are, without processing: CSS, JavaScript, images, and fonts. They differ from the pages that views generate and from media files, which are uploaded by users at run time. Django handles static files with the django.contrib.staticfiles app, which is installed by default. It finds the files in the apps and in the project, serves them during development, and gathers them into one directory for deployment.

The example project

The project is created with uv; the steps are explained in Django introduction. The commands below create a project directory, install Django, generate the project with the configuration package config, and start the app landing.

$ mkdir staticfiles
$ cd staticfiles
$ uv init --bare
$ uv add django
$ uv run django-admin startproject config .
$ uv run python manage.py startapp landing

The trailing dot in startproject config . creates manage.py and the config package in the current directory instead of one level deeper. A new app is ignored by Django until it is listed in INSTALLED_APPS, so we add the app to the list in config/settings.py; the other entries are generated by startproject.

config/settings.py
INSTALLED_APPS = [
    'django.contrib.admin',
    'django.contrib.auth',
    'django.contrib.contenttypes',
    'django.contrib.sessions',
    'django.contrib.messages',
    'django.contrib.staticfiles',
    'landing',
]

Every listing in this article is a file of the application, shown with a solid blue border and the label Project file.

The app landing has one page. The page uses a stylesheet that belongs to the app, and a script and an image that belong to the project. We create the directories for the templates and the static files first.

$ mkdir -p landing/templates/landing landing/static/landing static/js static/img

The finished project is laid out as follows; the next sections create the files.

.
├── config
│   ├── settings.py
│   └── urls.py
├── landing
│   ├── static
│   │   └── landing
│   │       └── style.css
│   ├── templates
│   │   └── landing
│   │       └── index.html
│   ├── urls.py
│   └── views.py
├── manage.py
└── static
    ├── img
    │   └── logo.svg
    └── js
        └── app.js

Static files settings

Three settings control the static files. The generated settings.py contains only STATIC_URL; we add the other two.

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

STATIC_URL is the URL prefix under which the static files are served; the generated value static/ is relative to the site root, so the files are available at /static/.... STATICFILES_DIRS lists additional directories with project-wide static files, such as our static directory. STATIC_ROOT is the directory where the collectstatic command copies all files for deployment; it is not used by the development server, and it must be different from the directories in STATICFILES_DIRS.

How files are found

Django finds the files with finders, listed in the STATICFILES_FINDERS setting. By default there are two: FileSystemFinder searches the STATICFILES_DIRS directories, and AppDirectoriesFinder searches the static subdirectory of every installed app. As with templates, all files share one namespace, so an app puts its files in a subdirectory named after the app: landing/static/landing/style.css is addressed as landing/style.css. Without the extra directory, two apps with a style.css would clash and the first one would win.

landing/static/landing/style.css
body {
    font-family: sans-serif;
    margin: 2rem;
}

.logo {
    color: #0c4b33;
    background: url('../img/logo.svg') no-repeat left center;
    padding-left: 80px;
}

The stylesheet colors the heading and puts an image behind it. The image is referenced with the relative path ../img/logo.svg; the browser resolves it against the URL of the stylesheet, so it points to /static/img/logo.svg.

The static template tag

Templates should not contain hard-coded static URLs. The {% static %} tag builds the URL from STATIC_URL and the relative path of the file. It lives in the static library, which we load with {% load static %} at the top of the template (or in the child templates; the load is not inherited). With the manifest storage, shown later, the tag also inserts the hashed name.

landing/templates/landing/index.html
{% load static %}
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <title>Static files</title>
  <link rel="stylesheet" href="{% static 'landing/style.css' %}">
</head>
<body>
  <h1 class="logo">Static files</h1>
  <img src="{% static 'img/logo.svg' %}" alt="Logo" width="32">
  <script src="{% static 'js/app.js' %}"></script>
</body>
</html>

The page loads the stylesheet from the app, the script and the image from the project's static directory. The view only renders the template.

landing/views.py
from django.shortcuts import render


def index(request):
    return render(request, 'landing/index.html')

The view only renders the template. The URLconf of the app routes the root URL to it, and the URLconf of the project includes the one of the app.

landing/urls.py
from django.urls import path

from . import views

urlpatterns = [
    path('', views.index, name='index'),
]

This is the whole routing of the app: one route for the page.

config/urls.py
from django.urls import include, path

urlpatterns = [
    path('', include('landing.urls')),
]

The script of the project only writes a message to the browser console, so that we can see that the browser loaded it.

static/js/app.js
console.log('static files work');

The image is a small SVG logo that the stylesheet uses as a background.

static/img/logo.svg
<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64">
  <circle cx="32" cy="32" r="28" fill="#0c4b33"/>
</svg>

These are all the files of the example; the static files are served by the development server.

The static files are served during development without any configuration, as long as DEBUG is True and the django.contrib.staticfiles app is installed: runserver uses its own static file handler. The page below shows the URLs produced by the tag.

$ curl -s http://127.0.0.1:8000/
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <title>Static files</title>
  <link rel="stylesheet" href="/static/landing/style.css">
</head>
<body>
  <h1 class="logo">Static files</h1>
  <img src="/static/img/logo.svg" alt="Logo" width="32">
  <script src="/static/js/app.js"></script>
</body>
</html>

Serving files in development

The development server finds a requested file with the finders, so the files can be edited and reloaded without any copy step. We request the stylesheet from the app and the image from the project directory; the first command prints the response headers and the file, the second one only the status line and the content type. The server sets the content type from the file extension.

$ curl -s -i http://127.0.0.1:8000/static/landing/style.css
HTTP/1.1 200 OK
Date: Fri, 13 Nov 2026 20:08:27 GMT
Server: WSGIServer CPython/3.15.2
Content-Type: text/css
Content-Length: 173
Content-Disposition: inline; filename="style.css"
Last-Modified: Fri, 13 Nov 2026 20:08:25 GMT

body {
    font-family: sans-serif;
    margin: 2rem;
}

.logo {
    color: #0c4b33;
    background: url('../img/logo.svg') no-repeat left center;
    padding-left: 80px;
}
$ curl -s -i http://127.0.0.1:8000/static/img/logo.svg | grep -i -E "^(HTTP|content-type)"
HTTP/1.1 200 OK
Content-Type: image/svg+xml

findstatic

The findstatic command shows which file Django uses for a relative path. It is the first tool to try when a static file does not load. With -v 2 it also lists the directories that were searched. A name that does not exist is reported as not found.

$ uv run python manage.py findstatic landing/style.css img/logo.svg
Found 'landing/style.css' here:
  ./landing/static/landing/style.css
Found 'img/logo.svg' here:
  ./static/img/logo.svg
$ uv run python manage.py findstatic nothing.css
No matching file found for 'nothing.css'.

collectstatic

In production the Django process should not serve the static files; a web server such as Nginx or a specialized middleware does it much faster. For that, the collectstatic command copies the files from all finders into the STATIC_ROOT directory, so the web server needs to know only one location. The admin site ships its own static files, which is why the copy contains more than our three. We use --noinput to skip the confirmation prompt.

$ uv run python manage.py collectstatic --noinput

133 static files copied to './staticfiles'.

The collected files

We list the top level of the target directory and then our own files. The directory tree of the admin files is not shown.

$ ls staticfiles
admin
img
js
landing
$ find staticfiles -type f -not -path "staticfiles/admin/*" | sort
staticfiles/img/logo.svg
staticfiles/js/app.js
staticfiles/landing/style.css

Hashed file names

Browsers cache static files. After a deployment, users may keep seeing an old stylesheet, unless the file name changes with its content. The ManifestStaticFilesStorage backend appends a hash of the content to every collected file name, such as style.5f2c0b4d7a9e.css, and writes the mapping to a staticfiles.json manifest. The {% static %} tag reads the manifest and emits the hashed names, so the files can be cached forever; a changed file gets a new name. The backend also rewrites the url() references inside the CSS files. The storage is configured with the STORAGES setting. We keep it in a separate settings module that imports the development settings and changes what differs in production.

config/settings_prod.py
from .settings import *

DEBUG = False
ALLOWED_HOSTS = ['127.0.0.1', 'localhost']

STORAGES = {
    'default': {
        'BACKEND': 'django.core.files.storage.FileSystemStorage',
    },
    'staticfiles': {
        'BACKEND': 'django.contrib.staticfiles.storage.ManifestStaticFilesStorage',
    },
}

The settings module turns off DEBUG and allows the local host names. The staticfiles entry of STORAGES selects the manifest storage. The --settings option selects the module for one command. The command below collects the files again; the output tells how many files were copied and how many were post-processed, which means that their names were hashed and their references were rewritten.

$ uv run python manage.py collectstatic --noinput --clear --settings=config.settings_prod

133 static files deleted, 133 static files copied to './staticfiles', 133 post-processed.

The manifest

Our own files now have hashed names. The staticfiles.json manifest maps the original names to the hashed ones. The listing shows the files outside of the admin directory and the paths entries of the manifest for our files.

$ find staticfiles -type f -not -path "staticfiles/admin/*" | sort
staticfiles/img/logo.eada26879de7.svg
staticfiles/img/logo.svg
staticfiles/js/app.fc885cc87057.js
staticfiles/js/app.js
staticfiles/landing/style.9eee754dafd7.css
staticfiles/landing/style.css
staticfiles/staticfiles.json
$ python3 -c "import json; m = json.load(open('staticfiles/staticfiles.json'))['paths']; [print(k, '->', m[k]) for k in ('landing/style.css', 'js/app.js', 'img/logo.svg')]"
landing/style.css -> landing/style.9eee754dafd7.css
js/app.js -> js/app.fc885cc87057.js
img/logo.svg -> img/logo.eada26879de7.svg

The rewritten stylesheet

The url('../img/logo.svg') reference in the collected stylesheet was replaced with the hashed name of the image, so the cached image also changes when the file changes. We print the collected stylesheet; the cat command expands the pattern to the hashed file name.

$ cat staticfiles/landing/style.*.css
body {
    font-family: sans-serif;
    margin: 2rem;
}

.logo {
    color: #0c4b33;
    background: url("../img/logo.eada26879de7.svg") no-repeat left center;
    padding-left: 80px;
}

The static tag with the manifest

With the production settings the {% static %} tag returns the hashed names. A server with DEBUG = False does not serve static files by itself; the --insecure option of runserver forces the static handler. We start such a server on port 8001, and, as a stand-in for a real web server, the http.server module of the standard library on port 8002, which serves the STATIC_ROOT directory.

The page links to the hashed files. The hashed file is not found by the development static handler: it searches the source directories with the finders and knows only the original names. The file is found by the stand-in web server, because it is present in STATIC_ROOT; it serves the directory at its root, while a real server would map the /static/ prefix to the directory. This is the reason why the hashed names require a real web server in production.

$ curl -s http://127.0.0.1:8001/
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <title>Static files</title>
  <link rel="stylesheet" href="/static/landing/style.9eee754dafd7.css">
</head>
<body>
  <h1 class="logo">Static files</h1>
  <img src="/static/img/logo.eada26879de7.svg" alt="Logo" width="32">
  <script src="/static/js/app.fc885cc87057.js"></script>
</body>
</html>
$ curl -s -o /dev/null -w "%{http_code}\n" http://127.0.0.1:8001/static/landing/style.9eee754dafd7.css
404
$ curl -s -o /dev/null -w "%{http_code} %{content_type}\n" http://127.0.0.1:8002/landing/style.9eee754dafd7.css
200 text/css

Serving static files in production

In production the web server answers the requests for /static/ directly from the STATIC_ROOT directory, and passes the other requests to Django. With Nginx, a location /static/ block with an alias to the directory is enough; the hashed names allow a long expires header. When there is no separate web server, a package such as WhiteNoise serves the files from the application. The usual deployment step is to run collectstatic after each release.

Two details are easy to forget. The STATIC_URL must end with a slash, and the user running the web server needs read permission for STATIC_ROOT. Do not put the directory under version control; it is generated.

Testing static files

The tests check that the static() helper (the function behind the tag) builds the URL, that the finders locate both the app file and the project file, and that the page contains the links. The test client does not serve the files themselves; the test runner runs with DEBUG = False and the staticfiles handler is part of runserver only.

landing/tests.py
from django.contrib.staticfiles import finders
from django.templatetags.static import static
from django.test import TestCase
from django.urls import reverse


class StaticFilesTests(TestCase):

    def test_static_helper_builds_url(self):
        self.assertEqual(static('landing/style.css'), '/static/landing/style.css')

    def test_finders_locate_app_and_project_files(self):
        self.assertIsNotNone(finders.find('landing/style.css'))
        self.assertIsNotNone(finders.find('img/logo.svg'))
        self.assertIsNone(finders.find('missing.css'))

    def test_page_links_to_static_files(self):
        response = self.client.get(reverse('index'))
        self.assertContains(response, 'href="/static/landing/style.css"')
        self.assertContains(response, 'src="/static/js/app.js"')

The finders.find() function returns the absolute path of the file or None. Tests of the real delivery of static files need a live server; Django provides StaticLiveServerTestCase for that.

$ uv run python manage.py test
Found 3 test(s).
System check identified no issues (0 silenced).
Creating test database for alias 'default'...
...
----------------------------------------------------------------------
Ran 3 tests in 0.006s

OK
Destroying test database for alias 'default'...

Source

Django documentation: Managing static files

Django documentation: The staticfiles app

Django documentation: Deploying static files

In this article we have configured the static files of a Django project, loaded them with the {% static %} tag, found them with findstatic, gathered them with collectstatic, and created hashed file names with the manifest storage. The pages that use the files are built with Django templates; the following articles describe the database layer, beginning with Django models.

Author

My name is Jan Bodnar, and I am a passionate programmer with extensive programming experience. I have been writing programming articles since 2007. To date, I have authored over 1,400 articles and 8 e-books. I possess more than ten years of experience in teaching programming.

List all Django tutorials.