File Uploads & Storage
|
This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production. The reference material consulted while preparing these pages is listed in the section bibliography. |
This page is written against Django 6.1 (6.1.1, released 2026-09-02; 6.1.2 is scheduled for 2026-10-06), Python 3.12-3.14
and PostgreSQL 15 or newer, with Pillow 12.3.0 and django-storages 1.14.6, all checked on 2026-10-03; where the 5.2 LTS
(5.2.17) behaves differently the text says so. Every listing was run in a scratch project on Python 3.13 and Django 6.1.1
with SQLite: manage.py check, 29 tests (manage.py test) and a curl session against runserver passed, and the
output shown below is real output. Nothing on this page needs PostgreSQL or Redis. The django-storages example was run
against moto, a local S3 look-alike server, not against Amazon S3: the code is the same, but credentials, regions,
bucket policies and CDN settings of a real bucket were not exercised, and the page says where that matters. In the
Bookshelf application that grows across this section, uploads are the cover image of a book, a public excerpt and a
private manuscript. A file upload crosses more trust boundaries than any other request: the bytes, the file name and the
declared type all come from the client, they are parsed by Django before your view runs, they are stored somewhere
that someone must later serve, and what you serve can be executed by a browser. This page follows that path from the
request to the storage backend and back to the browser.
In a hurry: what goes where
| Concern | Rule of thumb | Section |
|---|---|---|
Receiving a file |
|
|
Several files in one field |
Subclass the widget ( |
|
Big uploads |
Small files stay in memory (2.5 MB), larger ones stream to a temporary file; replace or add handlers with |
|
Model fields |
|
|
Saving and reading files in code |
|
|
Where files are stored |
The |
|
A new backend |
Subclass |
|
S3 and compatible stores |
django-storages |
|
Serving files |
|
|
Untrusted content |
Validate extension, size, decoded content and dimensions; rename on disk; serve from another domain with |
|
Housekeeping |
Deleting a model row does not delete its files; clean up after commit or with a command. |
The path of an upload
A browser sends the form as multipart/form-data: one request body made of parts, each with its own headers, some of
them files. Django does not hand your view the raw body. When the view (or CSRF middleware, or a form) first touches
request.POST or request.FILES, Django’s multipart parser walks the body, feeds file parts to the configured
upload handlers, and builds one UploadedFile per file. Your code then passes that object to a form, a model field or
a storage, and the storage decides where the bytes finally go.
multipart/form-data"] --> server["Web server
body size limit"] server --> asgi["WSGI / ASGI handler"] asgi --> csrf["CsrfViewMiddleware
reads request.POST"] csrf --> parser["MultiPartParser
request.upload_handlers"] parser -->|"small"| mem["InMemoryUploadedFile"] parser -->|"large"| tmp["TemporaryUploadedFile
file in temp dir"] mem --> form["Form / ModelForm
validators"] tmp --> form form --> field["FileField / ImageField
upload_to, FieldFile.save"] field --> storage["Storage from STORAGES
FileSystem, S3, custom"] storage --> serve["Web server, CDN
or a view"]
Two consequences shape the rest of the page. First, the body is parsed before your view body runs whenever
CsrfViewMiddleware is enabled, so anything that must happen early (changing the upload handlers, rejecting oversized
bodies) has to happen in the web server or at view setup. Second, the model never contains the file: it contains a path
that the storage can resolve, so moving to another storage is a configuration change plus a data copy, not a schema change.
The Bookshelf scratch project
The listings belong to one small catalog app of the running Bookshelf scenario. The pieces are: models.py (a Book
with cover, excerpt and manuscript file fields), forms.py, validators.py, handlers.py, storage.py,
views.py, signals.py and a prune_covers management command (see
Management Commands & Shell). The settings that matter are collected
here once; later sections show only the delta.
# config/settings.py (excerpt)
from pathlib import Path
BASE_DIR = Path(__file__).resolve().parent.parent
MEDIA_URL = "media/" # public URL prefix (relative: prefixed with SCRIPT_NAME)
MEDIA_ROOT = BASE_DIR / "media" # where FileSystemStorage writes
PRIVATE_ROOT = BASE_DIR / "private" # never exposed by a URL
STORAGES = {
"default": {"BACKEND": "django.core.files.storage.FileSystemStorage"},
"staticfiles": {"BACKEND": "django.contrib.staticfiles.storage.StaticFilesStorage"},
"private": {
"BACKEND": "django.core.files.storage.FileSystemStorage",
"OPTIONS": {"location": PRIVATE_ROOT},
},
}
FILE_UPLOAD_MAX_MEMORY_SIZE = 2_621_440 # 2.5 MB, the default; larger files go to a temp file
DATA_UPLOAD_MAX_MEMORY_SIZE = 2_621_440 # request body excluding files; the default
DATA_UPLOAD_MAX_NUMBER_FILES = 20 # default is 100
FILE_UPLOAD_PERMISSIONS = 0o644 # the default since 3.0
FILE_UPLOAD_DIRECTORY_PERMISSIONS = 0o755
Source: Django: File upload settings and STORAGES
The full set of project-level settings is covered in Settings & Configuration;
MEDIA_ROOT and STATIC_ROOT must differ (a system check enforces it), and so must MEDIA_URL and STATIC_URL.
Receiving a file: request.FILES
request.FILES is a dictionary-like object with one key per file input, filled only when three conditions hold: the
request method is POST, at least one file was actually posted, and the <form> carries
enctype="multipart/form-data". Forget the enctype and the browser sends only the file name as an ordinary field;
request.FILES is empty and the form reports "This field is required", which is the most common upload bug.
<form method="post" enctype="multipart/form-data">
{% csrf_token %}
{{ form.as_div }}
<button type="submit">Upload</button>
</form>
Source: Django: Basic file uploads
A plain form and view
The smallest working example is a form with a forms.FileField and a view that binds request.FILES to it. The form
constructor takes two dictionaries, data and files: leaving out request.FILES is the classic second bug, and again
the symptom is "required".
# catalog/forms.py
from django import forms
class UploadFileForm(forms.Form):
title = forms.CharField(max_length=50)
file = forms.FileField()
# catalog/views.py
from django.core.files.storage import default_storage
from django.http import JsonResponse
from django.shortcuts import render
from .forms import UploadFileForm
def save_upload(uploaded):
"""Store an UploadedFile through the default storage, chunk by chunk."""
return default_storage.save(f"inbox/{uploaded.name}", uploaded)
def upload_file(request):
if request.method == "POST":
form = UploadFileForm(request.POST, request.FILES) # both dictionaries
if form.is_valid():
name = save_upload(request.FILES["file"])
return JsonResponse({"saved_as": name})
return JsonResponse(form.errors, status=400)
return render(request, "catalog/upload.html", {"form": UploadFileForm()})
Source: Django: Basic file uploads and forms.FileField
A curl session against runserver (the CSRF token is taken from the form page and sent back as a header together with
the cookie) shows the whole round trip:
$ curl -s -b cj.txt -H "X-CSRFToken: $TOKEN" -F title=notes -F file=@big.bin http://127.0.0.1:8391/upload/
{"saved_as": "inbox/big.bin"}
$ curl -s -o /dev/null -w "%{http_code}\n" -F title=x -F file=@cover.png http://127.0.0.1:8391/quota-upload/
403
Two details of save_upload are deliberate. The upload is passed to default_storage.save() instead of being copied
with open(name, "wb") as older tutorials show: the storage iterates the file with chunks(), so a 2 GB upload never
sits in memory, and the storage chooses a free name instead of overwriting. And the client’s file name is not used as a
path: Django’s storage runs it through get_valid_name() and rejects .. segments. A multipart part named
../../etc/passwd was stored as inbox/passwd, and a second upload named notes.txt became inbox/notes_MVRyPkO.txt
(an underscore plus seven random characters, produced by get_alternative_name()). The name Django returns from save()
is the only name you may rely on, and the client name is still data to display, not to trust.
The UploadedFile object
Each value in request.FILES is an UploadedFile (or one of its subclasses): a wrapper over the received bytes.
| Member | Meaning |
|---|---|
|
The file name sent by the client, already stripped of directory components. Display it, do not build paths from it. |
|
Size in bytes after the upload was received. Reliable for limits, because Django counted the bytes itself. |
|
The |
|
Extra parameters of the header, and the charset for |
|
Reads everything into memory. Fine for small, size-checked files; use |
|
Generator of byte chunks (64 KB by default). The default way to copy an upload. |
|
|
iteration |
Yields lines with universal newlines, convenient for CSV or log uploads. |
|
Only on the disk-backed subclass: the path of the temporary file, handy for tools that need a real path. |
Source: Django: Uploaded files
A ModelForm with a file field
When the destination is a model, a ModelForm does the binding, the validation and the storage in one step: form.save()
saves the file to the location given by the field’s upload_to, then saves the row. The Bookshelf Book model is shown in
full in FileField and ImageField; the form lists its fields explicitly, as every model form should.
# catalog/forms.py
from django import forms
from .models import Book
class BookCoverForm(forms.ModelForm):
class Meta:
model = Book
fields = ["title", "cover", "excerpt"] # explicit allow-list, never "__all__" for user input
# catalog/views.py
from django.http import JsonResponse
from django.shortcuts import get_object_or_404, render
from .forms import BookCoverForm
from .models import Book
def book_cover(request, pk):
book = get_object_or_404(Book, pk=pk)
if request.method == "POST":
form = BookCoverForm(request.POST, request.FILES, instance=book)
if form.is_valid():
book = form.save()
return JsonResponse({
"cover": book.cover.name, "url": book.cover.url,
"width": book.cover_width, "height": book.cover_height,
})
return JsonResponse(form.errors, status=400)
return render(request, "catalog/upload.html", {"form": BookCoverForm(instance=book)})
Uploading a 120 x 180 PNG with curl -F cover=@cover.png answered
{"cover": "covers/1/2da646c9609a4c63a7ece6e9ac09458e.png", "url": "/media/covers/1/2da646c9609a4c63a7ece6e9ac09458e.png", "width": 120, "height": 180}
Notice that the stored name has nothing in common with cover.png; Validating uploads safely explains why. The rendered input for an
ImageField also carries accept="image/*", a browser hint that helps honest users and stops nobody else.
Without a form, there are two other ways to attach an upload, both from the official guide: assign the uploaded file to the
field (ModelWithFileField(file_field=request.FILES["file"]).save()), or, outside a request, assign a ContentFile that
has a name (ContentFile(b"Hello world!", name="hello-world.txt")). Assigning a file without a name and saving raises
FieldError since 5.1 (earlier versions failed less clearly):
book = Book.objects.create(title="T")
book.excerpt = ContentFile(b"x") # no name
book.save()
# FieldError: File for excerpt must have the name attribute specified to be saved.
Several files in one field
HTML allows <input type="file" multiple>, but forms.FileField validates one file. For years the docs showed a shortcut
that validated only the last file, which became CVE-2023-31047 and was fixed in 4.2.1 (and 4.1.9, 3.2.19): since then
ClearableFileInput and FileInput raise ValueError if you set the multiple attribute, unless the widget class sets
allow_multiple_selected = True. A field that validates every file has to subclass FileField as well:
# catalog/forms.py
from django import forms
class MultipleFileInput(forms.ClearableFileInput):
allow_multiple_selected = True
class MultipleFileField(forms.FileField):
def __init__(self, *args, **kwargs):
kwargs.setdefault("widget", MultipleFileInput())
super().__init__(*args, **kwargs)
def clean(self, data, initial=None):
single_file_clean = super().clean
if isinstance(data, (list, tuple)):
return [single_file_clean(d, initial) for d in data]
return [single_file_clean(data, initial)]
class GalleryForm(forms.Form):
images = MultipleFileField()
# catalog/views.py
from django.views.generic.edit import FormView
class GalleryView(FormView):
form_class = GalleryForm
template_name = "catalog/upload.html"
success_url = "/gallery/"
def form_valid(self, form):
names = [save_upload(f) for f in form.cleaned_data["images"]]
return JsonResponse({"saved": names})
def form_invalid(self, form):
return JsonResponse(form.errors, status=400)
Posting two files with curl -F images=@cover.png -F images=@cover.png returned
{"saved": ["inbox/cover_ls4Yyhr.png", "inbox/cover_7ywf0Wi.png"]}. In a template the rendered input is
<input type="file" name="images" multiple required id="id_images">. Two limits apply. This handles several files at
the form level only: the official guide warns that you cannot put several files into one model FileField this way (use
a related model, such as a BookImage with a foreign key). And the number of file parts is capped by
DATA_UPLOAD_MAX_NUMBER_FILES (default 100, introduced in the security releases 4.1.7, 4.0.10 and 3.2.18 against a
denial-of-service in multipart parsing): posting 25 files with the limit set to 20 answered 400 Bad Request because Django
raises TooManyFilesSent, a SuspiciousOperation.
Testing uploads
Tests post SimpleUploadedFile objects (in-memory files with a name and content type) through the test client, which
encodes the multipart body for you, and point MEDIA_ROOT at a temporary directory so no test writes into the real one.
import shutil
import tempfile
from django.core.files.uploadedfile import SimpleUploadedFile
from django.test import TestCase, override_settings
TMP_MEDIA = tempfile.mkdtemp(prefix="media-")
@override_settings(MEDIA_ROOT=TMP_MEDIA)
class UploadTests(TestCase):
@classmethod
def tearDownClass(cls):
super().tearDownClass()
shutil.rmtree(TMP_MEDIA, ignore_errors=True)
def test_plain_upload(self):
f = SimpleUploadedFile("notes.txt", b"hello", content_type="text/plain")
r = self.client.post("/upload/", {"title": "t", "file": f})
self.assertEqual(r.status_code, 200)
self.assertEqual(r.json()["saved_as"], "inbox/notes.txt")
Source: Django: Client.post() and Overriding settings
For faster suites replace the whole storage with InMemoryStorage (see InMemoryStorage for fast tests). The test client does not enforce
CSRF by default, so this test does not prove your form template contains {% csrf_token %}; the curl session above did.
Where the bytes live: upload handlers
The multipart parser does not store anything itself. It asks a chain of upload handlers, taken from
FILE_UPLOAD_HANDLERS, which of them wants each file, and the first handler that returns an UploadedFile wins. The
default list is
FILE_UPLOAD_HANDLERS = [
"django.core.files.uploadhandler.MemoryFileUploadHandler",
"django.core.files.uploadhandler.TemporaryFileUploadHandler",
]
Source: Django: FILE_UPLOAD_HANDLERS
and gives Django’s default behaviour: small uploads live in memory (InMemoryUploadedFile), large ones are streamed to a
temporary file (TemporaryUploadedFile) as they arrive.
| Setting | Default | What it controls |
|---|---|---|
|
2621440 (2.5 MB) |
Largest upload held in memory by |
|
|
Where |
|
|
Mode of saved files. Since 3.0 the default is explicit; with |
|
|
Mode of directories created while saving files. |
|
2621440 (2.5 MB) |
Maximum request body size excluding file data, checked when |
|
100 |
Maximum number of file parts per multipart request ( |
|
1000 |
Maximum number of non-file form fields. |
Which class you get can be observed directly. With the defaults, a 1000-byte part and a 3 MiB part, posted through
RequestFactory, produced
{'small': ('InMemoryUploadedFile', 1000, None)}
{'large': ('TemporaryUploadedFile', 3145728, '/var/folders/.../tmpfjgur2ia.upload.bin')}
Source: Django: Built-in upload handlers
The temporary file’s name keeps the extension of the uploaded name, and the file is removed when the UploadedFile is
closed or garbage collected. In the Django 6.1.1 source, MemoryFileUploadHandler decides once per request, from the
whole body size (the actual size when the stream is seekable, as on ASGI, otherwise Content-Length), not per file: a
request carrying several files whose total exceeds the limit streams every file to disk.
Writing a custom handler
Subclass FileUploadHandler when you need behaviour that cannot be done after the fact: enforcing a quota while the
bytes arrive, computing a checksum on the fly, driving a progress bar, or sending data straight to another store without a
local copy. The hooks are called in this order for every request.
| Hook | When and what |
|---|---|
|
Optional. Called before parsing; return |
|
Optional. A new file part starts. May raise |
|
Required. Gets each chunk ( |
|
Required. The file ended. Return an |
|
Optional. All files are done. |
|
Optional, added in 3.2. The connection dropped mid-upload; release what you hold. |
The handler below counts bytes and aborts as soon as the budget is exhausted. It stores nothing, so the default handlers
after it still build the UploadedFile: it is a filter in front of them.
# catalog/handlers.py
from django.core.files.uploadhandler import FileUploadHandler, StopUpload
class MaxUploadSizeHandler(FileUploadHandler):
"""Abort the whole upload as soon as it exceeds ``limit`` bytes."""
def __init__(self, request=None, limit=5 * 1024 * 1024):
super().__init__(request)
self.limit = limit
self.received = 0
def receive_data_chunk(self, raw_data, start):
self.received += len(raw_data)
if self.received > self.limit:
raise StopUpload(connection_reset=True) # do not read the rest of the body
return raw_data # feed the next handler in the chain
def file_complete(self, file_size):
return None # a later handler builds the UploadedFile
StopUpload(connection_reset=True) makes Django stop reading the body at once, which the browser shows as a connection
reset; the default StopUpload() consumes the rest of the body first and then halts, which is friendlier to the client
and costs bandwidth. In both cases request.FILES stays empty, so the form reports the field as required: if you want a
specific message, record the abort on the handler or on the request and test for it in the view.
Changing handlers for one view
FILE_UPLOAD_HANDLERS applies everywhere; a single view can change request.upload_handlers instead. Two rules from the
official guide matter: do it before anything reads request.POST or request.FILES (changing the list afterwards raises
an error), and note that CsrfViewMiddleware reads request.POST, so the view must be csrf_exempt to get a chance
to modify the list, and the code that really processes the request must be wrapped in csrf_protect. The handlers then
start receiving file data before the CSRF check has run, which is an exposure you accept consciously. For a class-based
view the equivalent is to exempt dispatch and protect the method:
# catalog/views.py
from django.utils.decorators import method_decorator
from django.views import View
from django.views.decorators.csrf import csrf_exempt, csrf_protect
@method_decorator(csrf_exempt, name="dispatch")
class QuotaUploadView(View):
"""Per-request handler: must be installed before request.POST/FILES is read."""
def setup(self, request, *args, **kwargs):
request.upload_handlers.insert(0, MaxUploadSizeHandler(request, limit=64 * 1024))
super().setup(request, *args, **kwargs)
@method_decorator(csrf_protect)
def post(self, request, *args, **kwargs):
form = UploadFileForm(request.POST, request.FILES)
if not form.is_valid():
return JsonResponse(form.errors, status=400)
return JsonResponse({"saved_as": save_upload(request.FILES["file"])})
Using insert(0, …) puts the handler first so it sees every chunk before the defaults. With the 64 KB budget, a 200 KB
upload and a small one behaved like this against runserver:
$ curl -s -o /dev/null -w "%{http_code}\n" -b cj.txt -H "X-CSRFToken: $TOKEN" -F title=x -F file=@big.bin .../quota-upload/
400
$ curl -s -b cj.txt -H "X-CSRFToken: $TOKEN" -F title=x -F file=@cover.png .../quota-upload/
{"saved_as": "inbox/cover.png"}
$ curl -s -o /dev/null -w "%{http_code}\n" -F title=x -F file=@cover.png .../quota-upload/
403
The first answer is the form’s "required" error, the second shows that small files still reach the default handlers, and the
third shows csrf_protect rejecting a request without a token even though dispatch is exempt.
Limits belong in front of Django too
Django’s settings bound memory and field counts; they do not bound the body. The security guide says so plainly: limit
upload size in the web server (Apache’s LimitRequestBody, Nginx’s client_max_body_size, the proxy of your platform)
and do not rely solely on DATA_UPLOAD_MAX_MEMORY_SIZE or FILE_UPLOAD_MAX_MEMORY_SIZE. Under ASGI the entire request
may be spooled to disk before Django enforces its own limits, so a missing proxy limit becomes a disk-filling attack. The
MaxUploadSizeHandler above is useful for per-view quotas, but it runs after the proxy has already accepted the bytes.
Source: Django: User-uploaded content
FileField and ImageField
FileField and ImageField store a name in a varchar column (100 characters by default, set max_length for longer
paths) and nothing else: the bytes live in a storage, and the database holds a path relative to that storage’s root. That is
why the files are not in your database backups and why dumpdata only dumps names (see
Fixtures, Serialization & Initial Data). The general field
catalogue is in Models & Fields; this section covers what is specific to files.
| Option | Meaning |
|---|---|
|
A string or |
|
A storage object, or a callable returning one (3.1). Defaults to |
|
Length of the stored path, including |
|
Names of model fields that receive the image dimensions each time an image is set. Queryable, unlike the properties. |
|
Not supported on |
Source: Django: FileField and ImageField
ImageField needs Pillow (pip install pillow), validates that the upload really is an image Pillow can open, and adds
width and height. The Bookshelf Book model uses three file fields with three different needs: an image with
validators and a generated path, a public attachment partitioned by date, and a private file on its own storage.
# catalog/models.py
import uuid
from pathlib import PurePath
from django.core.files.storage import storages
from django.db import models
from .validators import MaxFileSize, validate_cover_extension, validate_image_content
def cover_path(instance, filename):
"""Never trust the client file name: keep only a normalised extension."""
ext = PurePath(filename).suffix.lower()
return f"covers/{instance.pk or 'new'}/{uuid.uuid4().hex}{ext}"
def private_storage():
"""Callable storage: migrations store a reference to this function, not a path."""
return storages["private"]
class Book(models.Model):
title = models.CharField(max_length=200)
cover = models.ImageField(
upload_to=cover_path,
width_field="cover_width",
height_field="cover_height",
blank=True,
validators=[validate_cover_extension, validate_image_content, MaxFileSize(2 * 1024 * 1024)],
)
cover_width = models.PositiveIntegerField(null=True, blank=True, editable=False)
cover_height = models.PositiveIntegerField(null=True, blank=True, editable=False)
excerpt = models.FileField(upload_to="excerpts/%Y/%m/", blank=True)
manuscript = models.FileField(storage=private_storage, upload_to="manuscripts/", blank=True)
def __str__(self):
return self.title
Source: Django: FileField and Django: Using a callable
A few details of upload_to deserve attention:
-
A callable receives the instance before it is saved.
instance.pkis usuallyNonefor a new object (the example falls back tonew), and a primary key assigned by the database cannot be used. AUUIDprimary key or a generated token solves it; so does a random component, as here. -
The directory is not a security boundary. Whatever the callable returns is combined with the storage location after
Storage.generate_filename()validated it, so a returned../..is rejected, but a user name or title interpolated into the path creates directories you did not plan for (and, on a case-insensitive file system, collisions). -
strftime()codes are filled at save time. Withexcerpts/%Y/%m/an excerpt saved on 2026-10-02 went toexcerpts/2026/10/excerpt.txt; the date partitions keep any single directory from growing without bound.
The FieldFile behind the attribute
Reading book.cover does not give you a string or a Python file object but a FieldFile, a proxy that offers the File
API (see The File API: File, ContentFile, ImageFile) and delegates to the field’s storage. Its extras are save(name, content, save=True) and
delete(save=True), plus the name, path, size and url properties, which call the corresponding storage methods.
book = Book.objects.create(title="Dune")
book.excerpt.save("excerpt.txt", ContentFile("héllo"), save=True)
print(book.excerpt.name) # excerpts/2026/10/excerpt.txt
print(book.excerpt.size) # 6 (bytes, not characters)
print(book.excerpt.url) # /media/excerpts/2026/10/excerpt.txt
print(book.excerpt.path) # /tmp/media-5f1h1nrm/excerpts/2026/10/excerpt.txt (FileSystemStorage only)
with book.excerpt.open("rb") as fh:
assert fh.read().decode() == "héllo"
book.excerpt.delete(save=True) # removes the file and clears the field
assert not book.excerpt # an empty FieldFile is falsy
book.refresh_from_db()
assert book.excerpt.name == ""
Source: Django: FileField and FieldFile
Points that bite in practice:
-
save()anddelete()also save the model by default. Passsave=Falsewhen you are about to callinstance.save()yourself, or you write the row twice. -
The on-disk name is not final until the model has been saved. The storage may rename the file to avoid a collision, so read
nameaftersave(), never before. -
pathexists only for local storage. On S3 or any remote backendFieldFile.pathraisesNotImplementedError; useopen()andurland keep your code storage-neutral. -
Deleting the row does not delete the file, and neither does replacing it (see Housekeeping: orphaned files).
-
Dimensions are updated at assignment, not after
FieldFile.save(). Since 5.1ImageField.update_dimension_fields(force=True)is no longer called after the image is saved to storage (so a storage that resizes the image cannot desynchronise the stored dimensions). In the scratch project, afterbook.cover.save(…)the in-memorybook.cover_widthwasNonewhilebook.cover.widthwas 8 and, afterrefresh_from_db(),cover_widthwas 8 as well; assigningbook.cover = ContentFile(data, name="d.png")and saving filled it at once. Prefer assignment or aModelForm, or refresh the instance. -
An image read from
FieldFileis closed. The official guide notes thatImage.open(car.photo)raisesValueError: seek of closed filebecause dimensions are cached but the file is not open; callbook.cover.open()first, or use the context manager as in the listing below.
from PIL import Image
with book.cover.open("rb") as fh:
with Image.open(fh) as image:
print(image.size, image.format) # (10, 20) PNG
Source: Django: Using files in models
Storage objects in migrations
A migration records the field’s arguments, including storage. A callable is serialised by reference, so the generated
migration contained storage=catalog.models.private_storage and makemigrations --check reported "No changes detected". A storage instance is serialised by its deconstruct() data: when the earlier scratch version held an
instance of FileSystemStorage(location=PRIVATE_ROOT), the migration froze the absolute path of one developer’s disk
(location=pathlib.PurePosixPath('/private/tmp/…/private')) and produced a new migration whenever the path changed.
Callables (or a storage whose options come from STORAGES) keep migrations portable; see
Migrations.
The cost of a callable is that it is evaluated once, when the model class is imported, so override_settings(STORAGES=…)
does not re-evaluate it. The official guide offers a LazyObject wrapper for tests; in the scratch project a test that has
to isolate a callable-storage field simply swaps the storage object on the field for the duration of the test:
from django.core.files.storage import FileSystemStorage
def test_private_manuscript(self):
private = FileSystemStorage(location=TMP_PRIVATE)
field = Book._meta.get_field("manuscript")
original, field.storage = field.storage, private
self.addCleanup(setattr, field, "storage", original)
...
Source: Django: Using a callable
The File API: File, ContentFile, ImageFile
Whenever Django needs to represent a file it uses django.core.files.File, a thin wrapper around a Python file object with
Django extras. Most of the time you receive one (an upload, a FieldFile); you construct one yourself when you need to
store something that already exists.
| Member | Meaning |
|---|---|
|
Wraps an open file; |
|
Name (relative to the storage root once stored), size in bytes, mode, and the wrapped object. In subclasses |
|
Reopens (and seeks to 0); also a context manager. Since 5.0 the positional and keyword arguments are passed on to the built-in |
|
Iterate in 64 KB blocks; whether more than one block is needed. |
iteration, |
Delegated to the wrapped file. |
|
A |
|
|
Source: Django: The File object
Attaching an existing file on disk to a field is the usual one-off task (a data import, a management command). Use File
inside a with block so the Python file is closed; forgetting it in a loop over thousands of rows ends in
OSError: [Errno 24] Too many open files.
from pathlib import Path
from django.core.files import File
path = Path("/data/specs.pdf")
book = Book.objects.get(title="Dune")
with path.open(mode="rb") as f:
book.excerpt = File(f, name=path.name)
book.save()
print(book.excerpt.name) # excerpts/2026/10/specs.pdf
Source: Django: Using files in models
File and ContentFile can be iterated by chunks regardless of the origin, which makes a copy loop trivial and
storage-neutral:
with File(open(path, "rb")) as fh:
print(fh.size, list(fh.chunks(5)), fh.multiple_chunks(5))
fh.open() # reopen: also seeks back to 0
print(fh.read())
Source: Django: File.chunks()
A recent change deserves a line: since 6.1 File is always truthy, where it used to evaluate according to its name. The
subclasses FieldFile, UploadedFile and its subclasses keep the old behaviour. In the scratch project
bool(File(io.BytesIO(b""))), with and without a name, and bool(ContentFile(b"")) all printed True, while
bool(Book(title="t").cover) was False. So if book.cover: still means "has a file", but if some_file: on a bare File
no longer tells you anything.
Source: Django 6.1: Miscellaneous
The Storage API
A storage is the object that understands a particular place for files. You rarely call it for model fields (the field
does), but you call it directly for anything else: a generated report, an export, a file you move around. There are two
ways to get one: default_storage (a lazy proxy for the storage under STORAGES["default"]) and
storages["alias"] for any alias you defined.
from django.core.files.base import ContentFile
from django.core.files.storage import default_storage, storages
path = default_storage.save("path/to/file", ContentFile(b"new content"))
print(path) # path/to/file
print(default_storage.size(path)) # 11
print(default_storage.open(path).read()) # b'new content'
print(default_storage.listdir("path/to")) # ([], ['file'])
print(default_storage.url(path)) # /media/path/to/file
print(default_storage.get_valid_name("my file (1).txt")) # my_file_1.txt
print(default_storage.get_available_name(path)) # path/to/file_3YtQDzQ
default_storage.delete(path)
print(default_storage.exists(path)) # False
print(default_storage.delete("nope")) # None: FileSystemStorage.delete() ignores a missing file
private = storages["private"] # the alias defined in STORAGES above
| Method | Contract |
|---|---|
|
Stores |
|
A |
|
Presence, removal, size in bytes. |
|
URL through which the file can be fetched, or |
|
Local file system path, or |
|
A pair of lists |
|
|
|
Keeps only letters, digits, periods and underscores (other characters are removed or replaced). |
|
Validates |
|
A name that does not exist yet, calling |
|
Adds an underscore and seven random characters before the extension (3.0). |
Source: Django: The Storage class
The FileSystemStorage implementation guards the root: saving ../escape.txt raised
SuspiciousFileOperation: Detected path traversal attempt in '../escape.txt', and delete("") raises ValueError since 4.0
(it used to fail with an assertion). Remember that every one of these methods is synchronous and blocking, and that a
remote storage turns each call into a network request; Sync and async covers the consequences.
The STORAGES setting
STORAGES (4.2) maps aliases to a BACKEND and optional OPTIONS, which are passed to the backend as keyword arguments
when it is created. It replaced DEFAULT_FILE_STORAGE and STATICFILES_STORAGE, which were deprecated in 4.2 and
removed in 5.1, together with get_storage_class(). Two aliases are special: default (the storage behind
FileField, ImageField and default_storage) and staticfiles (what collectstatic and the static template tag
use). Any other alias is yours, as private above. The setting replaces the default value instead of merging with it:
if you define only a custom default and forget staticfiles, the static files storage disappears. The details are in
Settings & Configuration.
STORAGES = {
"default": {"BACKEND": "django.core.files.storage.FileSystemStorage"},
"staticfiles": {"BACKEND": "django.contrib.staticfiles.storage.StaticFilesStorage"},
"exports": {
"BACKEND": "django.core.files.storage.FileSystemStorage",
"OPTIONS": {"location": "/srv/bookshelf/exports", "base_url": "/exports/"},
},
}
Source: Django: STORAGES
The storages handler object behaves like a dictionary (storages["exports"]), exposes the raw settings as
storages.backends, and offers create_storage(config) to build a storage from one STORAGES-style dictionary, which
helps packages that instantiate storages in their own tests. Under override_settings(STORAGES=…) Django resets the
handler, so storages["alias"] and default_storage follow the override. Here is what a test with a different default and
a custom alias saw:
with override_settings(STORAGES={
"default": {"BACKEND": "django.core.files.storage.InMemoryStorage"},
"staticfiles": {"BACKEND": "django.contrib.staticfiles.storage.StaticFilesStorage"},
"dict": {"BACKEND": "catalog.storage.DictStorage", "OPTIONS": {"base_url": "/d/"}},
}):
assert isinstance(storages["dict"], DictStorage)
print(storages.backends)
path = default_storage.save("x.txt", ContentFile(b"1"))
print(type(default_storage.open(path)).__name__, default_storage.url(path)) # InMemoryFileNode /media/x.txt
Source: Django: storages
FileSystemStorage
The default backend writes below MEDIA_ROOT and builds URLs from MEDIA_URL. Its constructor options are the
OPTIONS keys:
| Option | Meaning |
|---|---|
|
Absolute directory for the files; defaults to |
|
URL prefix that serves them; defaults to |
|
Modes for new files and directories; default to |
|
New in 5.1. When |
The private alias above has a trap worth seeing: leaving out base_url means the storage inherits MEDIA_URL, so a
private manuscript’s url was /media/manuscripts/draft.pdf, a link that looks public but 404s because the file is under
PRIVATE_ROOT, not MEDIA_ROOT. For storages whose files must never be linked, do not render their url anywhere, and
serve them through a view (Private files).
allow_overwrite replaced a private hook. FileSystemStorage.OS_OPEN_FLAGS was deprecated in 5.1 and removed in 6.0, and
subclasses that tuned it to allow overwriting must set the option instead. The effect, observed:
s = FileSystemStorage(location=tmp, allow_overwrite=True)
print(s.save("same.txt", ContentFile(b"1")), s.save("same.txt", ContentFile(b"2"))) # same.txt same.txt
print(FileSystemStorage(location=tmp).save("same.txt", ContentFile(b"3"))) # same_d2wDr0f.txt
Source: Django 5.1: File Storage and Django 6.0: Features removed
Overwriting is rarely what you want for user uploads (two users uploading cover.png must not replace each other); it is
useful for generated artefacts whose name is derived from the content or the object.
InMemoryStorage for fast tests
InMemoryStorage (4.2) keeps files in memory, with no persistence, so a test suite can write thousands of files without
touching the disk and without cleanup. Point default at it in the test settings (or with override_settings) and keep a
real FileSystemStorage for the few tests that check permissions or paths. path() is not meaningful on it.
# config/settings_test.py
from .settings import * # noqa: F401,F403
STORAGES = {
"default": {"BACKEND": "django.core.files.storage.InMemoryStorage"},
"staticfiles": {"BACKEND": "django.contrib.staticfiles.storage.StaticFilesStorage"},
"private": {"BACKEND": "django.core.files.storage.InMemoryStorage"},
}
Writing a custom storage
Write a storage class when no existing backend fits: a proprietary object store, an encrypted directory, a content-addressed
store. The recipe from the official how-to has five steps: subclass django.core.files.storage.Storage; make the class
instantiable without arguments (read defaults from django.conf.settings); implement _open() and _save(); override
path() only if the storage is local; and make the class deconstructible so migrations can serialise it, usually with
django.utils.deconstruct.deconstructible. Methods that raise NotImplementedError by default (delete, exists,
listdir, size, url) are optional: implement what the rest of your application calls. The hooks are:
| Hook | Contract |
|---|---|
|
Required. Return a |
|
Required. |
|
Customise how unsafe characters are replaced. The default keeps letters, digits, periods and underscores. |
|
Return a free name, within |
|
Build the suffix used on collisions. |
The class below is complete and runnable: a non-persistent storage that keeps bytes in a dictionary. It is a teaching tool, not a production backend (state is per process and lost on restart), but it shows every moving part, including the no-argument constructor that falls back to a setting.
# catalog/storage.py
from datetime import datetime, timezone
from io import BytesIO
from django.conf import settings
from django.core.files.base import File
from django.core.files.storage import Storage
from django.utils.deconstruct import deconstructible
@deconstructible
class DictStorage(Storage):
"""A tiny, non-persistent storage that keeps bytes in a dict (demo only)."""
def __init__(self, base_url=None):
# Django must be able to instantiate us with no arguments: fall back to settings.
self.base_url = base_url or getattr(settings, "DICT_STORAGE_URL", "/dict-files/")
self._files = {}
def _open(self, name, mode="rb"):
try:
return File(BytesIO(self._files[name][0]), name=name)
except KeyError:
raise FileNotFoundError(name) from None
def _save(self, name, content):
self._files[name] = (content.read(), datetime.now(timezone.utc))
return name # the name actually used
def exists(self, name):
return name in self._files
def delete(self, name):
self._files.pop(name, None)
def size(self, name):
return len(self._files[name][0])
def url(self, name):
return self.base_url + name
def listdir(self, path):
prefix = path.rstrip("/") + "/" if path else ""
dirs, files = set(), []
for n in self._files:
if n.startswith(prefix):
head, _, tail = n[len(prefix):].partition("/")
(dirs.add(head) if tail else files.append(head))
return sorted(dirs), sorted(files)
def get_modified_time(self, name):
return self._files[name][1]
Because the base class provides save(), get_available_name() and generate_filename(), the new storage inherited
collision handling for free, and a test confirmed the whole contract:
s = DictStorage()
name = s.save("a/b/hello.txt", ContentFile(b"hi")) # 'a/b/hello.txt'
assert s.open(name).read() == b"hi" and s.size(name) == 2
assert s.url(name) == "/dict-files/a/b/hello.txt"
assert s.listdir("a") == (["b"], [])
second = s.save("a/b/hello.txt", ContentFile(b"again")) # 'a/b/hello_pwgk0d4.txt'
s.delete(name)
with pytest.raises(FileNotFoundError):
s.open("missing")
print(DictStorage(base_url="/x/").deconstruct())
# ('catalog.storage.DictStorage', (), {'base_url': '/x/'})
Overriding a hook changes the policy without rewriting the backend. A content-addressed variant stores each upload under a
name derived from the SHA-256 of its bytes, so identical uploads are stored once and the client’s name is irrelevant. It
overrides save() (the public entry point) to compute the name, and get_available_name() so that an existing name is
reused instead of renamed:
import hashlib
from django.core.files.base import ContentFile
@deconstructible
class ContentAddressedStorage(DictStorage):
def get_available_name(self, name, max_length=None):
return name # same content, same name: nothing to avoid
def save(self, name, content, max_length=None):
data = content.read()
digest = hashlib.sha256(data).hexdigest()
ext = name.rsplit(".", 1)[-1].lower() if "." in name else "bin"
return super().save(f"{digest[:2]}/{digest}.{ext}", ContentFile(data), max_length)
Saving My Cover.JPG and other.jpg with the same bytes returned the same name,
09/0967115f2813a3541eaef77de9d9d5773f1c0c04314b0bbfe4ff3b3b1c55b5d5.jpg. Register it under an alias (the dict alias
shown earlier) and use it with storage="…" or a callable. Three cautions when you write a real backend: make _save()
atomic (write to a temporary name and rename), treat name as untrusted (join it only through the platform’s API, as
FileSystemStorage does), and make exists() and size() cheap, because Django calls them while resolving names and
rendering admin pages.
django-storages: S3 and compatible object stores
Object storage is the usual production answer: files survive redeploys and container restarts, any number of web processes share them, and a CDN can serve them. The third-party package django-storages supplies storage backends for Amazon S3, Google Cloud Storage, Azure Blob Storage, SFTP, FTP, Dropbox and Apache Libcloud, and its S3 backend works with S3-compatible services too (Backblaze B2, Cloudflare R2, DigitalOcean Spaces, Oracle Cloud, Scaleway, MinIO). It is a community package on the Django ecosystem page, not part of Django. Version 1.14.6 (2025-04-02) is the latest on PyPI at the time of writing; its trove classifiers stop at Django 5.1, and it worked unchanged with Django 6.1.1 in the scratch project (system checks, saving, reading, signing, deleting), so "supports" here means "verified on this stack", not "declared by the package".
pip install "django-storages[s3]" # installs boto3 as well
Source: django-storages: Amazon S3
Since Django 4.2 the package is configured through STORAGES OPTIONS; the old global AWS_* settings still exist but the
package documentation recommends the options (the old settings were global, so two buckets needed subclassing). Credentials
are searched in this order: session_profile, access_key/secret_key/security_token options, the AWS_S3_* or AWS_*
environment variables, and boto3’s default session (instance roles, SSO). Commit none of them: use an instance role, or
environment variables injected by the platform.
# config/settings_s3.py
import os
from .settings import * # noqa: F401,F403
# Credentials come from the environment (AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY),
# never from the repository.
STORAGES = {
"default": {
"BACKEND": "storages.backends.s3.S3Storage",
"OPTIONS": {
"bucket_name": os.environ.get("MEDIA_BUCKET", "bookshelf-media"),
"endpoint_url": os.environ.get("AWS_S3_ENDPOINT_URL"), # None for real AWS
"region_name": os.environ.get("AWS_S3_REGION_NAME", "eu-west-1"),
"location": "media", # key prefix inside the bucket
"file_overwrite": False, # the package default is True!
"querystring_auth": True, # signed, expiring URLs (the default)
"querystring_expire": 300, # seconds
"object_parameters": {"CacheControl": "private, max-age=300"},
},
},
"staticfiles": {"BACKEND": "django.contrib.staticfiles.storage.StaticFilesStorage"},
"private": STORAGES["private"],
}
Source: django-storages: Settings
The options most often needed:
| Option | Meaning |
|---|---|
|
Required. The bucket that holds the files. |
|
Key prefix prepended to every name; lets one bucket hold media and other data. |
|
Default |
|
Canned ACL such as |
|
Sign URLs (default |
|
Build URLs on your CDN domain (CloudFront needs |
|
Point at S3-compatible services; with a custom endpoint also set the region to avoid authorisation errors. |
|
Parameters for every |
|
Bytes kept in memory before rolling over to a temporary file (default 0, no rollover). |
|
Compress selected content types on upload (mostly for static files). |
Source: django-storages: Settings
The same listing doubles as the production configuration with the endpoint removed. To exercise it without an account, the
scratch project ran moto’s S3 server locally (moto_server -p 5555), created a bucket with boto3, and ran the storage
calls, with AWS_S3_ENDPOINT_URL and dummy credentials taken from the environment:
from django.core.files.base import ContentFile
from django.core.files.storage import default_storage
name = default_storage.save("covers/demo.txt", ContentFile(b"hello s3"))
print(name, default_storage.exists(name), default_storage.size(name)) # covers/demo.txt True 8
print(default_storage.save("covers/demo.txt", ContentFile(b"second"))) # covers/demo_NrTNNfB.txt
print(default_storage.url(name))
# http://127.0.0.1:5555/bookshelf-media/media/covers/demo.txt?AWSAccessKeyId=...&Signature=...&Expires=...
print(default_storage.listdir("covers")) # ([], ['demo.txt', 'demo_NrTNNfB.txt'])
default_storage.path(name) # NotImplementedError: S3 has no local path
print(default_storage.get_modified_time(name).tzinfo) # tzutc(): aware datetimes
Source: django-storages: Amazon S3 and moto: Server mode
With file_overwrite false the second save got a Django-style suffix; url() returned a signed query string that fetched
the object with a plain urllib request; and book.cover.save("c.png", …) on a Book stored covers/1/….png
in the bucket, with book.cover.url pointing at it. The signature scheme in the URL is a moto detail; a real AWS endpoint
returns X-Amz- parameters. A Book model needed no change: the field only knows default_storage. Test pieces that moto
cannot reproduce (bucket policies, CORS, CloudFront, KMS, IAM) against a real bucket in a staging account.
Notes for production:
-
Latency is part of the API.
exists(),size()andlistdir()are network calls. Rendering a list of 50 books that touchescover.sizemakes 50 requests; store the size in a column, or avoid calling it in loops.cover.urldoes not call the network (it signs locally). -
Signed URLs expire. With
querystring_auth=Trueevery page render yields different URLs, which defeats HTTP caching of the page; a CDN with a custom domain and public, immutable objects (content-hashed names) caches well. -
Static files can use the same package by pointing the
staticfilesalias at an S3 storage, but thencollectstaticuploads to the bucket; most sites serve static files from WhiteNoise or a CDN instead. -
Configuration is per alias. Two buckets (public media, private exports) are two aliases with different options.
-
Prefer the S3 transport settings you understand.
addressing_style,signature_version,client_configandtransfer_configexist for gevent compatibility, proxies and older regions; the package documents each.
Serving media
Django stores files; something else must serve them. The rule is: never serve user uploads from the Django application process in production, and never from the same origin as the application.
In development
During development django.conf.urls.static.static() appends a URL pattern that serves MEDIA_ROOT through
django.views.static.serve. It works only when DEBUG is true and only with a local prefix (media/), not a full URL, and
the official guide marks it "not suitable for production use".
# config/urls.py
from django.conf import settings
from django.conf.urls.static import static
from django.contrib import admin
from django.urls import include, path
urlpatterns = [
path("admin/", admin.site.urls),
path("", include("catalog.urls")),
] + static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
Against runserver, curl -I http://127.0.0.1:8391/media/covers/1/2da646c9609a4c63a7ece6e9ac09458e.png answered
200 OK with Content-Type: image/png. In a template, book.cover.url already includes MEDIA_URL; if you prefer the bare
prefix, add django.template.context_processors.media to the template context processors and use the MEDIA_URL
variable.
In production
With the default FileSystemStorage the usual setup is a web server (Nginx, Apache) or a CDN serving the directory behind
MEDIA_URL directly, with Django out of the path. A minimal Nginx fragment (validate against your own layout; this one was
not run):
location /media/ {
alias /srv/bookshelf/media/;
add_header X-Content-Type-Options "nosniff" always;
add_header Content-Security-Policy "default-src 'none'; img-src 'self'; style-src 'none'" always;
types { image/jpeg jpg jpeg; image/png png; image/webp webp; application/pdf pdf; }
default_type application/octet-stream;
}
client_max_body_size 6m; # the proxy-level limit, set above the largest legitimate upload
The types block makes Nginx answer with a content type from a short allow-list and treat everything else as
application/octet-stream, so an uploaded .html is downloaded, not rendered. With S3, serving comes with the storage
(url() returns the bucket or CDN address). Whichever you use, the deployment checklist is the same: serve from a
dedicated top-level or second-level domain (see Validating uploads safely), restrict methods to GET and HEAD, disable script
handlers such as mod_php in the media directory, and set Cache-Control according to whether the names change when the
content does.
Private files
Some files must be visible only to some users: manuscripts, invoices, exports. Give them a storage whose files are not under
any public prefix (the private alias), keep their URLs out of templates, and serve them through a view that checks
permission first and streams with FileResponse:
# catalog/views.py
from django.http import FileResponse, Http404, HttpResponseForbidden
from django.shortcuts import get_object_or_404
def manuscript(request, pk):
"""Serve a private file only to staff; the storage has no public URL."""
if not request.user.is_staff:
return HttpResponseForbidden("staff only")
book = get_object_or_404(Book, pk=pk)
if not book.manuscript:
raise Http404
response = FileResponse(book.manuscript.open("rb"), as_attachment=True, filename=f"book-{book.pk}.pdf")
response["X-Content-Type-Options"] = "nosniff"
return response
Source: Django: FileResponse objects
An anonymous request got 403; a staff user got 200 with the streamed bytes and the headers
Content-Disposition: attachment; filename="book-1.pdf" and X-Content-Type-Options: nosniff. as_attachment=True makes
the browser download the file instead of rendering it, and the explicit filename replaces the stored name. For large files
and many requests, a proxy can take over the transfer after your permission check (Nginx X-Accel-Redirect, Apache
X-Sendfile): the view returns an empty response with that header and the proxy streams the file from a protected location.
With S3 the equivalent is a short-lived signed URL (url() with querystring_auth=True) returned in a redirect after the
check. FileResponse and streaming responses are covered in depth on the responses page of this section.
Validating uploads safely
Everything about an upload except its bytes is chosen by the sender: the name, the declared type, the extension, the announced length. The security guide is blunt about the consequence: Django’s image handling lets an HTML file be uploaded as an image if it starts with a valid PNG header followed by markup, because Pillow’s verification accepts it, and "no bulletproof technical solution exists at the framework level to safely validate all user uploaded file content". Safety comes from layers, each assuming the previous one failed.
| Layer | What it does |
|---|---|
Proxy limit |
Bounds the body size before Django sees it (Limits belong in front of Django too). |
Extension allow-list |
|
Size |
Checked on the received file ( |
Content check |
Decode the bytes (Pillow |
Server-side name |
Generate the storage name; keep at most a normalised extension ( |
Safe serving |
Another registrable domain, |
Antivirus and processing |
For documents, scan asynchronously and re-encode images (a re-saved image drops embedded payloads). |
Source: Django: User-uploaded content
The validators are ordinary callables raising ValidationError, so they work on a model field, on a form field and in the
admin alike. Django’s own ImageField also runs the Pillow check and FileExtensionValidator with the extensions Pillow
knows, so ours narrow and add to it:
# catalog/validators.py
from django.core.exceptions import ValidationError
from django.core.validators import FileExtensionValidator
from django.utils.deconstruct import deconstructible
from PIL import Image, UnidentifiedImageError
ALLOWED_IMAGE_TYPES = {"JPEG": "image/jpeg", "PNG": "image/png", "WEBP": "image/webp"}
validate_cover_extension = FileExtensionValidator(["jpg", "jpeg", "png", "webp"])
@deconstructible
class MaxFileSize:
"""Reject files larger than ``limit`` bytes (checked after parsing)."""
def __init__(self, limit):
self.limit = limit
def __call__(self, value):
if value.size > self.limit:
raise ValidationError(
"File too large (%(size)d bytes, limit %(limit)d).",
code="file_too_large",
params={"size": value.size, "limit": self.limit},
)
def __eq__(self, other):
return isinstance(other, MaxFileSize) and self.limit == other.limit
def validate_image_content(value):
"""Decode the bytes: the extension and Content-Type are only hints."""
position = value.tell() if hasattr(value, "tell") else 0
try:
value.seek(0)
with Image.open(value) as image:
image.verify() # structural check; does not decode every pixel
if image.format not in ALLOWED_IMAGE_TYPES:
raise ValidationError(
"Unsupported image format %(format)s.",
code="bad_image_format",
params={"format": image.format},
)
if image.width * image.height > 25_000_000:
raise ValidationError("Image dimensions too large.", code="image_too_big")
except (UnidentifiedImageError, OSError, SyntaxError) as exc:
raise ValidationError("Not a valid image.", code="invalid_image") from exc
finally:
value.seek(position)
Source: Django: FileExtensionValidator and Validators
MaxFileSize is a class with @deconstructible because validators on a field are serialised into migrations; a plain
function would be referenced by name, a lambda cannot be serialised at all. The cases below were all posted through the
real form view; the errors are the strings the form returned.
| Upload | Result |
|---|---|
|
400 |
a real PNG named |
400 |
a 3 MB noise PNG |
400 |
a 120 x 180 PNG |
200, stored as |
The second row shows why an allow-list of your own matters: Django’s form field accepts any extension Pillow can read
(including pdf, eps and psd), which is wider than a book cover needs; ours restricts it on the model and on every
form built from it. The first row shows that the content check is the stronger one. What neither can see is a polyglot,
a file that is a valid PNG and also contains HTML or script after the image data: the bytes decode, the format is allowed,
the extension is right, and a browser that sniffs the content may still treat it as HTML. That is why the serving layer
has the last word.
Serving untrusted files safely
The official recommendations, in order of strength:
-
Serve uploads from a distinct top-level or second-level domain (for example
usercontent-example.comfor a site onexample.com; a subdomain such asusercontent.example.comis not sufficient), so the same-origin policy blocks cookie theft and script access even if a file executes. -
Allow only expected extensions and configure the server to serve only those.
-
Disable script execution on the media location (
mod_php, CGI, SSI). -
Send
X-Content-Type-Options: nosniffand a restrictiveContent-Security-Policy(Django 6.0 added built-in CSP support for pages it renders; for static media the web server must send the header, as in the Nginx fragment above). -
Use
Content-Disposition: attachmentfor anything that is not meant to be rendered inline. -
Avoid SVG uploads from untrusted users unless you sanitise them: an SVG is an XML document that can contain script, and it renders inline in a browser. Pillow does not read SVG at all, so the checks above reject it; accepting it means a separate sanitiser and a download-only disposition.
-
Re-encode images you display (open with Pillow, resize or convert, save as a new file) when you can afford it; that also strips metadata such as GPS coordinates and any appended payload.
Other traps seen in uploads: filenames with Unicode normalisation tricks or reserved Windows names (the server-side name
removes them), huge images that exhaust memory on decode ("decompression bombs": Pillow warns above its pixel limit; the
validator above rejects above 25 million pixels), zip files that expand enormously, and CSV or spreadsheet uploads with
formulas that a spreadsheet executes when opened (prefix cells that begin with =, +, - or @ when you export them
back).
Housekeeping: orphaned files
The database and the storage are two systems with no transaction between them, and Django deliberately does not synchronise
them. The reference says it explicitly: when a model is deleted, related files are not deleted, and neither are files
replaced by a new upload. In the scratch project, deleting a Book left its excerpt in storage, and uploading a second
cover over the first left the first on disk (the prune_covers command reported it as an orphan after the curl session).
Two defences exist and are usually combined.
Delete after commit. A post_delete receiver removes the files, but only after the surrounding transaction commits, so a
rollback does not lose files for rows that still exist. The storage and the name are bound in default arguments because the
instance is gone by the time the callback runs. See
Transactions, Raw SQL & Multiple Databases for
on_commit.
# catalog/signals.py
from django.db import transaction
from django.db.models.signals import post_delete
from django.dispatch import receiver
from .models import Book
FILE_FIELDS = ("cover", "excerpt", "manuscript")
@receiver(post_delete, sender=Book)
def delete_book_files(sender, instance, **kwargs):
"""Remove stored files only after the surrounding transaction commits."""
for name in FILE_FIELDS:
fieldfile = getattr(instance, name)
if fieldfile:
# bind the storage and name now; the instance is gone by commit time
storage, path = fieldfile.storage, fieldfile.name
transaction.on_commit(lambda s=storage, p=path: s.delete(p))
# catalog/apps.py
from django.apps import AppConfig
class CatalogConfig(AppConfig):
name = "catalog"
def ready(self):
from . import signals # noqa: F401
The test uses captureOnCommitCallbacks(execute=True) so the callbacks run inside `TestCase’s wrapping transaction:
def test_signal_deletes_after_commit(self):
book = Book.objects.create(title="T")
book.excerpt.save("e.txt", ContentFile(b"x"))
name = book.excerpt.name
with self.captureOnCommitCallbacks(execute=True):
book.delete()
self.assertFalse(default_storage.exists(name))
A receiver does not cover everything: it cannot see a file that was replaced by a new upload, rows removed by database-level
cascades (such as DB_CASCADE, new in 6.1), or a crash between the two steps. Add a periodic sweep as a custom management command (see
Management Commands & Shell) that compares what the storage
holds with what the database references, using only the Storage API so it works on S3 as well:
# catalog/management/commands/prune_covers.py
from django.core.files.storage import default_storage
from django.core.management.base import BaseCommand
from catalog.models import Book
def walk(storage, root=""):
"""Yield every file name below ``root`` using only the Storage API."""
try:
dirs, files = storage.listdir(root)
except FileNotFoundError: # FileSystemStorage raises this for a missing directory
return
for name in files:
yield f"{root}/{name}" if root else name
for d in dirs:
yield from walk(storage, f"{root}/{d}" if root else d)
class Command(BaseCommand):
help = "Delete files under covers/ that no Book references (dry run by default)."
def add_arguments(self, parser):
parser.add_argument("--delete", action="store_true")
def handle(self, *args, **options):
referenced = set(Book.objects.exclude(cover="").values_list("cover", flat=True))
orphans = [n for n in walk(default_storage, "covers") if n not in referenced]
for name in orphans:
self.stdout.write(("deleting " if options["delete"] else "orphan ") + name)
if options["delete"]:
default_storage.delete(name)
self.stdout.write(self.style.SUCCESS(f"{len(orphans)} orphan file(s)"))
The first version of this command crashed with FileNotFoundError on a fresh install, because FileSystemStorage.listdir()
raises it when the directory does not exist yet (the try above is the fix). In a test with one referenced cover and one
stray file, prune_covers printed orphan covers/stray.png and 1 orphan file(s), and --delete removed only the stray
file. Run it as a dry run first, on a schedule, and keep object versioning or a lifecycle rule on S3 buckets so an
over-eager sweep is recoverable. On S3, listing a prefix with millions of keys is slow and billed; the same job is usually
better done from the provider’s inventory reports.
Sync and async
The storage API and the File API are synchronous. Under ASGI nothing changes functionally, but every storage call inside
an async def view blocks the event loop for the duration of the disk or network access, which on S3 can be hundreds of
milliseconds. Wrap the calls with sync_to_async() or asyncio.to_thread(); the examples run in the scratch project:
import asyncio
from asgiref.sync import sync_to_async
from django.core.files.base import ContentFile
from django.core.files.storage import default_storage
async def go():
name = await sync_to_async(default_storage.save)("async.txt", ContentFile(b"z"))
data = await asyncio.to_thread(lambda: default_storage.open(name).read())
return name, data
print(asyncio.run(go())) # ('async.txt', b'z')
Source: Django: Asynchronous support and File storage API
Notes: model methods have asynchronous variants (asave(), adelete(), arefresh_from_db()), but FieldFile.save() and
FieldFile.delete() have none, so they follow the same wrapping rule; form validation (which reads and decodes the upload)
is synchronous and CPU-bound, so validate large images in a worker, not in the event loop; and on ASGI the request body may
be spooled to disk before your view starts, which is why the proxy limit is more important, not less. django-storages
backends are synchronous because boto3 is.
Where the books are outdated
Older tutorials and the books written for Django 1.11 to 3.x (see the bibliography on the index page of this section) teach a workflow that still runs but contains traps or removed names:
-
DEFAULT_FILE_STORAGEandSTATICFILES_STORAGE. Both were removed in 5.1, as wasget_storage_class(). UseSTORAGES(4.2). A project upgraded from 4.1 with these settings simply loses its custom storage, silently falling back to the local disk, until the setting is moved. -
Copying with
open(path, "wb"). Older examples build a path fromuploaded.nameand write it by hand. Usedefault_storage.save()or a model field;nameis client data. -
A
FileFieldalone as the validation. Books showFileFieldfollowed by trust in the content type or the extension. The security guide, and the polyglot behaviour above, say that is not enough. -
ImageFieldas a security check. It proves that Pillow can open the file, nothing more. -
MEDIA_ROOTserved by Django in production. Thestatic()helper works only withDEBUG = Trueand the guide marks it unsuitable for production. -
A multiple-file widget with
attrs={"multiple": True}. It stopped working in 4.2.1 (and 4.1.9, 3.2.19) with aValueError, because it validated only the last file; useallow_multiple_selectedand a field that cleans every file. -
No limit on the number of files.
DATA_UPLOAD_MAX_NUMBER_FILESexists since 4.1.7 (also 4.0.10, 3.2.18), default 100. -
FILE_UPLOAD_PERMISSIONS = Nonebehaviour. Before 3.0 uploaded files had different modes depending on their size; the default is now an explicit0o644. -
Storage code that assumed
path(). Anything that usesFieldFile.pathbreaks on S3; useopen()andurl. -
Calling
FileSystemStorage.save()withos.patharguments. Since 3.1 it acceptspathlib.Path, andupload_toacceptsPathsince 3.0; new code needs noos.path.join.
What changed recently
| Version | Change |
|---|---|
6.1 |
|
6.0 |
|
5.1 |
|
5.0 |
|
4.2 |
|
4.1.7 |
|
4.0 |
|
3.2 |
|
3.1 |
|
3.0 |
|
Checklist of pitfalls
-
Forgetting
enctype="multipart/form-data"orrequest.FILESin the form constructor: "This field is required". -
Using the client’s file name as a path or as the stored name; always take the name
save()returns. -
Reading a whole upload with
read()instead ofchunks(). -
Trusting
content_type, the extension orImageFieldalone; no proxy size limit; serving uploads from the application’s own origin withoutnosniff. -
Accepting
multipleon a plainFileField, which validates one file. -
Overriding
STORAGESwithout thestaticfilesalias, or leaving removedDEFAULT_FILE_STORAGEsettings behind. -
FileSystemStorageprivate storage whose inheritedbase_urlproduces public-looking but dead URLs. -
A storage instance on a field: the migration freezes its constructor arguments; use a callable.
-
FieldFile.save()/delete()saving the model twice, and dimension fields stale afterFieldFile.save()(5.1). -
Assuming files vanish with their rows: use
on_commitreceivers and a periodic sweep. -
Calling storage methods in loops over remote storages, or from
async defwithoutsync_to_async(). -
Changing
request.upload_handlersafterrequest.POSTwas read, or forgettingcsrf_protecton the processing method. -
Raising
StopUploadinhandle_raw_input(): it runs outside the parser’s error handling and becomes a 500. -
Real S3 behaviour not exercised by a local simulator: credentials, ACLs, CORS and CDN signing need a staging bucket.