Responsive Thumbnails in Django Templates with sorl-thumbnail

Blog / Django · August 8, 2014 · Updated June 10, 2026 · 13 min read
Responsive Thumbnails in Django Templates with sorl-thumbnail

To create responsive thumbnails in Django templates with sorl-thumbnail, call the {% thumbnail %} tag once per size you need, then wire those generated images into an HTML srcset (with a sizes attribute) or a <picture> element so the browser downloads the smallest image that fits each screen and pixel density. One fixed 100x100 thumbnail cannot serve a phone, a retina laptop, and a 4K monitor well — responsive markup lets sorl pre-generate every variant while the browser picks the right one.

This guide is the responsive-images companion to our general sorl-thumbnail walkthrough. It targets Django 5.x, Pillow, and modern formats (WebP and AVIF), and it covers srcset/sizes, the <picture> element for art direction and device-pixel-ratio (1x/2x retina), lazy loading, and the width/height attributes that stop layout shift and protect your Core Web Vitals. At MicroPyramid we have shipped image-heavy Django sites since 2014 across 50+ projects, and these are the patterns we actually deploy.

Key takeaways

  • {% thumbnail %} generates one file per size. Call it multiple times to build a set of widths, then reference each in srcset so the browser downloads only what it needs.
  • srcset + sizes handles resolution switching; <picture> with <source> handles art direction (a different crop per breakpoint) and format fallback (AVIF, then WebP, then JPEG).
  • Modern formats are a one-line win. Pass format="WEBP" (or AVIF) to the tag, or set THUMBNAIL_FORMAT globally; WebP and AVIF cut bytes 25-50% versus JPEG.
  • Always set width and height on the <img> and add loading="lazy" plus decoding="async" — this prevents cumulative layout shift (CLS) and defers off-screen work, both of which help LCP.
  • sorl pre-generates and caches via a key-value store. For very large or unpredictable image sets, an on-the-fly CDN (Cloudinary, imgix, Thumbor) often beats pre-generating every variant — we cover that trade-off honestly below.

Why one fixed thumbnail is not enough

The classic sorl pattern produces a single image at a single size:

{% load thumbnail %}

{% thumbnail item.image "100x100" crop="center" as im %}
    <img src="{{ im.url }}" width="{{ im.width }}" height="{{ im.height }}" alt="{{ item.title }}">
{% endthumbnail %}

That 100x100 file is perfect on a layout slot that is exactly 100 CSS pixels wide at 1x density. But the same markup is wrong everywhere else:

  • On a retina phone (device-pixel-ratio 2 or 3), a 100px slot needs a 200-300px image or it looks soft.
  • On a wide desktop card the slot might be 400px, so the 100px file is stretched and blurry.
  • On a small list view you are shipping more pixels than the slot can show — wasted bytes and a slower LCP.

The fix is not "make the thumbnail bigger." It is to generate several thumbnails and let the browser choose. HTML gives you two tools for that: the srcset/sizes attributes and the <picture> element. sorl-thumbnail's job is simply to produce each variant on demand and cache it.

Building srcset and sizes from {% thumbnail %}

Resolution switching is the common case: the same image, just at different widths. You describe each candidate with a w descriptor (its intrinsic pixel width) and tell the browser how wide the slot will be with sizes. The browser then picks the smallest file that still covers the slot at the current density.

Generate three widths and emit them as a comma-separated srcset:

{% load thumbnail %}

{% thumbnail item.image "400x300" crop="center" format="WEBP" as small %}
{% thumbnail item.image "800x600" crop="center" format="WEBP" as medium %}
{% thumbnail item.image "1200x900" crop="center" format="WEBP" as large %}
  <img
    src="{{ medium.url }}"
    srcset="{{ small.url }} {{ small.width }}w,
            {{ medium.url }} {{ medium.width }}w,
            {{ large.url }} {{ large.width }}w"
    sizes="(max-width: 600px) 100vw,
           (max-width: 1024px) 50vw,
           33vw"
    width="{{ medium.width }}"
    height="{{ medium.height }}"
    loading="lazy"
    decoding="async"
    alt="{{ item.title }}">
{% endthumbnail %}{% endthumbnail %}{% endthumbnail %}

A few things that matter here:

  • The w descriptor must be the real width of the generated file. Reading it from {{ small.width }} (rather than hardcoding 400w) keeps it correct even when sorl adjusts dimensions to preserve aspect ratio.
  • sizes is a promise about layout, not a media query that loads anything. It tells the browser the slot is full-width on phones, half-width on tablets, and a third on desktop, so it can pick before CSS is applied.
  • Keep a plain src as the fallback for browsers that ignore srcset (effectively none in 2026, but it costs nothing).
  • Whitespace matters: there must be a space between the URL and the w descriptor, and a comma between candidates.

This is fine for a quick test, but three nested {% thumbnail %} blocks read badly. In real templates, generate the set in a small inclusion tag or pass a list of sizes from the view, then loop.

Art direction and retina with the <picture> element

srcset switches resolution but always shows the same crop. When you want a different crop per breakpoint — a tight square on mobile, a wide 16:9 banner on desktop — that is art direction, and it needs <picture> with multiple <source> elements. <picture> is also the cleanest way to offer format fallbacks (AVIF first, then WebP, then JPEG) and explicit device-pixel-ratio (DPR) variants with the x descriptor.

{% load thumbnail %}

<picture>
  {# Desktop: wide 16:9 crop, 1x and 2x for retina #}
  {% thumbnail item.image "1200x675" crop="center" format="AVIF" as d1 %}
  {% thumbnail item.image "2400x1350" crop="center" format="AVIF" as d2 %}
    <source
      media="(min-width: 768px)"
      type="image/avif"
      srcset="{{ d1.url }} 1x, {{ d2.url }} 2x">
  {% endthumbnail %}{% endthumbnail %}

  {# Mobile: tight square crop, 1x and 2x #}
  {% thumbnail item.image "600x600" crop="center" format="WEBP" as m1 %}
  {% thumbnail item.image "1200x1200" crop="center" format="WEBP" as m2 %}
    <source
      media="(max-width: 767px)"
      type="image/webp"
      srcset="{{ m1.url }} 1x, {{ m2.url }} 2x">
  {% endthumbnail %}{% endthumbnail %}

  {# Fallback img: required, and where width/height/alt live #}
  {% thumbnail item.image "1200x675" crop="center" as fb %}
    <img
      src="{{ fb.url }}"
      width="{{ fb.width }}"
      height="{{ fb.height }}"
      loading="lazy"
      decoding="async"
      alt="{{ item.title }}">
  {% endthumbnail %}
</picture>

How the browser reads this: it walks the <source> elements top to bottom, takes the first whose media matches and whose type it can decode, then uses the x descriptor to pick the 1x or 2x file for the current density. If no <source> matches, it falls back to the <img>. Note that width, height, and alt always live on the inner <img>, never on <source>.

Use <picture> only when you genuinely need different crops or format fallbacks. If you just need the same image at different sizes, srcset/sizes on a single <img> is simpler and lets the browser optimize more freely.

Serving WebP and AVIF from sorl-thumbnail

sorl-thumbnail renders through Pillow (the default pil_engine), so it can output any format Pillow can write. The output format is controlled three ways, from broadest to most specific:

  1. THUMBNAIL_FORMAT in settings sets the default for every thumbnail.
  2. format="WEBP" as a tag option overrides it for one thumbnail (used in the snippets above).
  3. THUMBNAIL_PRESERVE_FORMAT = True keeps each source file's own format instead of converting.

WebP is universally supported and a safe global default. AVIF compresses even harder but depends on your Pillow build: Pillow 11.3+ bundles libavif, and earlier versions need the pillow-avif-plugin package installed so Pillow registers the AVIF codec. Once that codec is present, format="AVIF" simply works. Tune the size/quality trade-off with the quality option (or THUMBNAIL_QUALITY, default 95 — usually higher than you need):

{% load thumbnail %}

{# Per-thumbnail format + quality override #}
{% thumbnail item.image "800x600" crop="center" format="AVIF" quality=60 as im %}
    <img src="{{ im.url }}" width="{{ im.width }}" height="{{ im.height }}"
         loading="lazy" decoding="async" alt="{{ item.title }}">
{% endthumbnail %}

Because AVIF support varies by environment, the safest production pattern is the <picture> fallback shown earlier: offer AVIF, then WebP, then a JPEG <img>. The browser takes the best format it can decode, and you never serve a broken image to an old client.

Settings that make responsive thumbnails work

sorl stores thumbnail metadata in a key-value store (KVStore) so it does not re-render an image it has already generated. The defaults work, but production deployments usually tune a few keys in settings.py:

# settings.py

INSTALLED_APPS = [
    # ...
    "sorl.thumbnail",
]

# --- sorl-thumbnail configuration ---

# Pillow-backed engine (the default). Listed here for clarity.
THUMBNAIL_ENGINE = "sorl.thumbnail.engines.pil_engine.Engine"

# Where thumbnail metadata (the cache index) lives.
# cached_db is the default; Redis is faster for high-traffic sites.
THUMBNAIL_KVSTORE = "sorl.thumbnail.kvstores.cached_db_kvstore.KVStore"
# THUMBNAIL_KVSTORE = "sorl.thumbnail.kvstores.redis_kvstore.KVStore"
# THUMBNAIL_REDIS_HOST = "127.0.0.1"

# Default output format + quality for every {% thumbnail %} call.
THUMBNAIL_FORMAT = "WEBP"        # "JPEG" (default), "WEBP", "AVIF", "PNG"
THUMBNAIL_QUALITY = 80           # default is 95; 75-82 is a good web range
THUMBNAIL_PROGRESSIVE = True     # progressive JPEGs render sooner
THUMBNAIL_PRESERVE_FORMAT = False  # True keeps each source's own format

# Render placeholder images in development so missing files
# do not raise and slow down local work.
THUMBNAIL_DUMMY = DEBUG

Notes from real projects:

  • Use Redis for the KVStore at scale. cached_db is fine for small sites, but a busy gallery hammers it; the redis_kvstore backend is markedly faster and easier to flush.
  • Generated thumbnails are written to THUMBNAIL_STORAGE (your DEFAULT_FILE_STORAGE by default), so they work transparently with S3, GCS, or a CDN-backed bucket.
  • Pin Pillow in requirements.txt and rebuild your image after upgrades — AVIF/WebP behavior tracks the Pillow (and libavif/libwebp) version, not Django.
  • If you change crops or formats site-wide, clear the KVStore (./manage.py thumbnail clear) so stale entries are regenerated.

Lazy loading and stopping layout shift (Core Web Vitals)

Responsive sizing is half the battle; how the image loads decides your Core Web Vitals score. Three attributes do most of the work:

  • loading="lazy" defers off-screen images until the user scrolls near them, cutting initial payload and improving LCP. Do not lazy-load your above-the-fold hero — that delays the very element LCP measures. Mark the hero loading="eager" (or fetchpriority="high") and lazy-load everything below.
  • decoding="async" lets the browser decode the image off the main thread so it does not block rendering.
  • width and height (the intrinsic pixel dimensions, which sorl hands you as {{ im.width }}/{{ im.height }}) let the browser reserve the correct box before the image arrives. Without them the page reflows when each image loads — that is cumulative layout shift (CLS), a direct Core Web Vitals penalty. CSS like img { height: auto; } then keeps the aspect ratio while the layout stays put.
{% load thumbnail %}

{# Above-the-fold hero: eager + high priority, NOT lazy #}
{% thumbnail hero.image "1600x900" crop="center" format="WEBP" as h %}
  <img src="{{ h.url }}" width="{{ h.width }}" height="{{ h.height }}"
       loading="eager" fetchpriority="high" decoding="async"
       alt="{{ hero.title }}">
{% endthumbnail %}

{# Everything below the fold: lazy #}
{% for item in items %}
  {% thumbnail item.image "600x400" crop="center" format="WEBP" as im %}
    <img src="{{ im.url }}" width="{{ im.width }}" height="{{ im.height }}"
         loading="lazy" decoding="async" alt="{{ item.title }}">
  {% endthumbnail %}
{% endfor %}

If you want a deeper checklist of what moves these scores, our notes on improving your Google PageSpeed score walk through the render-blocking and image-weight issues that thumbnails directly affect.

sorl-thumbnail vs the alternatives

sorl-thumbnail is not the only way to do responsive images in Django. Here is an honest comparison focused on the features that matter for responsive delivery — not pricing:

Capability sorl-thumbnail easy-thumbnails django-imagekit CDN (Cloudinary / imgix / Thumbor)
Generation model On first request, then cached On first request or pre-generated On access or pre-generated (ImageSpec) On-the-fly at the edge, per URL
WebP output Yes (via Pillow) Yes Yes Yes (often automatic)
AVIF output Yes (Pillow 11.3+ / plugin) Yes (Pillow-dependent) Yes (Pillow-dependent) Yes (automatic with f_auto)
Built-in retina helper Manual in template THUMBNAIL_HIGH_RESOLUTION Manual Automatic DPR (dpr_auto)
Native srcset/picture No — you build the markup No — you build the markup No — you build the markup Yes — responsive breakpoints generated
Metadata/cache store KVStore (db, cached_db, Redis) Per-storage + db Cache framework Edge cache, no app state
Best for Small-to-mid Django sites, full control in templates Alias-driven workflows, easy retina Declarative specs tied to models Large/unpredictable libraries, global delivery

The honest summary: sorl-thumbnail gives you precise, template-level control and zero external dependencies, which is ideal when your image set is known and moderate. easy-thumbnails is a close cousin with an alias system and a built-in high-resolution flag. django-imagekit suits teams who prefer declaring image specs on the model. And once your catalogue gets large, varied, or globally distributed, a CDN with on-the-fly resizing usually wins.

When a CDN beats pre-generating thumbnails

Here is the trade-off we are upfront about with clients: pre-generating thumbnails (the sorl model) is great until you have too many variants. Every (size x format x DPR x crop) combination is a separate file that has to be rendered, stored, and invalidated. For a product catalogue with thousands of images and a half-dozen responsive variants each, that is a lot of storage and a lot of cold-cache renders.

An on-the-fly image CDN — Cloudinary, imgix, Thumbor, or Cloudflare Images — flips the model: you store one master image and request transformations by URL (for example w_800,f_auto,q_auto path segments on the master). The edge generates and caches each variant on demand, picks AVIF or WebP automatically per browser (f_auto), and handles DPR and responsive breakpoints for you. You trade a third-party dependency (and its bill) for near-zero app-side image work and global delivery.

A practical rule of thumb:

  • Stick with sorl-thumbnail when your images are moderate in number, you want everything inside Django with no extra vendor, and you value template-level control.
  • Reach for a CDN when the image library is large or unpredictable, traffic is global, or pre-generating every variant has become an operational burden.

Many teams run a hybrid: sorl (or storage-backed originals) plus a CDN in front for transformation and delivery. If you are weighing this for a content-heavy build, our web development team has shipped both models and can tell you which fits your traffic and catalogue. It is also worth keeping filenames stable and descriptive either way — see preserving file names with sorl for better SEO.

Frequently Asked Questions

How do I build a responsive srcset with sorl-thumbnail?

Call {% thumbnail %} once for each width you want (for example 400, 800, and 1200 pixels wide), assigning each to a variable with as. Then write an <img> whose srcset lists every variant followed by its w descriptor — read the real width from {{ variant.width }} rather than hardcoding it — and add a sizes attribute describing how wide the slot is at each breakpoint. The browser downloads only the smallest file that covers the slot at the current pixel density. Keep a plain src as a fallback.

When should I use <picture> instead of srcset?

Use srcset plus sizes on a single <img> when you want the same image at different sizes — that is resolution switching, and it is the common case. Reach for <picture> with multiple <source> elements when you need art direction (a different crop per breakpoint, like a square on mobile and a 16:9 banner on desktop) or format fallback (offer AVIF, then WebP, then JPEG). <picture> also gives you explicit 1x/2x device-pixel-ratio control via the x descriptor.

Can sorl-thumbnail output WebP and AVIF?

Yes. sorl renders through Pillow, so it writes any format Pillow supports. Set the output globally with THUMBNAIL_FORMAT = "WEBP" or per call with format="WEBP". WebP works everywhere. AVIF depends on your Pillow build: Pillow 11.3+ bundles libavif, and older versions need the pillow-avif-plugin package so the codec is registered. Once the codec is present, format="AVIF" works the same way. The safest pattern is a <picture> element offering AVIF, then WebP, then JPEG.

How do responsive thumbnails affect Core Web Vitals?

Strongly. Always set width and height on the <img> (sorl gives you {{ im.width }}/{{ im.height }}) so the browser reserves space before the image loads — this prevents cumulative layout shift (CLS). Add loading="lazy" to off-screen images to shrink the initial payload and decoding="async" to keep decoding off the main thread, both of which help Largest Contentful Paint (LCP). One caveat: never lazy-load your above-the-fold hero, because it is usually the LCP element — mark it loading="eager" with fetchpriority="high" instead.

Where does sorl-thumbnail store generated thumbnails and metadata?

The image files are written to THUMBNAIL_STORAGE (your default file storage unless overridden), so they work with local disk, S3, or GCS transparently. The metadata — which tells sorl an image has already been generated so it is not re-rendered — lives in the key-value store set by THUMBNAIL_KVSTORE. The default is cached_db; high-traffic sites should switch to the Redis backend for speed. If you change crops or formats site-wide, run ./manage.py thumbnail clear to drop stale entries so they regenerate.

Should I use sorl-thumbnail or an image CDN in 2026?

It depends on your image set. sorl-thumbnail is ideal when the number of images is moderate, you want everything inside Django with no extra vendor, and you value template-level control over each variant. An on-the-fly CDN (Cloudinary, imgix, Thumbor, Cloudflare Images) is the better choice when the library is large or unpredictable, traffic is global, or pre-generating every size, format, and DPR variant has become an operational burden — the edge generates and caches variants on demand and auto-selects AVIF or WebP per browser. Many teams run a hybrid of both.

Share this article