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

enctype="multipart/form-data", method="post", pass request.FILES to the form, never read request.FILES before CSRF is checked.

Receiving a file: request.FILES

Several files in one field

Subclass the widget (allow_multiple_selected = True) and the field (validate every file).

Several files in one field

Big uploads

Small files stay in memory (2.5 MB), larger ones stream to a temporary file; replace or add handlers with FILE_UPLOAD_HANDLERS.

Where the bytes live: upload handlers

Model fields

FileField/ImageField store a name in a varchar column; the bytes live in a storage, not in the database.

FileField and ImageField

Saving and reading files in code

File, ContentFile and FieldFile.save(); default_storage or storages["alias"] for storage-level calls.

The File API: File, ContentFile, ImageFile, The Storage API

Where files are stored

The STORAGES setting (4.2): default for media, staticfiles for static files; defining it replaces the defaults.

The STORAGES setting

A new backend

Subclass Storage, implement _open() and _save() (plus exists, delete, size, url…​), decorate with @deconstructible.

Writing a custom storage

S3 and compatible stores

django-storages S3Storage through STORAGES OPTIONS; credentials from the environment.

django-storages: S3 and compatible object stores

Serving files

static() in development only; a web server or CDN in production; a view with FileResponse for private files.

Serving media, Private files

Untrusted content

Validate extension, size, decoded content and dimensions; rename on disk; serve from another domain with nosniff.

Validating uploads safely

Housekeeping

Deleting a model row does not delete its files; clean up after commit or with a command.

Housekeeping: orphaned files

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.

flowchart LR browser["Browser
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

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>

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()})

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

name

The file name sent by the client, already stripped of directory components. Display it, do not build paths from it.

size

Size in bytes after the upload was received. Reliable for limits, because Django counted the bytes itself.

content_type

The Content-Type header of the part. Client-controlled; "trust but verify". For an ImageField, Django replaces it with the type Pillow detected, or None.

content_type_extra, charset

Extra parameters of the header, and the charset for text/* parts. Same trust level.

read()

Reads everything into memory. Fine for small, size-checked files; use chunks() for anything else.

chunks(chunk_size=None)

Generator of byte chunks (64 KB by default). The default way to copy an upload.

multiple_chunks(chunk_size=None)

True if the file is larger than the chunk size (2.5 MB is the default threshold for the built-in handlers).

iteration

Yields lines with universal newlines, convenient for CSV or log uploads.

TemporaryUploadedFile.temporary_file_path()

Only on the disk-backed subclass: the path of the temporary file, handy for tools that need a real path.

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")

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",
]

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

FILE_UPLOAD_MAX_MEMORY_SIZE

2621440 (2.5 MB)

Largest upload held in memory by MemoryFileUploadHandler; above it the data is streamed to disk.

FILE_UPLOAD_TEMP_DIR

None (the OS temporary directory)

Where TemporaryFileUploadHandler writes. Put it on a volume with enough space and the right permissions.

FILE_UPLOAD_PERMISSIONS

0o644

Mode of saved files. Since 3.0 the default is explicit; with None the mode depends on the handler and the umask. The temporary files are not affected. Always write the 0o prefix.

FILE_UPLOAD_DIRECTORY_PERMISSIONS

None

Mode of directories created while saving files.

DATA_UPLOAD_MAX_MEMORY_SIZE

2621440 (2.5 MB)

Maximum request body size excluding file data, checked when request.body or request.POST is read. RequestDataTooBig is a SuspiciousOperation (400).

DATA_UPLOAD_MAX_NUMBER_FILES

100

Maximum number of file parts per multipart request (TooManyFilesSent).

DATA_UPLOAD_MAX_NUMBER_FIELDS

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')}

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

handle_raw_input(input_data, META, content_length, boundary, encoding)

Optional. Called before parsing; return None to continue, or a (POST, FILES) tuple to take over parsing completely. It runs outside the parser’s error handling, so do not raise StopUpload here (the exception becomes a 500).

new_file(field_name, file_name, content_type, content_length, charset, content_type_extra)

Optional. A new file part starts. May raise StopFutureHandlers so later handlers ignore this file. Call super().new_file() to store the arguments on the handler.

receive_data_chunk(raw_data, start)

Required. Gets each chunk (chunk_size bytes, 64 KB by default; the smallest value among handlers wins). Return the data to pass it on, return None to swallow it, raise StopUpload to abort the upload or SkipFile to skip this file.

file_complete(file_size)

Required. The file ended. Return an UploadedFile to finish, or None to let a later handler do it.

upload_complete()

Optional. All files are done.

upload_interrupted()

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.

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

upload_to

A string or Path appended to the storage location (may contain strftime() codes such as uploads/%Y/%m/), or a callable (instance, filename) returning a Unix-style relative path. The result is passed to Storage.save().

storage

A storage object, or a callable returning one (3.1). Defaults to default_storage.

max_length

Length of the stored path, including upload_to. A longer generated name fails; the storage truncates when it can.

ImageField.width_field, height_field

Names of model fields that receive the image dimensions each time an image is set. Queryable, unlike the properties.

primary_key

Not supported on FileField.

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

A few details of upload_to deserve attention:

  • A callable receives the instance before it is saved. instance.pk is usually None for a new object (the example falls back to new), and a primary key assigned by the database cannot be used. A UUID primary 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. With excerpts/%Y/%m/ an excerpt saved on 2026-10-02 went to excerpts/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 == ""

Points that bite in practice:

  • save() and delete() also save the model by default. Pass save=False when you are about to call instance.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 name after save(), never before.

  • path exists only for local storage. On S3 or any remote backend FieldFile.path raises NotImplementedError; use open() and url and 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.1 ImageField.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, after book.cover.save(…​) the in-memory book.cover_width was None while book.cover.width was 8 and, after refresh_from_db(), cover_width was 8 as well; assigning book.cover = ContentFile(data, name="d.png") and saving filled it at once. Prefer assignment or a ModelForm, or refresh the instance.

  • An image read from FieldFile is closed. The official guide notes that Image.open(car.photo) raises ValueError: seek of closed file because dimensions are cached but the file is not open; call book.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

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)
    ...

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

File(file_object, name=None)

Wraps an open file; name defaults to the file’s name.

name, size, mode, file

Name (relative to the storage root once stored), size in bytes, mode, and the wrapped object. In subclasses file may not be a real Python file, so prefer the subclass’s own attributes.

open(mode=None, *args, **kwargs)

Reopens (and seeks to 0); also a context manager. Since 5.0 the positional and keyword arguments are passed on to the built-in open().

chunks(chunk_size=None), multiple_chunks()

Iterate in 64 KB blocks; whether more than one block is needed.

iteration, read, readline, write, seek, tell, close…​

Delegated to the wrapped file.

ContentFile(content, name=None)

A File over a str or bytes value. No file on disk is needed.

ImageFile

django.core.files.images.ImageFile adds width and height.

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

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())

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.

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

save(name, content, max_length=None)

Stores content (a File or file-like object) under a free variant of name; returns the name actually used.

open(name, mode="rb")

A File (possibly a subclass; for remote storages reading may be slow). Missing files raise FileNotFoundError.

exists(name), delete(name), size(name)

Presence, removal, size in bytes.

url(name)

URL through which the file can be fetched, or NotImplementedError if the storage has no URLs.

path(name)

Local file system path, or NotImplementedError for storages that are not on the local disk.

listdir(path)

A pair of lists (directories, files); may be NotImplementedError.

get_accessed_time, get_created_time, get_modified_time

datetime values (aware when USE_TZ is true); may be NotImplementedError.

get_valid_name(name)

Keeps only letters, digits, periods and underscores (other characters are removed or replaced).

generate_filename(filename)

Validates filename (rejects traversal) and calls get_valid_name() on the final component, keeping any upload_to path.

get_available_name(name, max_length=None)

A name that does not exist yet, calling get_alternative_name() on collisions; SuspiciousFileOperation if none is found.

get_alternative_name(file_root, file_ext)

Adds an underscore and seven random characters before the extension (3.0).

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/"},
    },
}

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

FileSystemStorage

The default backend writes below MEDIA_ROOT and builds URLs from MEDIA_URL. Its constructor options are the OPTIONS keys:

Option Meaning

location

Absolute directory for the files; defaults to MEDIA_ROOT.

base_url

URL prefix that serves them; defaults to MEDIA_URL.

file_permissions_mode, directory_permissions_mode

Modes for new files and directories; default to FILE_UPLOAD_PERMISSIONS and FILE_UPLOAD_DIRECTORY_PERMISSIONS.

allow_overwrite

New in 5.1. When True a save replaces an existing file; the default False keeps writing under a new name.

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

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

_open(name, mode="rb")

Required. Return a File (or subclass); raise FileNotFoundError for a missing file.

_save(name, content)

Required. name has already passed through get_valid_name() and get_available_name(); content is a File. Return the name actually used.

get_valid_name(name)

Customise how unsafe characters are replaced. The default keeps letters, digits, periods and underscores.

get_available_name(name, max_length=None)

Return a free name, within max_length; raises SuspiciousFileOperation when none is found.

get_alternative_name(file_root, file_ext)

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

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"],
}

The options most often needed:

Option Meaning

bucket_name

Required. The bucket that holds the files.

location

Key prefix prepended to every name; lets one bucket hold media and other data.

file_overwrite

Default True: the same name replaces the object. Set False to get Django’s rename-on-collision behaviour; for user uploads this is almost always the right value.

default_acl

Canned ACL such as public-read. Default None: objects are private, as S3 defaults. Prefer bucket policies and signed URLs over public ACLs.

querystring_auth, querystring_expire

Sign URLs (default True, valid 3600 s) or return plain URLs for public buckets.

custom_domain

Build URLs on your CDN domain (CloudFront needs cloudfront_key and cloudfront_key_id for signed URLs).

endpoint_url, region_name, addressing_style

Point at S3-compatible services; with a custom endpoint also set the region to avoid authorisation errors.

object_parameters

Parameters for every put_object (CacheControl, ContentDisposition, StorageClass, SSEKMSKeyId, Metadata…​).

max_memory_size

Bytes kept in memory before rolling over to a temporary file (default 0, no rollover).

gzip, gzip_content_types

Compress selected content types on upload (mostly for static files).

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

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() and listdir() are network calls. Rendering a list of 50 books that touches cover.size makes 50 requests; store the size in a column, or avoid calling it in loops. cover.url does not call the network (it signs locally).

  • Signed URLs expire. With querystring_auth=True every 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 staticfiles alias at an S3 storage, but then collectstatic uploads 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_config and transfer_config exist 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

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). DATA_UPLOAD_MAX_* bound memory and counts, not bytes.

Extension allow-list

FileExtensionValidator(["jpg", "jpeg", "png", "webp"]). Never a deny-list, never based on the content type.

Size

Checked on the received file (value.size), not on Content-Length.

Content check

Decode the bytes (Pillow verify()), check the decoded format against an allow-list and bound the pixel count.

Server-side name

Generate the storage name; keep at most a normalised extension (cover_path above).

Safe serving

Another registrable domain, X-Content-Type-Options: nosniff, attachment disposition for non-images, no script execution.

Antivirus and processing

For documents, scan asynchronously and re-encode images (a re-saved image drops embedded payloads).

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)

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

evil.png containing <script>alert(1)</script>

400 Upload a valid image. The file you uploaded was either not an image or a corrupted image. (the form field’s own Pillow check rejects it before our validators run)

a real PNG named page.html

400 File extension "html" is not allowed. Allowed extensions are: bmp, dib, gif, jfif, …​ (the form field’s extension validator, with Pillow’s long list of formats)

a 3 MB noise PNG

400 File too large (2998075 bytes, limit 2097152).

a 120 x 180 PNG

200, stored as covers/1/<32 hex>.png, dimensions 120 x 180

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:

  1. Serve uploads from a distinct top-level or second-level domain (for example usercontent-example.com for a site on example.com; a subdomain such as usercontent.example.com is not sufficient), so the same-origin policy blocks cookie theft and script access even if a file executes.

  2. Allow only expected extensions and configure the server to serve only those.

  3. Disable script execution on the media location (mod_php, CGI, SSI).

  4. Send X-Content-Type-Options: nosniff and a restrictive Content-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).

  5. Use Content-Disposition: attachment for anything that is not meant to be rendered inline.

  6. 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.

  7. 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')

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_STORAGE and STATICFILES_STORAGE. Both were removed in 5.1, as was get_storage_class(). Use STORAGES (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 from uploaded.name and write it by hand. Use default_storage.save() or a model field; name is client data.

  • A FileField alone as the validation. Books show FileField followed by trust in the content type or the extension. The security guide, and the polyglot behaviour above, say that is not enough.

  • ImageField as a security check. It proves that Pillow can open the file, nothing more.

  • MEDIA_ROOT served by Django in production. The static() helper works only with DEBUG = True and 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 a ValueError, because it validated only the last file; use allow_multiple_selected and a field that cleans every file.

  • No limit on the number of files. DATA_UPLOAD_MAX_NUMBER_FILES exists since 4.1.7 (also 4.0.10, 3.2.18), default 100.

  • FILE_UPLOAD_PERMISSIONS = None behaviour. Before 3.0 uploaded files had different modes depending on their size; the default is now an explicit 0o644.

  • Storage code that assumed path(). Anything that uses FieldFile.path breaks on S3; use open() and url.

  • Calling FileSystemStorage.save() with os.path arguments. Since 3.1 it accepts pathlib.Path, and upload_to accepts Path since 3.0; new code needs no os.path.join.

What changed recently

Version Change

6.1

File is always truthy (FieldFile and the UploadedFile family keep name-based truthiness). MultiPartParser validates Base64-encoded parts strictly; invalid data raises MultiPartParserError.

6.0

FileSystemStorage.OS_OPEN_FLAGS removed (deprecated in 5.1); use allow_overwrite. Built-in Content Security Policy support.

5.1

FileSystemStorage(allow_overwrite=True) allows replacing files; DEFAULT_FILE_STORAGE, STATICFILES_STORAGE and get_storage_class() removed; saving a FileField file without a name raises FieldError; update_dimension_fields(force=True) no longer called after saving an image.

5.0

File.open() passes positional and keyword arguments to the built-in open(); ImageField.update_dimension_fields() skipped on post_init when no dimension fields are set.

4.2

STORAGES setting; InMemoryStorage; allow_multiple_selected introduced as the opt-in for multi-file widgets (4.2.1 security release).

4.1.7

DATA_UPLOAD_MAX_NUMBER_FILES added (security release, also 4.0.10 and 3.2.18).

4.0

FileSystemStorage.delete("") raises ValueError instead of AssertionError.

3.2

FileUploadHandler.upload_interrupted().

3.1

FileField/ImageField accept a callable storage; FileSystemStorage.save() supports pathlib.Path.

3.0

Storage.get_alternative_name(); FILE_UPLOAD_PERMISSIONS defaults to 0o644; upload_to supports Path.

Source: Django 6.1, 6.0, 5.1, 5.0, 4.2, 4.1.7, 4.0, 3.2, 3.1 and 3.0

Checklist of pitfalls

  • Forgetting enctype="multipart/form-data" or request.FILES in 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 of chunks().

  • Trusting content_type, the extension or ImageField alone; no proxy size limit; serving uploads from the application’s own origin without nosniff.

  • Accepting multiple on a plain FileField, which validates one file.

  • Overriding STORAGES without the staticfiles alias, or leaving removed DEFAULT_FILE_STORAGE settings behind.

  • FileSystemStorage private storage whose inherited base_url produces 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 after FieldFile.save() (5.1).

  • Assuming files vanish with their rows: use on_commit receivers and a periodic sweep.

  • Calling storage methods in loops over remote storages, or from async def without sync_to_async().

  • Changing request.upload_handlers after request.POST was read, or forgetting csrf_protect on the processing method.

  • Raising StopUpload in handle_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.