Images are the lifeblood of any content-rich website, yet they're often treated as an afterthought. Wagtail provides a sophisticated image management system that goes far beyond simple upload-and-display — with focal points, dynamic renditions, responsive image support, and a fully extensible image model. In this lesson, you will learn how to harness Wagtail's image engine to deliver beautiful, optimized, and responsive images at any scale.

1. Learning Objectives

By the end of this lesson, you will be able to:

  • Understand Wagtail's image model architecture and how images are stored, processed, and served
  • Use focal points to control smart cropping for responsive images at any aspect ratio
  • Generate and use image renditions with Wagtail's rendition framework (fill, max, min, width, height)
  • Register a custom image model that extends Wagtail's AbstractImage with additional fields
  • Build responsive images with srcset attributes using multiple renditions
  • Optimize images with WebP and AVIF formats via Willow plugins
  • Perform bulk image imports and management commands for large asset migrations

2. Why This Matters (Real-World Scenario)

Imagine you're building a photography portfolio website where each image needs to appear in multiple contexts — a hero banner at 1920x600, a thumbnail card at 400x300, a gallery grid at 800x600, and a full-resolution lightbox view. Uploading the same photo four times at different sizes is wasteful and error-prone. Without smart cropping, a beautiful portrait could become a decapitated mess when resized to a landscape card.

This is exactly what Wagtail's image system solves. You upload each image once, mark where the focal point is (the subject's face, the product's logo, the landmark's spire), and Wagtail automatically generates any rendition you request — cropping intelligently around that focal point. Combined with responsive image tags (srcset + sizes), your pages deliver crisp images at every screen size without loading a single byte more than necessary.

By the end of this lesson, you'll have a complete image pipeline that saves upload time, improves page performance, and delivers beautiful images across every device.

3. Core Concepts

3.1 The Wagtail Image Model

Wagtail's image system is built on Django's file-handling infrastructure. Every uploaded image is an instance of the wagtailimages.Image model, which stores:

  • File reference — the actual image file stored in Django's media storage
  • Dimensions — width and height extracted on upload
  • Focal point — a set of coordinates (x, y, width, height) that mark the image's subject area
  • Metadata — title, file size, created/modified dates, and the uploading user
  • Collection — images can be grouped into Collections for permission-based access control

Each image also records its original file size, MIME type, and a SHA256 file hash for deduplication. None of this metadata is editable by the user through the admin panel, but you can extend it via a custom image model.

3.2 Focal Points — The Heart of Smart Cropping

A focal point is a rectangular region of the image that marks the most important visual content. When Wagtail generates a rendition at a different aspect ratio than the original, it crops around this focal area to preserve the subject.

The focal point has four integer properties:

  • focal_point_x — The X coordinate of the center of the focal area (pixels from left)
  • focal_point_y — The Y coordinate of the center of the focal area (pixels from top)
  • focal_point_width — The width of the focal rectangle
  • focal_point_height — The height of the focal rectangle

Users can set the focal point visually in the Wagtail admin by clicking and dragging on the image editor. The operation is entirely client-side — the original image file is never modified. The coordinates are stored in the database alongside the image metadata.

3.3 Image Renditions — Dynamic Resizing Without Duplicates

A rendition is a dynamically generated variant of an original image. Wagtail never modifies the uploaded original; instead, it caches renditions in a separate table (wagtailimages.Rendition) and reuses them if the same filter is applied again. The cache is invalidated if the original image or its focal point changes.

Wagtail provides several built-in filter operations (called specs):

  • original — The untouched source image
  • fill-WxH — Crop and resize to fill exactly WxH pixels, centered on the focal point
  • max-WxH — Fit within a WxH bounding box, preserving aspect ratio (no cropping)
  • min-WxH — Cover at least WxH pixels, cropping if needed
  • width-W — Scale to W pixels wide, height auto
  • height-H — Scale to H pixels tall, width auto
  • scale-N — Scale by percentage (e.g., scale-50 for 50%)

Under the hood, Wagtail uses the Willow library, which abstracts over Pillow (PIL). Willow provides a uniform API for image operations and supports multiple backends.

3.4 Collections as Image Folders

Collections group images (and documents) into organizational folders. Each collection can have independent permission rules:

  • Collection-level permissions — add, edit, delete images within that collection
  • View restrictions — limit which users can see which collections in the admin and chooser UI
  • Privacy settings — control whether images in a collection are publicly accessible or require authentication

The root collection holds all images by default. You can nest collections arbitrarily to mirror your content structure (e.g., "Blog", "Portfolio", "Marketing", each with sub-collections by year or project).

4. Hands-On Practice — Step by Step

4.1 Prerequisites

This lesson assumes you have a running Wagtail project (continuing from the Wagtail Permissions setup in lesson 8). You'll need at least one page model with an Image-related field. If you're following the series, the existing portfolio project works perfectly.

Step 1: Explore the Built-In Image Model

Before extending Wagtail's image system, let's inspect the built-in capabilities. Start your development server and open the Wagtail admin. Navigate to Settings > Images (or click the Images icon in the sidebar). Try uploading a few images — a portrait photo, a landscape photo, and a product shot with a clear focal subject.

After uploading, click each image and select Edit. You'll see a focal point picker — click and drag on the image to define the focal area. Try setting focal points on the portrait (centered on the face) and the landscape (centered on a building or landmark).

Now let's understand what's stored in the database:

# Inspect stored images from the Django shell
python manage.py shell -c "
from wagtail.images.models import Image

for img in Image.objects.all()[:5]:
    print(f'ID: {img.id} | Title: {img.title}')
    print(f'  Dimensions: {img.width}x{img.height}')
    print(f'  File: {img.file.name}')
    print(f'  File size: {img.file_size} bytes')
    print(f'  Focal point: ({img.focal_point_x}, {img.focal_point_y}) w={img.focal_point_width} h={img.focal_point_height}')
    print()
"
Inspecting stored image metadata from the Django shell

Step 2: Rendering Images with Template Tags

Wagtail provides two powerful template tags for image rendering:

{% load wagtailimages_tags %}

<!-- Basic image rendition: fill a 400x300 card -->
{% image page.hero_image fill-400x300 class='card-img' alt='Hero image' %}

<!-- Max fit within a bounding box (no cropping) -->
{% image page.gallery_image max-800x600 %}

<!-- Scale by width only -->
{% image page.banner width-1920 %}

<!-- Just get the URL -->
{% image_url page.thumbnail fill-150x150 %}

<!-- Responsive image with srcset -->
{% image page.hero_image fill-1920x600 as hero_large %}
{% image page.hero_image fill-1280x400 as hero_medium %}
{% image page.hero_image fill-640x200 as hero_small %}
<img
  src='{{ hero_large.url }}'
  srcset='
    {{ hero_small.url }} 640w,
    {{ hero_medium.url }} 1280w,
    {{ hero_large.url }} 1920w
  '
  sizes='(max-width: 640px) 100vw, (max-width: 1280px) 1280px, 1920px'
  alt='{{ page.hero_image.title }}'
  loading='lazy'
>
Template tag patterns for rendering images with renditions and srcset

The {% image %} tag generates an HTML <img> element with the rendition URL and intrinsic dimensions (width and height attributes) automatically. The {% image_url %} tag returns just the URL for use in inline styles or custom markup. Note that Wagtail automatically adds width and height attributes to the generated <img> tag from the rendition's actual dimensions — this helps prevent Cumulative Layout Shift (CLS).

Step 3: Programmatic Renditions in Python

Sometimes you need to generate renditions in Python (e.g., in a management command, a serializer, or a custom view). The get_rendition() method is your gateway:

# Generating renditions programmatically
from wagtail.images.models import Image

img = Image.objects.get(id=1)

# Create a rendition
rendition = img.get_rendition('fill-400x300')
print(f'Rendition URL: {rendition.url}')
print(f'Dimensions: {rendition.width}x{rendition.height}')
print(f'File size: {rendition.file_size} bytes')

# Check if rendition exists (cached) or forces regeneration
renditions = img.renditions.filter(filter_spec='fill-400x300')
if renditions.exists():
    print('Rendition is cached in database')

# Generate multiple renditions for a responsive image set
specs = ['fill-1920x600', 'fill-1280x400', 'fill-640x200']
rendition_set = {spec: img.get_rendition(spec) for spec in specs}
for spec, r in rendition_set.items():
    print(f'{spec}: {r.width}x{r.height} @ {r.url}')
Generating and inspecting image renditions programmatically

Each call to get_rendition() checks the rendition table first. If the rendition for that filter spec already exists, it returns the cached version — the underlying Willow image processing runs only once per spec per original image. This means your first rendition generation is slow (a few hundred milliseconds for a large image), but subsequent calls are a simple database lookup.

Step 4: Creating a Custom Image Model

For many projects, Wagtail's built-in Image model is sufficient. But when you need additional metadata (copyright info, alt text defaults, credit lines, EXIF data, or custom validation), you must subclass AbstractImage and AbstractRendition.

Important: Once you swap image models, Wagtail uses your custom model everywhere — the image chooser, the admin panel, and all template tags. The switch requires a database migration and must be done early in your project, or you'll need a data migration to transfer existing images.

# myapp/models.py — Custom Image Model
from django.db import models
from wagtail.images.models import AbstractImage, AbstractRendition, Image


class CustomImage(AbstractImage):
    '''
    Custom image model with additional metadata fields.
    Register this in settings.WAGTAILIMAGES_IMAGE_MODEL.
    '''
    credit = models.CharField(
        max_length=255,
        blank=True,
        help_text='Photo credit or attribution line'
    )
    alt_text = models.CharField(
        max_length=255,
        blank=True,
        help_text='Default alt text for accessibility'
    )
    copyright = models.CharField(
        max_length=255,
        blank=True,
        help_text='Copyright holder information'
    )
    is_archived = models.BooleanField(
        default=False,
        help_text='Archived images are hidden from the chooser'
    )

    admin_form_fields = Image.admin_form_fields + (
        'credit',
        'alt_text',
        'copyright',
    )

    class Meta(AbstractImage.Meta):
        verbose_name = 'Custom Image'
        verbose_name_plural = 'Custom Images'


class CustomRendition(AbstractRendition):
    '''
    Each custom image model needs its own rendition model.
    '''
    image = models.ForeignKey(
        CustomImage,
        on_delete=models.CASCADE,
        related_name='renditions'
    )

    class Meta(AbstractRendition.Meta):
        verbose_name = 'Custom Image Rendition'
        verbose_name_plural = 'Custom Image Renditions'
Custom image model with credit, alt text, copyright, and archive fields
# settings/base.py — Register the custom image model
WAGTAILIMAGES_IMAGE_MODEL = 'myapp.CustomImage'

# Then create and apply the migration:
# python manage.py makemigrations
# python manage.py migrate
Registering the custom image model in Django settings

After configuring the custom model, run python manage.py makemigrations and python manage.py migrate. The admin will automatically pick up your admin_form_fields and display the extra fields on the image edit form. Existing images will need a data migration to populate the new fields with defaults — Wagtail does not auto-migrate existing records to custom image models.

Warning: Once WAGTAILIMAGES_IMAGE_MODEL is set and migrated, you cannot change it without a complex data migration. Choose your custom fields carefully.

Step 5: Responsive Images with the Picture Tag

Modern responsive images use the <picture> element with <source> children to serve different formats (WebP for modern browsers, AVIF for bleeding-edge, JPEG for fallback) at different resolutions. Wagtail's {% picture %} tag (available via {% load wagtailimages_tags %}) simplifies this:

{% load wagtailimages_tags %}

<!-- Using the picture tag for format negotiation -->
{% picture page.hero_image format-'avif'|format-'webp'|format-'jpeg'|fill-1920x600 %}

<!-- This generates: -->
<picture>
  <source srcset='.../fill-1920x600.avif' type='image/avif'>
  <source srcset='.../fill-1920x600.webp' type='image/webp'>
  <img src='.../fill-1920x600.jpg' width='1920' height='600' alt='...'>
</picture>
Using the picture template tag for format negotiation with AVIF/WebP/JPEG fallback

The format- filter tells Wagtail to generate renditions in the specified format. The browser picks the first <source> whose MIME type it supports. This pattern reduces image payload by 30–50% compared to JPEG alone, with no perceptible quality difference. Wagtail uses Willow's format conversion, which delegates to Pillow's encoder — so PNG, WebP, and AVIF support depends on your Pillow build configuration.

Step 6: Image Optimization Settings

Wagtail exposes several settings to control image quality and rendering behavior. These live in your Django settings.py:

# settings/base.py — Image optimization settings

# JPEG/WebP compression quality (1-100, default 85)
WAGTAILIMAGES_JPEG_QUALITY = 80
WAGTAILIMAGES_WEBP_QUALITY = 80

# Maximum upload size in bytes (default: 10MB)
WAGTAILIMAGES_MAX_UPLOAD_SIZE = 20 * 1024 * 1024  # 20MB

# Allowed image file extensions
WAGTAILIMAGES_EXTENSIONS = ['jpg', 'jpeg', 'png', 'gif', 'webp', 'avif', 'svg']

# Feature detection for format support
WAGTAILIMAGES_FORMAT_CONVERSION = {
    'avif': {'quality': 60, 'optimize': True},
    'webp': {'quality': 80, 'optimize': True},
}

# Serve renditions directly from Django (default: True)
# Set False if you use a CDN that handles image transformations
WAGTAILIMAGES_SERVE_RENDITION = True
Configuration settings for image compression, upload limits, and format conversion

Setting the quality to 80 (instead of the default 85) reduces JPEG file sizes by roughly 15–20% with no visible quality degradation. For WebP, quality 80 with lossy compression produces files about 30% smaller than equivalent JPEG at quality 85. AVIF at quality 60 is often visually comparable to JPEG at 80 while being 50% smaller.

Step 7: Bulk Image Import with Management Commands

For production launches or migrations, you may need to import thousands of images. Wagtail provides a management command approach, plus the ability to write custom commands for folder-based imports:

# management/commands/import_images.py
import os
from django.core.management.base import BaseCommand
from django.core.files import File
from wagtail.images.models import Image


class Command(BaseCommand):
    help = 'Imports images from a local directory into Wagtail'

    def add_arguments(self, parser):
        parser.add_argument('directory', type=str,
                            help='Path to directory containing images')
        parser.add_argument('--collection-id', type=int, default=None,
                            help='Collection ID to assign imported images')

    def handle(self, *args, **options):
        directory = options['directory']
        collection_id = options.get('collection_id')
        valid_extensions = {'.jpg', '.jpeg', '.png', '.gif', '.webp', '.svg'}

        for filename in sorted(os.listdir(directory)):
            ext = os.path.splitext(filename)[1].lower()
            if ext not in valid_extensions:
                continue

            filepath = os.path.join(directory, filename)
            title = os.path.splitext(filename)[0].replace('_', ' ').replace('-', ' ')

            # Skip if already imported (check by title)
            if Image.objects.filter(title=title).exists():
                self.stdout.write(f'SKIP {filename} — already exists')
                continue

            with open(filepath, 'rb') as f:
                image = Image(title=title, file=File(f))
                if collection_id:
                    from wagtail.models import Collection
                    image.collection_id = collection_id
                image.save()

            self.stdout.write(f'IMPORTED {filename} → Image #{image.id}')

        self.stdout.write(self.style.SUCCESS('Import complete!'))
Management command for bulk importing images from a local directory

Run it with: python manage.py import_images /path/to/images --collection-id 2. For very large imports (10,000+ images), consider batching with transaction.atomic() blocks of 500 images to avoid memory exhaustion.

5. Common Errors & Solutions

  1. Error: 'Image rendition not found' or 404 on rendition URL — The rendition was never generated, or the generation failed silently. Run python manage.py wagtailimages_regenerate_renditions [image_id] to regenerate all renditions for a specific image. If the issue persists, check that your storage backend supports writing to the rendition directory.
  2. Error: 'Image' object has no attribute 'credit' after switching to custom model — Existing images were created before the custom model switch. They are stored as the old Image model instances and won't have your custom fields. You need a data migration to re-save them.
  3. Error: File size exceeds max upload size for images under 10MB — Your webserver (Nginx, Apache) may have a smaller client_max_body_size than Django's upload limit. Check both layers.
  4. Error: Generated WebP/AVIF renditions have wrong colors — Your Pillow installation lacks WebP/AVIF support. Reinstall with the right system libraries: pip install Pillow requires libwebp-dev and libavif-dev.
  5. Error: Focal point is not being respected in renditions — Only the fill- spec uses focal points. Specs like max- or width- preserve the full image without cropping. Always use the fill-WxH spec for focal-point-aware cropping.

6. Summary Checklist

  • I understand Wagtail's image model architecture: Image, Rendition, Collection, and Willow processing pipeline
  • I can set focal points on images and generate fill, max, min, width, and height renditions
  • I can use {% image %} and {% image_url %} template tags with renditions
  • I can build responsive images with srcset and sizes attributes for multiple breakpoints
  • I can use the {% picture %} tag for format negotiation (WebP/AVIF/JPEG)
  • I know how to create a custom image model with extra fields via AbstractImage
  • I can configure image quality, upload limits, and format conversion settings
  • I can write and run a bulk image import management command
  • I can troubleshoot rendition failures, format support issues, and focal-point problems

7. Practice Exercise

Challenge: Responsive Gallery with Format Negotiation

Build a Wagtail page that displays a photo gallery with the following requirements:

  • Each gallery image must appear in three sizes: thumbnail (200x200 fill), card (600x400 fill), and full-width hero (1600x500 fill)
  • Thumbnails should use the AVIF format with WebP and JPEG fallbacks
  • The card view must use srcset with three resolutions (1x, 2x, 3x) so Retina displays get crisp images
  • Each image must display its title, credit, and dimensions
  • Images should be grouped by collection (e.g., 'Landscapes', 'Portraits') on separate tabs
  • Add a management command that imports 20+ images from a folder and assigns them to the right collection

Bonus: Add lazy loading, a lightbox viewer (e.g., using the <dialog> element), and implement a caching strategy via the Wagtail image serve view.

8. Next Steps

You have now mastered Wagtail's image system — from basic renditions and focal points to custom image models, responsive images with format negotiation, and bulk import workflows. Your site can now deliver beautifully cropped, optimized images at any size, on any device, in the most efficient format.

Coming up next: The Wagtail CMS series continues with Wagtail Snippets & Custom Models: Reusable Content Components for Dynamic Sites, where you'll learn how to create reusable content components with @register_snippet, build custom index views for managing snippets, integrate snippets into StreamField via chooser blocks, and build dynamic site components from reusable data.

Further resources:

  • Wagtail Image Documentation — official reference for image tags, renditions, and settings
  • Willow Library — the image processing library Wagtail uses under the hood
  • Wagtail Images API — how to serve images through the REST API in headless setups
  • MDN Responsive Images Guide — modern responsive image markup

Common pitfalls to avoid:

  • Never hardcode image dimensions in templates — always use Wagtail's rendition-generated width/height attributes to avoid CLS
  • Don't forget to set focal points on portrait and product images — without them, fill- rendering center-crops and can decapitate subjects
  • Avoid changing WAGTAILIMAGES_IMAGE_MODEL after production data exists — migrating existing images is non-trivial
  • Remember to check both the webserver and Django upload limits when users report upload failures
  • Don't serve original files in production galleries — always use renditions for consistent sizing and optimization

Gataya Med

DevOps Engineer & Backend Developer. Sharing insights on cloud, automation, and scalable systems.

Comments (0)

Sarah Chen July 12, 2026

This is exactly what I needed! The initContainer approach solved our migration issues completely. Thanks for the detailed guide!

Reply

Leave a Comment