Filter concepts by levelShowing all levels.

Django · Media Files and Uploads

Media Files and Uploads

MEDIA_ROOT/FileField, treating every upload as untrusted input, and object storage with pre-signed URLs

  • MEDIA_ROOT/MEDIA_URL mirror STATIC_ROOT/STATIC_URLfor user uploads — never served directly by runserver in production
  • Treat every uploaded file as untrusted inputvalidate real content (decode it), never trust Content-Type or the extension
  • Django never auto-deletes an old FileField filereassignment or model deletion leaves it orphaned — explicit cleanup required
  • Pre-signed URLs let clients upload directly to storageserver-generated, time-limited, bypassing the app server entirely

1.MEDIA_ROOT, MEDIA_URL, FileField/ImageField, and upload handlers

MEDIA_ROOT and MEDIA_URL are user-uploaded content's equivalent of STATIC_ROOT/STATIC_URL — MEDIA_ROOT is the filesystem directory uploads are saved into, MEDIA_URL the prefix they're served under. FileField stores an uploaded file and exposes it as a FieldFile (with .url, .path, .size); ImageField is FileField plus dimension validation and (if Pillow is installed) automatic width/height population, and requires Pillow to even validate as an image field. Upload handlers (UploadedFile subclasses, configured via FILE_UPLOAD_HANDLERS) are the pluggable layer deciding HOW an uploaded file's bytes are received during the request — MemoryFileUploadHandler for small files (kept in memory), TemporaryFileUploadHandler for anything over FILE_UPLOAD_MAX_MEMORY_SIZE (streamed to a temp file on disk instead), chosen automatically based on size.

Key features

  • MEDIA_ROOT is the filesystem directory FileField/ImageField save uploads into; MEDIA_URL is the URL prefix they're served under — the same two-setting split as STATIC_ROOT/STATIC_URL, for a different kind of content
  • FileField.upload_to (a string path template or a callable) controls WHERE within MEDIA_ROOT a given upload is placed — a callable receives (instance, filename) and can build a per-user or per-date path
  • A FieldFile (what accessing a FileField/ImageField attribute actually returns) exposes .url, .path, .size, .name — not the raw bytes; .open()/.read() access content, and Django does NOT delete the old file automatically when a FileField is reassigned or the model is deleted
  • ImageField requires Pillow to be installed to validate as an image at all — without Pillow, ImageField's validation silently cannot confirm the upload is a real, decodable image
  • MemoryFileUploadHandler and TemporaryFileUploadHandler are the two default FILE_UPLOAD_HANDLERS, tried in order — a file at or under FILE_UPLOAD_MAX_MEMORY_SIZE (default 2.5MB) is handled in memory; anything larger streams to a temp file on disk automatically
  • runserver conveniently serves MEDIA_ROOT directly when DEBUG=True (via a URLconf helper), but Django explicitly documents this as unsuitable for production — a real deployment needs a web server, CDN, or object-storage backend to serve media

Static vs media, side by side

Static vs media, side by side
ConcernStatic filesMedia files
Originshipped with the codebaseuploaded by users at runtime
Root settingSTATIC_ROOTMEDIA_ROOT
URL settingSTATIC_URLMEDIA_URL
Trust leveltrusted (developer-controlled)untrusted — needs validation, see the sibling security concept
An upload picks a handler by size, automatically
Client
Upload handlers
FileField
  1. 1POST multipart file
  2. 2size ≤ FILE_UPLOAD_MAX_MEMORY_SIZE → MemoryFileUploadHandler
  3. 3size > threshold → TemporaryFileUploadHandler (disk)
  4. 4saved under MEDIA_ROOT via upload_to
  1. Client → Upload handlers: POST multipart file
  2. Upload handlers → Upload handlers: size ≤ FILE_UPLOAD_MAX_MEMORY_SIZE → MemoryFileUploadHandler
  3. Upload handlers → Upload handlers: size > threshold → TemporaryFileUploadHandler (disk)
  4. Upload handlers → FileField: saved under MEDIA_ROOT via upload_to

Command

python
class Document(models.Model):
    file = models.FileField(upload_to="documents/")

instance.file.url    # MEDIA_URL + relative path
instance.file.path    # absolute filesystem path

MEDIA_ROOT/MEDIA_URL mirror STATIC_ROOT/STATIC_URL for user-uploaded content — never served directly by runserver's convenience in production. upload_to (string or callable) controls placement within MEDIA_ROOT. Django never auto-deletes a FileField's old file on reassignment or model deletion — that cleanup is explicit, application-level responsibility. FILE_UPLOAD_MAX_MEMORY_SIZE picks memory-vs-disk handling, not a size cap.

2.Size, MIME, filename, and image validation — every upload is untrusted input

Django validates almost nothing about an uploaded file by default beyond what a field type implies (ImageField checks it decodes as an image, via Pillow) — file SIZE, MIME type, and filename safety are the application's own responsibility to check explicitly. Size validation means checking .size against an explicit limit in a form/serializer validator, since Django has no single built-in max-upload-size setting. MIME validation means checking the file's ACTUAL content type (e.g. via Pillow attempting to open it as an image, or a dedicated content-sniffing library), never trusting the browser-supplied Content-Type header or the file extension alone, both of which are trivially spoofable. Filename sanitization matters because Django's storage layer already handles path-traversal characters safely (get_valid_name()/get_available_name() strip and de-duplicate), but an application displaying or trusting the ORIGINAL filename elsewhere (e.g. in a Content-Disposition header, a log line, a shell command) can still be exposed to injection if that raw string isn't treated as untrusted.

Key features

  • Django has no single built-in setting capping individual uploaded file size — that validation is written explicitly, typically a form/serializer field checking uploaded_file.size against a defined maximum
  • The Content-Type header on an uploaded file is CLIENT-supplied and trivially spoofable — never trust it alone; ImageField's real validation works by actually attempting to decode the file with Pillow, which fails on non-image content regardless of what header claimed otherwise
  • A file extension is similarly just a naming convention the client chose — validating "is this really a JPEG" means checking file content/magic bytes (what Pillow's decode attempt effectively does for images), not string-matching the extension
  • FileSystemStorage.get_valid_name() and get_available_name() already sanitize path-traversal characters and de-duplicate filenames on the storage side — Django's own file-saving path is not vulnerable to a malicious filename escaping MEDIA_ROOT
  • The remaining filename risk is application code using the ORIGINAL, client-supplied filename elsewhere unsafely — e.g. in a Content-Disposition response header without proper escaping, which has historically been a real header-injection vector in various web frameworks
  • validate_image_file_extension and FileExtensionValidator are Django-provided validators for checking an extension against an allow-list, but both are documented as a weak, easily-bypassed check on their own (the extension is just a string) — meant to be one layer, not the only one

What to validate, and why the naive check is insufficient

What to validate, and why the naive check is insufficient
CheckNaive (insufficient) approachReal approach
File typetrust the Content-Type header or file extensionactually decode/inspect the content (Pillow for images)
File sizeassume Django enforces a limitexplicit .size check in a form/serializer validator
Filenametrust it's safe because Django saved it without erroringtreat the raw original filename as untrusted anywhere else it's used
A spoofed extension and Content-Type pass a naive check

malicious.jpg

actual bytes: an HTML/script payload

Naive check

filename ends in .jpg? Content-Type: image/jpeg?

Passes — both are client-controlled strings

Real check: Image.open(...).verify()

actually decodes the bytes

Rejected — not decodable as an image

  • malicious.jpg — actual bytes: an HTML/script payload
    • leads to Naive check
    • leads to Real check: Image.open(...).verify()
  • Naive check — filename ends in .jpg? Content-Type: image/jpeg?
    • on error, leads to Passes — both are client-controlled strings
  • Passes — both are client-controlled strings
  • Real check: Image.open(...).verify() — actually decodes the bytes
    • leads to Rejected — not decodable as an image
  • Rejected — not decodable as an image

Command

python
def validate_size(value):
    if value.size > MAX_SIZE:
        raise ValidationError("File too large.")

class Document(models.Model):
    file = models.FileField(validators=[validate_size])

Django validates almost nothing about an upload by default beyond what the field type implies — size, real content-based MIME checking (actually decoding, not trusting Content-Type or the extension), and safe reuse of the original filename are all explicit application responsibilities. Django's storage layer already sanitizes filenames for the SAVE path; any other reuse of the raw filename (a header, a log, a shell command) needs its own treatment as untrusted input.

3.Object storage, S3, pre-signed URLs, streaming, and lifecycle

DEFAULT_FILE_STORAGE (STORAGES["default"] as of Django 4.2+) is the swappable backend FileField/ImageField actually save through — swapping it from the local filesystem to an S3-compatible object store (via a third-party backend like django-storages) usually requires zero model changes, since FieldFile's API (.url, .open(), .delete()) stays the same regardless of backend. A pre-signed URL is a time-limited, cryptographically-signed URL granting temporary direct access (upload or download) to a specific object in a private bucket — generated server-side, used client-side, so large file transfers never have to proxy through the Django app server at all. Streaming means reading/writing a file in CHUNKS (FileField.chunks(), StreamingHttpResponse) rather than loading it entirely into memory — necessary once files are large enough that "just read the whole thing" risks memory exhaustion. Storage lifecycle refers to rules (typically configured on the object-storage side, e.g. S3 lifecycle policies) for automatically transitioning or deleting objects after a defined age — moving old files to cheaper storage tiers, or removing genuinely temporary ones.

Key features

  • STORAGES["default"] (Django 4.2+; DEFAULT_FILE_STORAGE is the pre-4.2 equivalent) is the swappable backend every FileField/ImageField saves through — django-storages provides S3Boto3Storage (and other cloud backends) as drop-in replacements for the local FileSystemStorage default
  • Because FieldFile's API (.url, .path is NOT guaranteed to exist for non-local backends, .open(), .delete()) stays consistent across backends, swapping storage is typically a settings change — code calling .path on a remote-storage field is the one common exception that breaks, since there IS no local filesystem path
  • A pre-signed URL is generated server-side (the app has real AWS/GCS credentials and calls a "generate a signed URL" API) but used entirely client-side — the browser or client uploads/downloads directly against the object store, never routing the file bytes through the Django app server
  • Pre-signed URLs are time-limited (an expiration is part of what makes the signature valid) — a leaked pre-signed URL is a real but bounded exposure, not a permanent one, unlike a permanently public object
  • FileField.chunks() reads a file in memory-bounded pieces — the documented, correct way to process a large uploaded file (hashing it, scanning it, copying it elsewhere) without loading the entire thing into memory at once
  • Storage lifecycle rules (e.g. S3 Lifecycle configuration) are typically configured on the storage provider directly, not in Django — "delete objects in this prefix after 30 days" or "transition to a cheaper storage class after 90 days" are bucket-level policies Django never needs to enforce itself

Local storage vs object storage, the practical differences

Local storage vs object storage, the practical differences
ConcernLocal filesystemObject storage (S3-compatible)
.path availabilityalways worksusually raises NotImplementedError — no local path exists
Serving large filesproxied through the app server unless a web server serves MEDIA_ROOT directlypre-signed URLs let clients transfer directly, bypassing the app server
Multi-server deploymentsneeds a shared filesystem/NFS, or files are only visible on one servernaturally shared — every app server instance sees the same bucket
Lifecycle/retentiona custom cron job or management commandoften a native bucket-level policy
A pre-signed URL lets the client upload directly, bypassing the app server
Client
Django app
Object storage (S3)
  1. 1request an upload URL
  2. 2authorize, then generate_presigned_url()
  3. 3signed URL, expires in 10 min
  4. 4PUT file bytes directly
  1. Client → Django app: request an upload URL
  2. Django app → Django app: authorize, then generate_presigned_url()
  3. Django app → Client: signed URL, expires in 10 min
  4. Client → Object storage (S3): PUT file bytes directly

Command

python
for chunk in uploaded_file.chunks():
    process(chunk)   # bounded memory, regardless of file size

Django's storage abstraction (STORAGES["default"]) is what makes swapping local disk for S3-compatible object storage usually a settings-only change — except .path, which only works for filesystem-backed storage; use .open()/.chunks()/.url for backend-agnostic code. Pre-signed URLs let a client transfer large files directly against object storage, generated server-side (with an expiration) but used client-side, bypassing the app server entirely. Storage lifecycle rules typically live as bucket-level policies, not Django code.

Remember

  • MEDIA_ROOT/MEDIA_URL mirror STATIC_ROOT/STATIC_URL for uploads; upload_to (string or callable) controls placement, and old files are never auto-deleted on reassignment or model deletion.
  • Validate an upload's real content (actually decode it), never trust the client-supplied Content-Type header or the file extension alone — both are trivially spoofable.
  • FILE_UPLOAD_MAX_MEMORY_SIZE picks memory-vs-disk handling, not a size cap — file size limits are the application's own explicit responsibility.
  • Django's storage abstraction makes swapping local disk for S3-compatible storage usually a settings-only change — except .path, which only works for filesystem-backed storage.

Pro tip

  • Use .chunks() (not .read()) to process a potentially-large upload — bounded memory regardless of file size.
  • Storage lifecycle rules (auto-delete or tier-transition after N days) typically live as bucket-level policies, not Django code.

Next up

  • Django REST Framework
  • DRF Views
  • DRF Authentication

Sources

  • Managing files — docs.djangoproject.com/en/5.2/topics/files/
  • Every claim checked against Django 5.2 LTS docs · checked 2026-08-24

Spotted a mistake on this page? Tell us.