File Uploads in Django: Images, PDFs, and Validation

Uploading files looks simple until you need to handle size limits, image validation, PDF restrictions, media storage, and security. This guide walks through a clean Django setup for accepting images and PDFs without turning your codebase into a mess.

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.

Rule of thumb: validate early, store safely, and never assume that a file is harmless just because its name ends with .pdf or .png.

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
Tip: using a callable for upload_to gives you room to organize files by type, date, or user later without rewriting your entire model design.

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
Important: content type checks are helpful, but they are not perfect security guarantees. For high-risk systems, validate file contents more deeply and consider virus scanning.

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)
Development only: serving uploads through Django like this is fine for local work. In production, media is usually handled by a web server, CDN, or cloud object storage.

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.

Allow only what you need. If you only want PDFs, reject everything else.
Limit file size. This protects storage, bandwidth, and request handling.
Do not trust filenames. Sanitize or replace them.
Be careful with executable content. Never allow uploads that could be executed by your server.
Separate uploaded media from application code. Keep media out of places where it could be served unsafely.
Consider virus scanning for sensitive systems. Especially for business, healthcare, or internal document workflows.

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.