Skip to content

Upload and Serve Media Files in Django

Media files are the user-generated files that your application stores outside the core codebase. In Django, this usually means profile pictures, product images, documents, PDFs, and similar uploads that come from forms, admin, or API requests. These files are different from static files because they are not part of the shipped source code; they are created at runtime and therefore need a separate storage and serving strategy.

MEDIA_URL

The public URL prefix that browsers use to request uploaded files, such as /media/.

MEDIA_ROOT

The filesystem directory where Django saves uploaded files on disk during development.

upload_to

The model field option that decides the subfolder name for each uploaded file inside MEDIA_ROOT.

When Django receives an uploaded file, it does not store the raw file content inside the database by default. Instead, the file is written to a storage backend, and the database keeps a reference to the file path or storage location. This design keeps your database smaller and makes large binary files easier to manage. In a simple local setup, that storage backend is usually your project’s local filesystem. In production, it is often a cloud object store such as S3, Azure Blob Storage, or Google Cloud Storage.

MEDIA_URL and MEDIA_ROOT work together. MEDIA_ROOT is the physical folder on disk, while MEDIA_URL is the public path used to reach files from the browser. If a file is saved at MEDIA_ROOT/uploads/image.jpg, Django can expose it at MEDIA_URL/uploads/image.jpg when development serving is enabled.

If you want to use ImageField, Django needs Pillow for image validation and metadata handling. Without Pillow, Django cannot fully support image-specific fields.

The first step is to define the media URL prefix and the local storage directory in settings.py. Django will use these settings whenever a model field like FileField or ImageField writes 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 trailing slash, because Django joins file paths underneath it. MEDIA_ROOT should point to a dedicated folder that is outside your app modules, so uploaded files stay separate from the source code. Using Path is the modern and readable approach when your project already relies on pathlib.

Django’s development server can serve media files only when you explicitly attach the media URL pattern in urls.py. This is convenient for local development because you can immediately test file uploads without setting up extra infrastructure. The same pattern should not be treated as a production solution.

# 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)

Mermaid flow of a typical upload:

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 stores any kind of uploaded file, while ImageField is a specialized version for images. Both fields work the same way at the storage level, but ImageField adds image validation and integrates with Pillow. The upload_to argument helps you keep files organized by placing them into a subdirectory.

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 to this model, Django saves the file into the media folder and stores the relative file path in the model record. Later, when you access file.url or image.url, Django resolves that stored path back into a usable URL.

Views, Serializers, Admin and Nested Routes

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

When you have a model that includes file uploads, your views and serializers need to handle the file data correctly. For example, if you have a product model with images, you might want to create a nested route for uploading images related to a specific product. This way, you can keep the product details and its images organized in a clear API structure.

This below example demonstrates how to set up nested routes for a product and its images using Django REST Framework and the drf-nested-routers package. The ProductImageSerializer is designed to handle file uploads, and the ProductImageViewSet ensures that uploaded images are associated with 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;
}

You now get endpoints like:

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

Django’s file fields automatically validate that the uploaded data is a file, but you can add custom validation logic to check file size, type, or other criteria. For example, you might want to restrict uploads to certain image formats or limit the maximum file size. or you want to upload any specific file type like PDF, DOCX, etc.

You can create a custom validator function to check the file size before saving it. Here’s an example of how to implement a 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, you can 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 validate the file extension to ensure that only specific types of files are uploaded. Here’s how you can create 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, the FileExtensionValidator is used to restrict uploads to PDF, DOCX, and TXT files. If a user tries to upload a file with an extension that is not in the allowed list, Django will raise a validation error.

You can also validate the dimensions of an uploaded image using Pillow. Here’s an example of how to create a custom validator for 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.")