Why file uploads need more than one line of code
Django makes uploads approachable with FileField and ImageField, but production apps usually need more than “let the user attach a file.” You often need to answer questions like:
What file types are allowed?
Maybe profile pictures should be JPEG or PNG, while reports must be PDF only.
How large can the upload be?
Without limits, one oversized file can waste storage and hurt the user experience.
Where should files be stored?
Local media works in development, but production often uses object storage like S3.
Can the file be trusted?
File extensions can lie. Validation should go beyond the filename when possible.
1. Define a model for images and PDFs
Let’s build a simple DocumentUpload model that accepts a title, one optional image, and one optional PDF. In real projects you may separate these into different models, but one model keeps the example compact.
from django.db import models
def upload_to_image(instance, filename):
return f"uploads/images/{filename}"
def upload_to_pdf(instance, filename):
return f"uploads/pdfs/{filename}"
class DocumentUpload(models.Model):
title = models.CharField(max_length=150)
image = models.ImageField(upload_to=upload_to_image, blank=True, null=True)
pdf = models.FileField(upload_to=upload_to_pdf, blank=True, null=True)
created_at = models.DateTimeField(auto_now_add=True)
def __str__(self):
return self.title
Use ImageField for images and FileField for general files like PDFs. Django needs the Pillow package for ImageField.
pip install Pillow
2. Build a form that knows how to validate uploads
ModelForms are ideal here because they connect neatly to your model and give you a natural place to add custom validation logic.
from django import forms
from django.core.exceptions import ValidationError
from django.core.validators import FileExtensionValidator
from .models import DocumentUpload
MAX_IMAGE_SIZE = 2 * 1024 * 1024 # 2 MB
MAX_PDF_SIZE = 5 * 1024 * 1024 # 5 MB
ALLOWED_IMAGE_TYPES = {"image/jpeg", "image/png", "image/webp"}
ALLOWED_PDF_TYPE = "application/pdf"
class DocumentUploadForm(forms.ModelForm):
pdf = forms.FileField(
required=False,
validators=[FileExtensionValidator(allowed_extensions=["pdf"])]
)
class Meta:
model = DocumentUpload
fields = ["title", "image", "pdf"]
def clean_image(self):
image = self.cleaned_data.get("image")
if not image:
return image
if image.size > MAX_IMAGE_SIZE:
raise ValidationError("Image must be 2 MB or smaller.")
content_type = getattr(image, "content_type", None)
if content_type not in ALLOWED_IMAGE_TYPES:
raise ValidationError("Only JPEG, PNG, and WebP images are allowed.")
return image
def clean_pdf(self):
pdf = self.cleaned_data.get("pdf")
if not pdf:
return pdf
if pdf.size > MAX_PDF_SIZE:
raise ValidationError("PDF must be 5 MB or smaller.")
content_type = getattr(pdf, "content_type", None)
if content_type != ALLOWED_PDF_TYPE:
raise ValidationError("Only PDF files are allowed.")
return pdf
def clean(self):
cleaned_data = super().clean()
image = cleaned_data.get("image")
pdf = cleaned_data.get("pdf")
if not image and not pdf:
raise ValidationError("Upload at least one file: an image or a PDF.")
return cleaned_data
This approach validates three important things:
- File size
- Expected extension for PDFs
- Basic content type checks from the uploaded file metadata
3. Handle uploads in the view
The most common mistake here is forgetting to pass request.FILES into the form. Without it, Django never receives the uploaded file data.
from django.shortcuts import redirect, render
from .forms import DocumentUploadForm
def upload_document(request):
if request.method == "POST":
form = DocumentUploadForm(request.POST, request.FILES)
if form.is_valid():
form.save()
return redirect("upload_success")
else:
form = DocumentUploadForm()
return render(request, "uploads/upload_form.html", {"form": form})
def upload_success(request):
return render(request, "uploads/upload_success.html")
The flow is straightforward: on POST, bind submitted fields and file data, validate, save, then redirect. On GET, show an empty form.
4. Write the template correctly
HTML forms for file uploads require one extra detail: enctype="multipart/form-data". Forget it, and your files will not be sent.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>Upload a file</title>
</head>
<body>
<h1>Upload an image or PDF</h1>
<form method="post" enctype="multipart/form-data">
{% csrf_token %}
{{ form.as_p }}
<button type="submit">Upload</button>
</form>
</body>
</html>
That single enctype attribute is what allows the browser to transmit file bytes. Also note the {% csrf_token %}, which is still required like any normal Django POST form.
5. Configure media files in development
Uploaded files are not static assets. They are user-generated media, so Django expects separate settings for them.
# settings.py
from pathlib import Path
BASE_DIR = Path(__file__).resolve().parent.parent
MEDIA_URL = "/media/"
MEDIA_ROOT = BASE_DIR / "media"
Then wire media serving into your project URLs for development mode:
# urls.py
from django.conf import settings
from django.conf.urls.static import static
from django.contrib import admin
from django.urls import path
from uploads.views import upload_document, upload_success
urlpatterns = [
path("admin/", admin.site.urls),
path("upload/", upload_document, name="upload_document"),
path("success/", upload_success, name="upload_success"),
]
if settings.DEBUG:
urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
6. Add better validation as your app grows
Basic file validation gets you started, but real apps often need stricter rules. Here are the most useful upgrades.
Validate image dimensions
Sometimes file size is not enough. A “small” image might still be the wrong shape or too tiny for display.
def clean_image(self):
image = self.cleaned_data.get("image")
if not image:
return image
if image.size > MAX_IMAGE_SIZE:
raise ValidationError("Image must be 2 MB or smaller.")
if image.width < 400 or image.height < 400:
raise ValidationError("Image must be at least 400x400 pixels.")
return image
Rename uploaded files safely
Original filenames can contain spaces, collisions, or weird characters. A UUID-based filename is often cleaner.
import os
import uuid
def upload_to_pdf(instance, filename):
ext = os.path.splitext(filename)[1].lower()
return f"uploads/pdfs/{uuid.uuid4()}{ext}"
Move reusable checks into validators
If several forms need the same size or type rules, custom validators can keep your code from repeating itself.
from django.core.exceptions import ValidationError
def validate_file_size(file, max_bytes):
if file.size > max_bytes:
raise ValidationError("File is too large.")
Keep business rules in one place
Decide deliberately whether a validation rule belongs in the form, model, serializer, or service layer. For plain Django form uploads, form-level validation is usually the most natural fit.
7. Security and production notes
File uploads are one of the easiest ways for an application to accept untrusted input, so this area deserves care.
What about cloud storage?
Once your app moves beyond small local deployments, local disk storage becomes limiting. The common next step is using django-storages with S3-compatible object storage. That gives you durability, easier scaling, and cleaner separation between app instances and uploaded data.
What about private files?
Not every upload should be public. For private documents, avoid exposing direct media URLs unless authorization checks are in place. Many teams store private files in cloud storage and generate short-lived signed URLs only for authorized users.