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:
- Enforce a full-request cap before Django consumes the body.
- Authorize the user and create an owner-scoped upload record.
- Store bytes under an opaque key in a private quarantine location.
- Verify the stored object's key, size, checksum, and detected type.
- Scan or transform the file in a background job.
- Mark it
readyonly after every required check passes. - Authorize every download or issue a short-lived signed URL.
- 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?
- What does Django's 2.5 MB upload setting mean?
- What state should a file upload have?
- How should Django validate an uploaded file?
- How do you handle a small multipart upload?
- Why is request.FILES empty?
- How do you implement a direct S3 upload?
- How do scanning and finalization work?
- How do you authorize downloads?
- How do you clean up abandoned uploads?
- How do you test a Django upload pipeline?
- What does Djass already provide?
- Django file upload checklist
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:
- Authenticate the uploader and check tenant role, quota, and purpose.
- Reject unsupported extensions using an allowlist.
- Enforce per-file, request, and file-count limits.
- Treat the submitted
Content-Typeas a hint, not evidence. - Inspect a bounded byte prefix with a maintained type-detection library.
- Parse or decode the file with the library that will consume it.
- Scan for malware or apply content disarm and reconstruction where the risk warrants it.
- 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
reservedrow, 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.FILESand 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
readytransition; - 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.