Skip to content

Upload and Serve Media Files in Django

Media files are files that your users upload themselves - things like profile pictures, product images, documents, PDFs, and so on. They come from forms, the admin panel, or API requests. These files are different from static files, because they are not part of your project’s code. They get created while the app is running, so they need their own way of being stored and served.

MEDIA_URL

The public URL that browsers use to ask for an uploaded file, something like /media/.

MEDIA_ROOT

The folder on your computer’s disk where Django actually saves uploaded files during development.

upload_to

A setting on the model field that decides which subfolder each uploaded file goes into, inside MEDIA_ROOT.

When Django gets an uploaded file, it doesn’t save the actual file content inside the database. Instead, the file gets written to a storage location, and the database just keeps a note of where that file is (its path). This keeps your database small and makes it much easier to manage big files. On a simple local setup, this storage location is usually just your project’s own filesystem (your computer’s disk). In production (when your app is live), it’s often a cloud storage service like S3, Azure Blob Storage, or Google Cloud Storage.

MEDIA_URL and MEDIA_ROOT work as a pair. MEDIA_ROOT is the real folder on disk where the file lives, while MEDIA_URL is the public web address used to reach that file from a browser. So if a file is saved at MEDIA_ROOT/uploads/image.jpg, Django can show it to the browser at MEDIA_URL/uploads/image.jpg, as long as development serving is turned on.

If you want to use ImageField, Django needs a package called Pillow to check and read image files properly. Without Pillow, Django can’t fully support image fields.

The first step is to set the media URL and the local storage folder inside settings.py. Django uses these settings whenever a model field like FileField or ImageField saves a file to disk.

# settings.py
from pathlib import Path
MEDIA_URL = "/media/"
MEDIA_ROOT = BASE_DIR / "media"

MEDIA_URL should always end with a slash (/), because Django adds file paths right after it. MEDIA_ROOT should point to its own dedicated folder, kept outside your app’s code folders, so uploaded files stay separate from your source code. Using Path is the cleaner, more modern way to write this, especially if your project already uses pathlib.

Django’s local development server can only show media files if you tell it to, by adding the media URL pattern inside urls.py. This is handy while you’re developing locally, because you can test file uploads right away without setting up any extra infrastructure. But this same setup should never be used as your solution in production.

# urls.py
from django.conf import settings
from django.conf.urls.static import static
from django.contrib import admin
from django.urls import path
urlpatterns = [
path("admin/", admin.site.urls),
]
if settings.DEBUG:
urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)

Here’s a simple flow of what happens when someone uploads a file:

graph TD A[User selects a file] --> B[Form or API request] B --> C[Django receives uploaded file] C --> D[File saved under MEDIA_ROOT] D --> E[Database stores file path] E --> F[Browser reads file through MEDIA_URL]

FileField can store any kind of uploaded file, while ImageField is made specifically for images. Both fields work the same way when it comes to storing the file, but ImageField also checks that the file is a real image, using Pillow behind the scenes. The upload_to setting helps you stay organized by placing files into their own subfolder.

from django.db import models
class Document(models.Model):
title = models.CharField(max_length=100)
file = models.FileField(upload_to="documents/")
image = models.ImageField(upload_to="images/", blank=True, null=True)

When a user uploads a file through this model, Django saves the file inside the media folder, and stores the file’s relative path in the database record. Later on, when you use file.url or image.url, Django turns that saved path into a usable web address.

Views, Serializers, Admin and Nested Routes

Section titled “Views, Serializers, Admin and Nested Routes”

When your model includes file uploads, your views and serializers need to handle that file data the right way. For example, if you have a product model with images, you might want a nested route just for uploading images that belong to one specific product. This way, your product details and its images stay neatly organized in your API.

The example below shows how to set up nested routes for a product and its images, using Django REST Framework along with the drf-nested-routers package. The ProductImageSerializer is built to handle file uploads, and the ProductImageViewSet makes sure each uploaded image gets linked to the correct product.

Model code (models.py)
# models.py
from django.db import models
class Product(models.Model):
name = models.CharField(max_length=150)
description = models.TextField(blank=True)
price = models.DecimalField(max_digits=10, decimal_places=2)
created_at = models.DateTimeField(auto_now_add=True)
def __str__(self):
return self.name
class ProductImage(models.Model):
product = models.ForeignKey(
Product,
related_name="images",
on_delete=models.CASCADE,
)
image = models.ImageField(upload_to="products/images/")
def __str__(self):
return f"Image for {self.product.name}"
Serializer code (serializers.py)
# serializers.py
from rest_framework import serializers
from .models import Product, ProductImage
class ProductImageSerializer(serializers.ModelSerializer):
class Meta:
model = ProductImage
fields = ["id", "product", "image"]
read_only_fields = ["id", "product"]
def create(self, validated_data):
product_pk = self.context.get("product_pk")
return ProductImage.objects.create(product_id=product_pk, **validated_data)
class ProductSerializer(serializers.ModelSerializer):
images = ProductImageSerializer(many=True, read_only=True)
class Meta:
model = Product
fields = ["id", "name", "description", "price", "created_at", "images"]
read_only_fields = ["id", "created_at"]
View code (views.py)
# views.py
from rest_framework import viewsets
from .models import Product, ProductImage
from .serializers import ProductSerializer, ProductImageSerializer
class ProductViewSet(viewsets.ModelViewSet):
queryset = Product.objects.prefetch_related("images").all()
serializer_class = ProductSerializer
class ProductImageViewSet(viewsets.ModelViewSet):
serializer_class = ProductImageSerializer
def get_queryset(self):
return ProductImage.objects.filter(product_id=self.kwargs["product_pk"])
def get_serializer_context(self):
context = super().get_serializer_context()
context["product_pk"] = self.kwargs.get("product_pk")
return context
Nested router URLs (urls.py)
# urls.py
from django.urls import include, path
from rest_framework.routers import DefaultRouter
from rest_framework_nested import routers
from .views import ProductViewSet, ProductImageViewSet
router = DefaultRouter()
router.register("products", ProductViewSet, basename="products")
products_router = routers.NestedDefaultRouter(router, "products", lookup="product")
products_router.register("images", ProductImageViewSet, basename="product-images")
urlpatterns = [
path("", include(router.urls)),
path("", include(products_router.urls)),
]
Admin code (admin.py)
# admin.py
from django.contrib import admin
from .models import Product, ProductImage
from django.utils.html import format_html
class ProductImageInline(admin.TabularInline):
model = ProductImage
readonly_fields = ["thumbnail"]
extra = 3
def thumbnail(self, obj):
if obj.image:
return format_html(f'<img src="{obj.image.url}" class="thumbnail" />')
return "No image"
class Media:
css = {
"all": ["app_name/css/style.css"]
}
@admin.register(Product)
class ProductAdmin(admin.ModelAdmin):
list_display = ["name", "price", "created_at"]
inlines = [ProductImageInline]
readonly_fields = ["created_at"]
/* static/app_name/css/style.css */
.thumbnail {
max-width: 100px;
max-height: 100px;
overflow: hidden;
border-radius: 4px;
border: 1px solid #ddd;
}

Now you’ll have endpoints like these:

  • GET /products/
  • POST /products/
  • GET /products/{product_pk}/images/
  • POST /products/{product_pk}/images/

Django’s file fields already check that the uploaded data is in fact a file, but you can add your own validation rules too, to check things like file size, file type, or anything else you need. For example, you might want to only allow certain image formats, set a maximum file size, or only allow specific file types like PDF or DOCX.

You can write your own validator function to check the file’s size before it gets saved. Here’s an example of a simple file size validator:

# validators.py
from django.core.exceptions import ValidationError
def validate_file_size(file):
max_size_kb = 500 # Maximum file size in KB
if file.size > max_size_kb * 1024:
raise ValidationError(f"File size should not exceed {max_size_kb} KB.")

To use this validator, just add it to your model field:

from django.db import models
from .validators import validate_file_size
class Document(models.Model):
title = models.CharField(max_length=100)
image = models.ImageField(upload_to="images/", validators=[validate_file_size])

You can also check the file’s extension, so only certain file types are allowed to be uploaded. Here’s how to set up a validator for allowed file extensions:

# models.py
from django.db import models
from django.core.validators import FileExtensionValidator
class Document(models.Model):
title = models.CharField(max_length=100)
file = models.FileField(
upload_to="documents/",
validators=[FileExtensionValidator(allowed_extensions=["pdf", "docx", "txt"])]
)

In this example, FileExtensionValidator only allows PDF, DOCX, and TXT files to be uploaded. If someone tries to upload a file with any other extension, Django will show a validation error and block the upload.

You can also check the size (dimensions) of an uploaded image using Pillow. Here’s an example of a custom validator that checks image dimensions:

# validators.py
from django.core.exceptions import ValidationError
from PIL import Image
def validate_image_dimensions(image):
WIDTH_MIN = 300
HEIGHT_MIN = 300
try:
img = Image.open(image)
width, height = img.size
if width < WIDTH_MIN or height < HEIGHT_MIN:
raise ValidationError(f"Image dimensions should be at least {WIDTH_MIN}x{HEIGHT_MIN} pixels.")
except Exception as e:
raise ValidationError("Invalid image file.")