Back to blog
By Rasul

Django File Uploads: A Production Pipeline

Build a Django file upload pipeline with size limits, layered validation, direct S3 uploads, malware scanning, cleanup, and tests.

A production Django file upload should be a stateful pipeline, not one FileField assignment. Send small files through Django; send large or failure-prone uploads directly to private object storage. For direct uploads, create an owner-scoped reservation before issuing a signature. For multipart uploads, cap the request at the edge, then create the row before persisting the spooled file to application storage. In both paths, validate in layers, scan outside the request, and expose only immutable accepted bytes after a server-controlled transition to ready.

The key distinction is simple: bytes received does not mean file accepted. Treat transport, validation, and publication as separate steps:

  1. Enforce a full-request cap before Django consumes the body.
  2. Authorize the user and create an owner-scoped upload record.
  3. Store bytes under an opaque key in a private quarantine location.
  4. Verify the stored object's key, size, checksum, and detected type.
  5. Scan or transform the file in a background job.
  6. Mark it ready only after every required check passes.
  7. Authorize every download or issue a short-lived signed URL.
  8. Reconcile abandoned records, orphaned objects, and incomplete multipart uploads.

This guide focuses on that lifecycle. If you only need storage backend configuration, use the separate Django S3 storage guide. It was verified against Django 6.1 on August 6, 2026.

On this page

Should uploads pass through Django or go directly to S3?

Use a normal multipart request when files are small, upload volume is modest, and server-side form handling is useful. The browser sends the file to Django; Django's upload handlers buffer it in memory or a temporary file; your application validates it and saves it through the configured storage backend. This path is easy to reason about, and Django's forms integrate naturally with it.

Use a direct object-storage upload when files are large, mobile connections need resumability, or proxy and application-worker capacity should not carry the file body. Django authorizes the operation and returns a narrowly scoped presigned request. The browser sends the bytes to a private bucket, then calls a finalization endpoint. Django verifies the stored object before it queues the same scanning and publication steps used by the multipart path.

The choice changes the byte path, not the trust boundary:

Concern Through Django Direct to object storage
Initial authorization In the view, after edge and upload-handler processing but before application persistence Before issuing the signature
Byte-size enforcement Edge limit plus application validation POST policy or signed-request contract plus final HEAD check
Django upload handlers Yes No
Application memory/disk pressure Yes, bounded by handlers and limits Minimal
Resumable multipart support Custom work Native provider/SDK support
Final content validation Required Required
Malware scan and publication gate Background state transition Background state transition

Do not choose direct upload merely because S3 is enabled. It adds CORS, signature expiry, finalization, abandoned-object cleanup, and more failure states. Choose it when those costs solve a measured transport problem.

What does Django's 2.5 MB upload setting mean?

Django does not reject files above 2.5 MB by default. Its default upload handlers keep smaller files in memory and stream larger files to a temporary file. FILE_UPLOAD_MAX_MEMORY_SIZE, which defaults to 2.5 MB, controls that memory-to-disk boundary. It is not a per-file limit. The Django 6.1 upload documentation and setting reference describe the two default handlers and this threshold.

DATA_UPLOAD_MAX_MEMORY_SIZE is not a substitute. Django's setting documentation states that file data in request.FILES is excluded from its calculation. Set an explicit business limit for each file class, cap the full request body at the reverse proxy or application server, and keep those two values aligned. Django's security guidance specifically recommends a fronting-server request-body limit, particularly for ASGI deployments.

When processing an UploadedFile, iterate over chunks() instead of calling read() on an arbitrarily large body:

from hashlib import sha256

def checksum_uploaded_file(uploaded_file):
    digest = sha256()
    for chunk in uploaded_file.chunks():
        digest.update(chunk)
    uploaded_file.seek(0)
    return digest.hexdigest()

Chunked reading bounds application memory. It does not enforce a maximum upload size, protect temporary-disk capacity, or stop a proxy from accepting a larger request. Those are separate controls.

What state should a file upload have?

For direct uploads, create the database row before issuing the storage signature. For multipart uploads, the proxy and Django upload handlers receive or spool the body before the view runs; create the row after authorization and before persisting the file through application storage. The row becomes the authority for ownership, expected shape, storage location, validation results, and cleanup. A minimal lifecycle is:

reserved -> uploaded -> scanning -> ready
    |           |           |
    +-----------+-----------+-> rejected -> deleted

Do not make the original filename the identifier or object key. Keep it only as sanitized display metadata. Generate an opaque key that includes the tenant boundary and an upload UUID.

import uuid

from django.conf import settings
from django.db import models


class Upload(models.Model):
    class Status(models.TextChoices):
        RESERVED = "reserved"
        UPLOADED = "uploaded"
        SCANNING = "scanning"
        READY = "ready"
        REJECTED = "rejected"
        DELETED = "deleted"

    id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
    owner = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE)
    object_key = models.CharField(max_length=500, unique=True)
    accepted_key = models.CharField(max_length=500, unique=True, null=True)
    accepted_version = models.CharField(max_length=255, blank=True)
    original_name = models.CharField(max_length=255)
    declared_type = models.CharField(max_length=100, blank=True)
    detected_type = models.CharField(max_length=100, blank=True)
    accepted_types = models.JSONField(default=list)
    max_bytes = models.PositiveBigIntegerField()
    expires_at = models.DateTimeField()
    size = models.PositiveBigIntegerField(null=True)
    checksum_sha256 = models.CharField(max_length=64, blank=True)
    object_version = models.CharField(max_length=255, blank=True)
    status = models.CharField(
        max_length=16,
        choices=Status.choices,
        default=Status.RESERVED,
    )
    created_at = models.DateTimeField(auto_now_add=True)
    updated_at = models.DateTimeField(auto_now=True)

Add constraints for your domain: allowed transitions, tenant quota, expiry, the actor who approved or rejected the file, and a non-sensitive reason code. Keep the state transition and domain side effects idempotent. A repeated finalize request or duplicate storage event must not publish twice.

The database and object store are not one transaction. A storage write may succeed before a database update fails, or the row may commit before a worker sees the object. Design reconciliation for both cases rather than pretending the operation is atomic.

How should Django validate an uploaded file?

Validation should become stricter as work becomes more expensive. A practical order is:

  1. Authenticate the uploader and check tenant role, quota, and purpose.
  2. Reject unsupported extensions using an allowlist.
  3. Enforce per-file, request, and file-count limits.
  4. Treat the submitted Content-Type as a hint, not evidence.
  5. Inspect a bounded byte prefix with a maintained type-detection library.
  6. Parse or decode the file with the library that will consume it.
  7. Scan for malware or apply content disarm and reconstruction where the risk warrants it.
  8. Store and serve it from an isolated, private origin.

Neither an extension nor UploadedFile.content_type proves what the bytes contain. Django calls the MIME value user-supplied, and its FileExtensionValidator documentation warns that a file can be renamed to any extension. File-signature checks are also only one signal.

Even ImageField is not a complete security boundary. Django's user-uploaded content guidance documents that a file can pass image-library checks yet contain HTML that a server later interprets. Serve user content from a distinct top-level or second-level domain—not merely a subdomain of the application origin—and set safe response types and download headers.

The OWASP File Upload Cheat Sheet recommends the same defense-in-depth shape: allowlisted extensions, application-generated names, bounded sizes, authorized uploaders, isolated storage, CSRF protection, and scanning or sandboxing when appropriate. It also warns that no single check is sufficient. For archives, enforce limits on the expanded contents, not just the compressed object.

The Django validator reference states that model validators are not automatically executed by model.save(). A ModelForm runs its included field validators; direct model assignment needs an explicit validation call or service-layer checks. Keep security-critical validation in a service that every HTML, API, and background path calls.

How do you handle a small multipart upload?

The HTML form must use multipart/form-data, and the view must bind both request.POST and request.FILES:

<form method="post" enctype="multipart/form-data">
  {% csrf_token %}
  {{ form.as_p }}
  <button type="submit">Upload</button>
</form>
from pathlib import PurePath

from django import forms

MAX_DOCUMENT_BYTES = 10 * 1024 * 1024
ALLOWED_DOCUMENT_EXTENSIONS = {".pdf"}


class DocumentUploadForm(forms.Form):
    file = forms.FileField()

    def clean_file(self):
        uploaded = self.cleaned_data["file"]
        extension = PurePath(uploaded.name).suffix.lower()
        if extension not in ALLOWED_DOCUMENT_EXTENSIONS:
            raise forms.ValidationError("Upload a PDF file.")
        if uploaded.size > MAX_DOCUMENT_BYTES:
            raise forms.ValidationError("The file is larger than 10 MB.")
        return uploaded

The view should reserve a row, run the shared validation service, write to a quarantine key through Django's storage API, update the record, and enqueue the scan only after the transaction commits:

from django.contrib.auth.decorators import login_required
from django.core.files.storage import default_storage
from django.db import transaction
from django.shortcuts import redirect, render


@login_required
def upload_document(request):
    form = DocumentUploadForm(request.POST or None, request.FILES or None)
    if request.method == "POST" and form.is_valid():
        incoming = form.cleaned_data["file"]
        upload = reserve_upload(owner=request.user, incoming=incoming)
        saved_key = default_storage.save(upload.object_key, incoming)
        with transaction.atomic():
            upload = Upload.objects.select_for_update().get(pk=upload.pk)
            mark_uploaded(upload=upload, saved_key=saved_key, size=incoming.size)
            transaction.on_commit(
                lambda upload_id=upload.pk: enqueue_scan_task(upload_id)
            )
        return redirect("upload_status", upload_id=upload.pk)
    return render(request, "uploads/new.html", {"form": form})

reserve_upload, mark_uploaded, and the queue call are application services in this example. Make reserve_upload enforce authorization and return a fresh key. Verify that saved_key matches the expected naming policy; storage backends may alter a colliding name. A failed database transaction after the storage write can still leave an orphan, so the cleanup job remains required.

Do not scan a large object synchronously in the web request. Persist progress and send the record ID—not the file bytes—to the worker. The Django background-task guide covers queue selection, idempotency, retries, and user-visible job state.

Why is request.FILES empty?

For Django's standard form flow, request.FILES is populated only when the request is POST, the form uses enctype="multipart/form-data", and a named file input actually submits a file. For a JavaScript client, append the file to FormData; do not send it as an ordinary JSON field or manually replace the browser-generated multipart boundary.

For multiple files, validate every item returned by request.FILES.getlist("files"). Do not attach multiple to a single-file field and assume one validation result covers the whole collection. The Django multiple-file example uses a multiple-selection widget and a field whose clean() method validates each file. In most SaaS domains, give each accepted file its own upload row so ownership, status, errors, and cleanup remain independent.

How do you implement a direct S3 upload?

A direct flow needs three server endpoints or equivalent service operations:

  • Reserve: authorize the user, create the reserved row, and return a presigned POST for its exact private key.
  • Finalize: derive the key from the owned row, inspect and freeze the object outside a database lock, then lock briefly and transition once to uploaded.
  • Status: return the owner-scoped scan state without exposing the bucket or credentials.

Prefer a presigned POST when you need an S3 policy condition for maximum size. Boto3's generate_presigned_post supports exact fields and conditions:

def create_upload_post(*, s3, bucket, upload, max_bytes):
    content_type = require_allowed_declared_type(upload.declared_type)
    return s3.generate_presigned_post(
        Bucket=bucket,
        Key=upload.object_key,
        Fields={"Content-Type": content_type},
        Conditions=[
            {"Content-Type": content_type},
            ["content-length-range", 1, max_bytes],
        ],
        ExpiresIn=300,
    )

The content-type condition constrains the submitted field; it does not prove the bytes have that type. Keep the expiry as short as the real upload permits. AWS describes presigned URLs as bearer tokens that can be used repeatedly until they expire, and a PUT to an existing key replaces the object. Generate a fresh, unguessable key for every reservation. See the S3 presigned URL security properties.

At finalization, use server credentials to inspect the exact bucket and key. Confirm ownership from the database—not a key supplied by the client—then compare the observed size and checksum with the reservation. If the client sent a supported checksum, S3 can validate it during upload; an ETag is not a universal whole-object MD5 value. AWS documents the distinction in its object-integrity guide. S3 can verify that received bytes match the supplied checksum. That does not prove intended identity or benign content.

A compact finalization service should inspect outside a database lock, then make the state change in a short transaction:

FINALIZED_UPLOAD_STATUSES = {
    Upload.Status.UPLOADED,
    Upload.Status.SCANNING,
    Upload.Status.READY,
    Upload.Status.REJECTED,
}


def finalize_direct_upload(*, owner, upload_id):
    reservation = Upload.objects.get(pk=upload_id, owner=owner)
    if reservation.status in FINALIZED_UPLOAD_STATUSES:
        return reservation
    require_live_reservation(reservation)
    observed = inspect_reserved_object(
        key=reservation.object_key,
        version=reservation.object_version or None,
    )
    frozen = freeze_reserved_object(
        source=observed,
        destination_key=fresh_server_only_key(upload_id),
        require_absent=True,
    )

    with transaction.atomic():
        upload = Upload.objects.select_for_update().get(pk=upload_id, owner=owner)
        if upload.status in FINALIZED_UPLOAD_STATUSES:
            return upload
        require_live_reservation(upload)
        verify_frozen_object(upload=upload, frozen=frozen)
        mark_uploaded_from_object(upload=upload, observed=frozen)
        transaction.on_commit(
            lambda upload_id=upload.pk: enqueue_scan_task(upload_id)
        )
        return upload

inspect_reserved_object derives the bucket from server configuration and the key from the owned row. freeze_reserved_object binds the source with its VersionId or CopySourceIfMatch, creates a fresh destination in a prefix the upload signer cannot write, and prohibits replacing an existing destination. Use If-None-Match: * or the provider's equivalent write-once condition. If a provider cannot conditionally create a copy, enable versioning and persist the exact destination VersionId instead. A destination-exists response is an idempotent replay only when a server inspection matches the expected source, size, and checksum; it must never trigger an overwrite.

mark_uploaded_from_object persists the winning accepted key and version. The scanner and download path use that exact immutable object. A later reuse of the presigned request may replace quarantine bytes, but it cannot replace accepted bytes. The freeze is another non-transactional write, so a concurrent loser or database failure can leave an object for reconciliation.

CORS only allows the browser origin and method to make the cross-origin request. It does not authorize an object or make the bucket public. The S3 CORS documentation keeps access policies as a separate check. Keep the bucket private, narrowly allow the application origin and required headers, and preserve IAM and signature checks.

For very large objects, a provider's multipart API can retry individual parts. AWS calls multipart upload a best practice around 100 MB and above, but that is provider guidance rather than a universal application threshold. Measure your network and SDK behavior before choosing a cutoff.

How do scanning and finalization work?

The worker receives an upload ID, locks or conditionally updates the record, and returns immediately if it is already terminal. A safe outline is:

@transaction.atomic
def begin_scan(upload_id):
    upload = Upload.objects.select_for_update().get(pk=upload_id)
    if upload.status in {Upload.Status.READY, Upload.Status.REJECTED}:
        return None
    if upload.status != Upload.Status.UPLOADED:
        raise InvalidUploadState(upload.status)
    upload.status = Upload.Status.SCANNING
    upload.save(update_fields=["status", "updated_at"])
    return upload.pk

Do the slow object download and scanner call outside the database lock. Then open a short transaction, confirm the row is still scanning, record the non-sensitive result, and transition to ready or rejected. Do not publish on scanner errors, unsupported formats, timeouts, or unavailable dependencies unless the product has an explicit, reviewed fail-open policy.

Object-storage events can help start work, but they are not your source of truth. Amazon S3 Event Notifications are designed for at-least-once delivery, so consumers must be idempotent. A user-triggered finalize endpoint plus a periodic reconciler gives you a recovery path when either the browser callback or an event is missing.

The ready transition may also create a thumbnail, extract safe metadata, or move/copy the object from a quarantine prefix. Define which step makes the file visible. If a copy succeeds and the database update fails, reconciliation must detect the extra object and finish or reverse the transition.

How do you authorize downloads?

Query the file through the authenticated owner's relationship and require ready. Never accept a raw object key from the URL and sign it directly:

from django.contrib.auth.decorators import login_required
from django.http import Http404, HttpResponseRedirect


@login_required
def download_upload(request, upload_id):
    upload = request.user.upload_set.filter(
        pk=upload_id,
        status=Upload.Status.READY,
    ).first()
    if upload is None:
        raise Http404
    return HttpResponseRedirect(
        build_short_lived_download_url(
            upload.accepted_key,
            version=upload.accepted_version or None,
        )
    )

Keep private signed URLs out of logs and long-lived pages. Sanitize the download filename used by Content-Disposition, set nosniff, and use an attachment response for types that should not render inline. If Django streams from storage instead, use a bounded file-like stream rather than a local field.path assumption.

How do you clean up abandoned uploads?

Every nonterminal state needs a deadline and an owner:

Failure Durable evidence Recovery action
Browser never uploads Expired reserved row, object absent Mark deleted and expire the row
Object arrives, finalize callback fails Object exists, row still reserved Reconciler verifies and advances it or deletes it
Validation or scan rejects rejected result with private object Delete or retain under a documented evidence policy
Worker crashes during scan Stale scanning timestamp Retry idempotently after a lease timeout
Database rolls back after object write Object exists without an owning row Inventory by managed prefix and remove after a safety window
Multipart upload is abandoned Incomplete provider upload Abort explicitly or by storage lifecycle rule
User deletes a ready file Tombstoned row plus object Retry object deletion until confirmed

Do not delete a newly discovered orphan immediately. A valid transaction or multipart completion may still be in flight. Use a safety window longer than the maximum reservation and processing time, record why an object qualifies, and make cleanup repeatable.

Deleting a model row does not automatically delete its stored file. Keep a tombstone or durable deletion job, remove the object through the storage API, and retry until storage confirms the outcome.

S3 stores uploaded parts until a multipart upload is completed or aborted. Configure AbortIncompleteMultipartUpload lifecycle cleanup in addition to application cancellation. AWS's multipart cleanup documentation explains the storage behavior.

How do you test a Django upload pipeline?

Test the contract, not just the happy-path form response:

  • valid multipart form binds request.FILES and creates one owned record;
  • missing multipart/form-data, empty files, too many files, and oversized files fail with stable errors;
  • extension, declared MIME type, detected type, and parser results can disagree;
  • object keys are generated by the server and cannot cross tenant boundaries;
  • the scan task is queued only after the database commit;
  • duplicate finalize calls and duplicate storage events produce one transition;
  • overwriting a quarantine key after finalization cannot change scanned or downloadable accepted bytes;
  • concurrent finalizers that observe different quarantine versions cannot replace the winning accepted object;
  • scanner failure never exposes the object;
  • only the owner can read status or obtain a ready-file download;
  • expired reservations, orphaned objects, and incomplete multipart uploads are reconciled without deleting live work;
  • local filesystem and object-storage test doubles satisfy the same storage contract.

Use Django's SimpleUploadedFile for focused form and service tests. Add a small set of provider tests for POST policy conditions, CORS, checksum behavior, signed-download expiry, and multipart abort. Mocking boto3 alone cannot establish that the real provider enforces the conditions you depend on.

Also test infrastructure limits in a deployed environment. A unit test cannot prove that the reverse proxy rejects an oversized body before Django consumes it or that temporary disk has enough bounded capacity.

What does Djass already provide?

Djass does not currently generate a browser-upload feature. Its Use S3 option adds S3-compatible media-storage configuration, not upload forms, validation, scanning, or the reservation workflow described here. The generator options reference makes that boundary explicit, and the adding-a-feature workflow shows where to extend a generated repository.

There is still a useful repository-derived pattern. We inspected Djass at commit 936b9b1 on August 6, 2026. Its generated-project artifact path creates a durable project record, sends the record ID to a background task, persists generating, ready, or failed state, stores a ZIP through Django's storage abstraction, records its byte size and SHA-256 checksum, and authorizes the download through the owning project. Tests replace the storage backend with a temporary filesystem while keeping the state contract.

That is adjacent implementation evidence, not a claim that Djass accepts user uploads. Reuse the durable-state, record-ID, checksum, owner-scoped download, and storage-contract patterns. Add the hostile-input validation, quarantine, direct-upload finalization, and cleanup controls before accepting arbitrary user files.

If you want the S3 configuration path in the generated codebase, enable Use S3, then review the environment-variable reference and the storage access guide. You can inspect the available starter options before choosing Djass access.

Django file upload checklist

Before shipping, verify that:

  • the edge and application enforce explicit, coordinated size limits that account for multipart overhead and allowed file count;
  • the uploader is authenticated and authorized for the file's purpose;
  • the database owns state, tenant, expected object key, and expiry;
  • the client filename is display metadata, never the storage identifier;
  • extension, declared MIME, detected type, parsing, and scanning are separate layers;
  • untrusted files remain private and isolated until the ready transition;
  • direct-upload signatures are short-lived and bound to a fresh key;
  • finalization verifies the stored object with server credentials;
  • scan and finalize operations are idempotent;
  • downloads re-check ownership and readiness;
  • reservations, objects, multipart sessions, and deleted files are reconciled;
  • tests cover rejected, duplicated, interrupted, and cross-tenant paths.

The production boundary is not form.is_valid(). It is the full path from an authorized reservation to a verified, recoverable, owner-scoped file.