Filter concepts by levelShowing all levels.

Django · Section 44

Media Files and Uploads

Level
advanced
Read
28 min
Concepts
3

MEDIA_ROOT/MEDIA_URL are user-uploaded content's equivalent of STATIC_ROOT/STATIC_URL — the filesystem directory and URL prefix for uploads, never served directly by runserver's convenience in production. FileField/ImageField store uploads via a FieldFile (.url, .open(), .chunks()), with upload_to (string or callable) controlling placement; Django never auto-deletes the old file on reassignment or model deletion, which is explicit application responsibility. The roadmap's own explicit security framing — treat every uploaded file as untrusted input — means size, real content-based type validation (actually decoding a file, never trusting the client-supplied Content-Type header or extension), and safe handling of the original filename anywhere it's reused are all the application's job, not something Django enforces by default. Django's storage abstraction (STORAGES["default"]) makes swapping local disk for S3-compatible object storage usually a settings-only change, and pre-signed URLs let a client transfer large files directly against that storage — generated server-side with an expiration, used client-side, bypassing the app server entirely — while .chunks() keeps processing of a large file's content memory-bounded regardless of size.

What is true here

  1. MEDIA_ROOT/MEDIA_URL mirror STATIC_ROOT/STATIC_URL for uploads — Django never auto-deletes an old FileField file on reassignment or deletion.
  2. Treat every uploaded file as untrusted input: validate real content, never trust the client-supplied Content-Type header or file extension alone.
  3. FILE_UPLOAD_MAX_MEMORY_SIZE only picks memory-vs-disk handling — file size limits are an explicit application-level validator.
  4. Django's storage abstraction makes swapping to S3-compatible object storage usually a settings-only change, except for .path.
  5. Pre-signed URLs let a client upload/download directly against object storage, bypassing the app server — generated server-side, time-limited.

What you will be able to do

  • Configure MEDIA_ROOT/MEDIA_URL and FileField/ImageField upload paths correctly
  • Validate an uploaded file's real content, size, and filename as genuinely untrusted input
  • Explain why Django never auto-deletes an old file, and clean one up explicitly when needed
  • Use pre-signed URLs and streaming to handle large file transfers without routing them through the app server or exhausting memory

MEDIA_ROOT, FileField/ImageField, and upload handlers

The settings, field types, and how Django receives an upload's bytes during a request.

MEDIA_ROOT, MEDIA_URL, FileField/ImageField, and upload handlers

coreintermediate

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.

Think of it as

MEDIA_ROOT/MEDIA_URL mirroring STATIC_ROOT/STATIC_URL is not a coincidence — both solve the same underlying problem (where do these files live on disk, and what URL serves them), just for content with a different origin: static files ship WITH the code, media files are uploaded by users AFTER deployment. That distinction is exactly why Django never serves MEDIA_ROOT in production the way runserver conveniently does in development — user uploads are untrusted, arbitrarily large, and need their own serving/storage strategy (often object storage, not a local directory, once the deployment is not a single small server). Upload handlers exist as a pluggable layer because "how do I receive these bytes" has a real trade-off Django doesn't want to hardcode: buffering a small upload in memory is fast and simple, but doing that for a large file risks exhausting server memory — the automatic switch to a temp-file handler past FILE_UPLOAD_MAX_MEMORY_SIZE is Django choosing the safer strategy once a file crosses a size threshold, without application code needing to think about it. FileField vs ImageField being genuinely different fields (not ImageField as a thin subclass alias) reflects that image-specific concerns — validating it's actually a decodable image, knowing its dimensions — need Pillow and don't apply to an arbitrary uploaded file.

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

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

What we're doing: A per-user upload path using a callable upload_to, keeping each user's files in their own directory.

documents/models.pypython
def user_upload_path(instance, filename):
    return f"users/{instance.owner_id}/documents/{filename}"

class Document(models.Model):
    owner = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE)
    file = models.FileField(upload_to=user_upload_path)
    uploaded_at = models.DateTimeField(auto_now_add=True)
1
upload_to as a callable receives (instance, filename) — instance is available even before the model is saved, since Django knows the model instance being built, just not its final pk yet.
7
The RAW filename argument (line 2) is user-controlled input — see the sibling concept for why it must be validated/sanitized, not trusted directly, even though this example focuses on the path structure.

Why this works: A callable upload_to naturally organizes uploads per-user (or per-date, per-tenant) without any extra bookkeeping — the directory structure alone makes it trivial to locate or bulk-manage one user's files, and avoids dumping every upload into one flat directory.

Assuming reassigning a FileField or deleting its model instance also deletes the old file from storage

Wrong

python
document.file = new_upload
document.save()   # the OLD file is still sitting in MEDIA_ROOT, orphaned

Better

python
old_file = document.file
document.file = new_upload
document.save()
if old_file:
    old_file.delete(save=False)   # explicit — Django does not do this automatically

What you see: Storage usage grows steadily over time with orphaned files that no model instance references anymore — reassigning a FileField or deleting the owning row never triggers an automatic cleanup of the underlying file.

Why: Django deliberately does NOT delete a FileField's underlying file automatically on reassignment or model deletion — the documented reasoning is that a file could be referenced from more than one place, or intentionally kept for other reasons, so automatic deletion could destroy something still needed. Explicit cleanup (a signal receiver, an override of delete(), or a scheduled job) is the documented responsibility of the application, not something the field handles on its own.

An upload picks a handler by size, automatically
Client
Upload handlers
FileField
  1. 1. POST multipart file
  2. 2. size ≤ FILE_UPLOAD_MAX_MEMORY_SIZE → MemoryFileUploadHandler
  3. 3. size > threshold → TemporaryFileUploadHandler (disk)
  4. 4. saved 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

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

Together

python
MEDIA_URL = "/media/"
MEDIA_ROOT = BASE_DIR / "media"

class Profile(models.Model):
    avatar = models.ImageField(upload_to="avatars/%Y/%m/")

Remember: 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.

See also: upload validation as untrusted input · object storage and presigned urls · specialized and file fields

Advertisement

Every upload is untrusted input

Size, real content-based MIME validation, and safe filename handling.

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

coreadvanced

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.

Think of it as

The roadmap's own framing — treat every uploaded file as untrusted input — is really the same principle as SQL injection or XSS applied to a different kind of input: whatever the client claims about a file (its name, its declared Content-Type, even its extension) is just a claim, not a fact, and trusting any of those claims without independent verification is what actually creates the vulnerability. A file named "photo.jpg" with a browser-supplied Content-Type of image/jpeg can still contain arbitrary bytes — a script, a polyglot file, anything — because both the filename and the Content-Type header are supplied by the client and can be set to whatever an attacker wants. This is why real MIME validation means inspecting the file's actual bytes (what Pillow does when it tries to genuinely decode an image, failing on anything that isn't one) rather than checking metadata the client controls. Filename sanitization has a similar shape but a narrower risk: Django's own storage backend already prevents a malicious filename from escaping MEDIA_ROOT via path traversal, so the main remaining risk is an application using the raw, attacker-controlled filename somewhere ELSE unsafely — a header, a log, a shell command — where its own project-specific escaping/validation matters, and none of that is automatically handled just because Django's storage layer is safe.

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

What we're doing: A ModelForm field with real, content-based validation for an uploaded profile picture — size, actual image content, and a safe extension allow-list layered together.

accounts/forms.pypython
def validate_avatar(uploaded_file):
    max_size = 2 * 1024 * 1024
    if uploaded_file.size > max_size:
        raise ValidationError("Image must be under 2MB.")

    from PIL import Image
    try:
        image = Image.open(uploaded_file)
        image.verify()   # raises if the content isn't actually a valid image
    except Exception:
        raise ValidationError("Upload is not a valid image file.")

class ProfileForm(forms.ModelForm):
    avatar = forms.ImageField(validators=[validate_avatar])

    class Meta:
        model = Profile
        fields = ["avatar"]
4
Size is checked FIRST, cheaply, before attempting the more expensive Pillow decode — avoids wasting work decoding a file that's going to be rejected anyway.
9
Image.verify() genuinely attempts to parse the file as an image — this is real content validation, not a check against client-supplied metadata like the Content-Type header.

Why this works: forms.ImageField already runs Pillow-based validation on its own, but layering an explicit size check and being deliberate about what "valid image" means (verify(), not just "Pillow didn't immediately crash on the header") documents the validation as an intentional decision rather than an implicit default a future reader has to go verify.

Validating an upload's type by checking its file extension or client-supplied Content-Type header alone

Wrong

python
def validate_upload(uploaded_file):
    if not uploaded_file.name.lower().endswith((".jpg", ".png")):
        raise ValidationError("Only JPG/PNG allowed.")
    # a file named "malicious.jpg" containing an HTML/script payload PASSES this check

Better

python
def validate_upload(uploaded_file):
    from PIL import Image
    try:
        Image.open(uploaded_file).verify()
    except Exception:
        raise ValidationError("Not a valid image file.")

What you see: A file that isn't actually an image at all — renamed to end in .jpg, or with a spoofed Content-Type — passes validation and gets stored and later served, potentially as a vector for stored XSS (if ever served with a browser-sniffable content type) or simply as unexpected, unvalidated content masquerading as an image throughout the rest of the application.

Why: Both the filename and the Content-Type header are entirely client-controlled strings with no enforced relationship to the file's actual bytes — an attacker can set either to whatever value defeats a naive check. Only actually attempting to parse/decode the content (what Image.verify() does) confirms the file genuinely is what it claims to be.

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

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

Together

python
def validate_upload(uploaded_file):
    if uploaded_file.size > 5 * 1024 * 1024:
        raise ValidationError("File too large (max 5MB).")
    try:
        Image.open(uploaded_file).verify()   # actually decodes — real content check
    except Exception:
        raise ValidationError("Not a valid image.")

Remember: 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.

See also: media root filefield and upload handlers · object storage and presigned urls · custom validation

Advertisement

Object storage, pre-signed URLs, and streaming

Swapping to S3-compatible storage, direct client uploads, large files, and lifecycle policies.

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

coreadvanced

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.

Think of it as

Django's storage API is deliberately an ABSTRACTION specifically so "where do file bytes actually live" can change without application code caring — a FileField doesn't know or care whether .save() writes to local disk or issues an S3 PutObject call, because both are hidden behind the same Storage interface. This is why the migration from local storage to S3 is usually a settings-only change, not a model rewrite: the abstraction was the whole point. Pre-signed URLs exist because proxying large file uploads/downloads THROUGH the Django app server is genuinely wasteful once files are large or frequent — every byte would otherwise pass through an app server's memory and network capacity for no reason, when the object store itself is perfectly capable of receiving the upload directly. A pre-signed URL is the mechanism that makes "direct, but still controlled" possible: it grants temporary, scoped access without making the bucket itself public, since only someone holding the correctly-signed URL (generated server-side, after whatever authorization check the app wants to run) can use it, and only for a limited time. Streaming and lifecycle policies are both responses to the same underlying fact — files can be large and numerous in ways request/response cycles and unlimited storage were never designed to absorb — streaming solves the per-request memory problem, lifecycle policies solve the aggregate storage-cost-over-time problem, at the infrastructure layer rather than in application code.

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

What we're doing: Generate a pre-signed upload URL server-side so a client can upload a large video file directly to S3, never routing the bytes through Django.

uploads/views.pypython
import boto3

def get_presigned_upload_url(request):
    key = f"uploads/{request.user.id}/{uuid.uuid4()}.mp4"
    s3 = boto3.client("s3")
    url = s3.generate_presigned_url(
        "put_object",
        Params={"Bucket": "my-app-uploads", "Key": key},
        ExpiresIn=600,   # 10 minutes
    )
    return JsonResponse({"upload_url": url, "key": key})
4
The object key is generated server-side, namespaced by user id — the client never chooses where its upload actually lands.
8
ExpiresIn bounds how long the signed URL remains valid — short enough to limit exposure if the URL leaks, long enough for a real upload to complete.

Why this works: A 2GB video file proxied through the Django app server would tie up a worker process/thread for the entire upload duration, for no benefit — a pre-signed URL lets the client upload directly to S3, freeing the app server to do only the cheap part: deciding whether the upload should be allowed at all, and where.

Calling FieldFile.path on a model using a non-local (S3-compatible) storage backend

Wrong

python
def process_upload(document):
    with open(document.file.path) as f:   # assumes a local filesystem path exists
        ...

Better

python
def process_upload(document):
    with document.file.open("rb") as f:   # works across every storage backend
        ...

What you see: NotImplementedError raised the moment .path is accessed — the app worked perfectly in development (local FileSystemStorage, where .path is meaningful) and breaks immediately after switching to S3 in production, where there is no local filesystem path for the underlying object.

Why: .path is explicitly documented as backend-specific — only storage backends actually backed by a local filesystem implement it meaningfully. .open() (and .chunks(), .url) are the storage-agnostic API every backend supports, which is exactly why code meant to work across both local and object storage should use those instead of assuming a filesystem path exists.

A pre-signed URL lets the client upload directly, bypassing the app server
Client
Django app
Object storage (S3)
  1. 1. request an upload URL
  2. 2. authorize, then generate_presigned_url()
  3. 3. signed URL, expires in 10 min
  4. 4. PUT 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

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

Together

python
# settings.py
STORAGES = {
    "default": {"BACKEND": "storages.backends.s3.S3Storage"},
    "staticfiles": {"BACKEND": "django.contrib.staticfiles.storage.StaticFilesStorage"},
}
AWS_STORAGE_BUCKET_NAME = "my-app-uploads"

Remember: 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.

See also: media root filefield and upload handlers · upload validation as untrusted input

Advertisement