09 · Static Files & User Uploads¶
Django draws a hard line between two kinds of files:
- Static files are part of your code: CSS, JavaScript, logos, fonts. You write them, they're versioned in git, and they change only when you deploy.
- Media files are uploaded by users at runtime: avatars, book covers, attachments. They're data, they're never in git, and you can't trust them.
They have separate settings, separate storage and separate security concerns. Mixing them up is one of the classic Django deployment mistakes.
Static files in development¶
django.contrib.staticfiles (installed by default) collects static files from each app's
static/ folder. As with templates, namespace them by app name:
In a template:
With DEBUG = True, runserver serves these automatically. To check where Django finds
a file, use findstatic:
$ python manage.py findstatic catalog/site.css
Found 'catalog/site.css' here:
/…/shelf/catalog/static/catalog/site.css
Files that don't belong to any one app (a site-wide logo) go in a project folder listed
in STATICFILES_DIRS = [BASE_DIR / "static"].
Static files in production¶
In production you don't want Python serving every CSS request out of a dozen app
folders. Instead, collectstatic copies every static file from every app (including the
admin's) into one directory, STATIC_ROOT, which a web server, a CDN or WhiteNoise
serves directly:
131, not 1: almost all of them belong to the admin.
Cache-busting with hashed filenames¶
Browsers cache CSS aggressively. If site.css changes but keeps its name, returning
visitors can see stale styles for days. The fix is to put a content hash in the file
name, and Django's ManifestStaticFilesStorage does exactly that:
STORAGES = {
"default": {"BACKEND": "django.core.files.storage.FileSystemStorage"},
"staticfiles": {"BACKEND": "django.contrib.staticfiles.storage.ManifestStaticFilesStorage"},
}
After collectstatic with that setting and DEBUG = False, our template tag
{% static 'catalog/site.css' %} rendered:
and staticfiles/catalog/ contained both site.css and site.c2819c21bcc3.css. The
hash changes whenever the file's content changes, so you can tell browsers and CDNs to
cache these URLs forever. The storage also rewrites url(...) references inside CSS to
the hashed names.
The strictness has a cost worth knowing about before your first deploy. We referenced a file that didn't exist:
With the manifest storage, a typo in a {% static %} path is a 500 error in
production, not a broken link. Run collectstatic in CI and render your main pages in a
test with DEBUG = False to catch it early. Level 4 · 03 adds WhiteNoise, which serves
these files efficiently from the Django process itself.
Media: user uploads¶
A model field stores the path in the database and the file in storage:
from django.core.validators import FileExtensionValidator
class Cover(models.Model):
book = models.OneToOneField(Book, on_delete=models.CASCADE)
image = models.FileField(
upload_to="covers/%Y/",
validators=[FileExtensionValidator(["jpg", "png"])],
)
We saved two covers, both uploaded as front.png:
>>> c1.image.name, c1.image.url, c1.image.size
('covers/2026/front.png', '/media/covers/2026/front.png', 9)
>>> c2.image.name
'covers/2026/front_J8OaAe3.png'
upload_to supports strftime codes, which keeps any one directory from holding
millions of files. The second upload didn't overwrite the first: the storage noticed the
name was taken and appended a random suffix.
ImageField works the same way but also verifies the upload is a real image and can
record its dimensions. It requires the Pillow library (pip install pillow); without
it, Django's system checks stop the project as soon as a model uses ImageField:
catalog.Cover.photo: (fields.E210) Cannot use ImageField because Pillow is not installed.
HINT: Get Pillow at https://pypi.org/project/Pillow/ or run command "python -m pip install Pillow".
Upload forms¶
A form that accepts files needs two things, or request.FILES will be empty:
<form method="post" enctype="multipart/form-data">
{% csrf_token %}
{{ form.as_div }}
<button>Upload</button>
</form>
The validator ran when we tried to upload evil.html:
Serving media during development¶
runserver does not serve media automatically. Add this to the root URLconf, which
only takes effect when DEBUG = True:
from django.conf import settings
from django.conf.urls.static import static
urlpatterns = [
# ...
] + static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
In production, media should be served by the web server or, more commonly, stored in
object storage (S3, GCS, Azure Blob) through a storage backend such as the
django-storages package. Configuring a cloud bucket can't be demonstrated without an
account, so this course doesn't show output for it; the pattern is changing
STORAGES["default"] to the provider's backend class.
Upload security¶
User uploads are one of the riskiest features you can add:
- Extension checks are a first filter, not a guarantee. A file named
cover.pngcan contain anything.ImageField(via Pillow) at least checks that it parses as an image. - Never serve uploads from the same origin with a type the browser will execute. An
uploaded
.htmlor.svgserved from your domain can run JavaScript with your users' cookies. Serving media from a separate domain (or object storage) removes that risk. - Limit size.
DATA_UPLOAD_MAX_MEMORY_SIZElimits non-file request bodies, andFILE_UPLOAD_MAX_MEMORY_SIZEonly controls when uploads spill from memory to a temp file. Neither caps file size. Enforce a size limit in your form'sclean_<field>()and in your web server (client_max_body_sizein Nginx). - Don't trust the uploaded file name for anything other than display. Django's storage already sanitises it; don't build paths from it yourself.
How It Actually Works¶
Both kinds of files go through storage backends, configured in STORAGES. A storage
is an object with methods like save(name, content), url(name), exists(name) and
delete(name). FileSystemStorage writes under MEDIA_ROOT; cloud backends implement
the same methods against an API. A FileField stores only the name string; accessing
book.cover.image.url asks the storage to build a URL from that name. That indirection
is why moving media to S3 is a settings change, not a code change.
On save, the storage calls get_available_name(), which checks exists() and appends
a random seven-character suffix until the name is free. That's the _J8OaAe3 we saw.
Deleting a model instance does not delete its file. We deleted c1 and the file was
still on disk afterwards. Django stopped doing it automatically years ago because a
rolled-back transaction could otherwise delete a file whose row still exists. Clean up
orphaned files with a periodic job (Level 4 · 05 shows a management command for this
kind of task).
For static files, {% static %} asks the staticfiles storage for a URL. The manifest
storage looks the name up in staticfiles.json, a mapping written by collectstatic,
and raises if it's missing; that's the error we saw.
Common mistakes¶
- Putting uploads under
STATIC_ROOT, or static files underMEDIA_ROOT.collectstaticmay overwrite or ignore them. - Forgetting
enctype="multipart/form-data"and wondering whyrequest.FILESis empty. - Forgetting to pass
request.FILESto the form. - Running without
collectstaticin production and getting an unstyled admin. - Committing
media/to git, including users' uploads. - Assuming deleting the row deletes the file.
Exercise¶
- Add
catalog/static/catalog/site.css, link it frombase.htmlwith{% static %}, and confirm withfindstatic. - Configure
ManifestStaticFilesStorage, runcollectstatic, setDEBUG = False(andALLOWED_HOSTS = ["127.0.0.1"]), and view the page source to find the hashed CSS name. Change the CSS and runcollectstaticagain: what happens to the hash? - Add a
Covermodel with aFileField, an upload form, and a page that shows the cover. Add aclean_image()that rejects files over 2 MB. - Upload two files with the same name and inspect the stored names.