Video coming soon

The recording for this lesson has not been published yet.

What we cover

  • MEDIA_ROOT, MEDIA_URL and urlpatterns in dev
  • FileField, ImageField and Pillow
  • Generating thumbnails on save
  • Validating size, extension and content type

Lesson notes

1. Static files are yours, media files are theirs

You already met STATIC_URL in the tutorial: CSS and images that you ship with the code. Uploads are different. They arrive at runtime, they are not in git, and they must never be trusted.

StaticMedia
Comes from You, in the repository Users, at runtime
Settings STATIC_URL, STATIC_ROOT, STATICFILES_DIRS MEDIA_URL, MEDIA_ROOT
Collected by collectstatic Nothing - the files are already there
In version control Yes Never

2. MEDIA_ROOT, MEDIA_URL and serving in development

# mysite/settings.py
MEDIA_URL = "/media/"                # the URL prefix in the browser
MEDIA_ROOT = BASE_DIR / "media"      # the directory on disk
# mysite/urls.py
from django.conf import settings
from django.conf.urls.static import static
from django.contrib import admin
from django.urls import include, path

urlpatterns = [
    path("admin/", admin.site.urls),
    path("", include("blog.urls")),
]

if settings.DEBUG:
    urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)

Add the directory to .gitignore so uploads never end up in a commit:

# .gitignore
/media/

3. From the browser to MEDIA_ROOT

browser multipart/form-data request.FILES UploadedFile form.is_valid() your validators run storage.save() MEDIA_ROOT/posts/2026/cat.jpg database row "posts/2026/cat.jpg" post.image.url = MEDIA_URL + name the column stores a path, not bytes

4. FileField, ImageField and Pillow

pip install Pillow
pip freeze > requirements.txt
# blog/models.py
def post_image_path(instance, filename):
    """Group uploads by year so the directory stays browsable."""
    return f"posts/{timezone.now():%Y/%m}/{filename}"


class Post(models.Model):
    ...
    image = models.ImageField(
        upload_to=post_image_path,
        blank=True,
        null=True,
        help_text="JPEG, PNG or WebP, up to 2 MB.",
    )
    attachment = models.FileField(upload_to="attachments/", blank=True)

ImageField is a FileField that additionally verifies the file is really an image (Pillow opens it) and gives you .width and .height. Both store a relative path in the database, so moving MEDIA_ROOT does not break any row.

AttributeValue
post.image.nameposts/2026/08/cat.jpg
post.image.pathAbsolute path on disk (local storage only)
post.image.url/media/posts/2026/08/cat.jpg
post.image.sizeBytes - hits the storage backend

5. The form, the view and the template

# blog/views.py
def post_edit(request, pk):
    post = get_object_or_404(Post, pk=pk)
    form = PostForm(request.POST or None, request.FILES or None, instance=post)
    if request.method == "POST" and form.is_valid():
        form.save()
        return redirect(post.get_absolute_url())
    return render(request, "blog/post_edit.html", {"form": form})
<form method="post" enctype="multipart/form-data" novalidate>
  {% csrf_token %}
  {{ form.as_div }}
  <button class="btn btn-primary">Save</button>
</form>

{% if post.image %}
  <img src="{{ post.image.url }}" alt="{{ post.title }}" class="img-fluid rounded">
{% endif %}

6. Validating size, extension and content

A browser tells you the file name and a Content-Type header. Both are supplied by the client and both can lie. Check three things yourself.

# blog/validators.py
from django.core.exceptions import ValidationError
from django.core.validators import FileExtensionValidator

MAX_UPLOAD_SIZE = 2 * 1024 * 1024  # 2 MB

validate_image_extension = FileExtensionValidator(
    allowed_extensions=["jpg", "jpeg", "png", "webp"]
)


def validate_max_size(value):
    if value.size > MAX_UPLOAD_SIZE:
        raise ValidationError(
            "Keep the file under %(limit)s MB (yours is %(size).1f MB).",
            params={"limit": 2, "size": value.size / 1024 / 1024},
        )
    image = models.ImageField(
        upload_to=post_image_path,
        blank=True,
        validators=[validate_image_extension, validate_max_size],
    )

The real content check is to let Pillow verify the bytes. ImageField already does this, but if you accept a plain FileField, do it in the form:

from PIL import Image, UnidentifiedImageError


class PostForm(forms.ModelForm):
    class Meta:
        model = Post
        fields = ["title", "text", "image"]

    def clean_image(self):
        image = self.cleaned_data.get("image")
        if not image or not hasattr(image, "file"):
            return image          # unchanged, already on disk
        try:
            with Image.open(image.file) as img:
                img.verify()      # raises if the bytes are not an image
        except (UnidentifiedImageError, OSError) as exc:
            raise ValidationError("That file is not a readable image.") from exc
        image.file.seek(0)        # verify() consumed the stream
        return image

7. Thumbnails on save

A 6 MB phone photo on a list page is a bad experience. Generate a small copy once, when the post is saved, and store its path in a second field.

from io import BytesIO

from django.core.files.base import ContentFile
from PIL import Image, ImageOps

THUMB_SIZE = (400, 300)


class Post(models.Model):
    ...
    image = models.ImageField(upload_to=post_image_path, blank=True)
    thumbnail = models.ImageField(upload_to="posts/thumbs/", blank=True, editable=False)

    def save(self, *args, **kwargs):
        super().save(*args, **kwargs)          # the original must exist first
        if self.image and not self.thumbnail:
            self._make_thumbnail()
            super().save(update_fields=["thumbnail"])

    def _make_thumbnail(self):
        with Image.open(self.image) as img:
            img = ImageOps.exif_transpose(img).convert("RGB")
            img.thumbnail(THUMB_SIZE)
            buffer = BytesIO()
            img.save(buffer, format="WEBP", quality=80)
        self.thumbnail.save(
            f"{Path(self.image.name).stem}-thumb.webp",
            ContentFile(buffer.getvalue()),
            save=False,
        )

Two details matter. exif_transpose honours the rotation flag phones write, so portrait photos do not come out sideways. save=False on the inner call stops the recursion into Post.save().

For anything more elaborate - several sizes, cropping, regeneration when the source changes - reach for django-imagekit or easy-thumbnails instead of growing this method, and move the work into a background task (module 9) once it starts to be felt in the request.

8. Deleting a post does not delete its file

Since Django 1.3 the file is intentionally left on disk when the row disappears, so a rolled-back transaction cannot destroy data. Clean up explicitly if you want to:

from django.db.models.signals import post_delete
from django.dispatch import receiver


@receiver(post_delete, sender=Post)
def delete_post_files(sender, instance, **kwargs):
    for field in ("image", "thumbnail"):
        file = getattr(instance, field)
        if file:
            file.delete(save=False)

9. Exercise

  1. Set MEDIA_URL and MEDIA_ROOT, wire the development URL pattern, and add /media/ to .gitignore.
  2. Add image to Post with an upload_to callable, install Pillow, migrate, and upload a picture through your edit page.
  3. Deliberately remove enctype from the form tag and observe that the upload silently does nothing. Put it back.
  4. Add the size and extension validators, then try to upload a 5 MB file and a .txt renamed to .jpg.
  5. Generate a thumbnail on save and use it in the post list while the full image stays on the detail page.
  6. Write a test using SimpleUploadedFile and override_settings(MEDIA_ROOT=tempfile.mkdtemp()) so the suite never writes into your real media directory.

Further reading

After this lesson you will

  • Accept an image for every blog post
  • Serve uploaded files during development