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 insrcsetso the browser downloads only what it needs.srcset+sizeshandles 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"(orAVIF) to the tag, or setTHUMBNAIL_FORMATglobally; WebP and AVIF cut bytes 25-50% versus JPEG. - Always set
widthandheighton the<img>and addloading="lazy"plusdecoding="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
wdescriptor must be the real width of the generated file. Reading it from{{ small.width }}(rather than hardcoding400w) keeps it correct even when sorl adjusts dimensions to preserve aspect ratio. sizesis 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
srcas the fallback for browsers that ignoresrcset(effectively none in 2026, but it costs nothing). - Whitespace matters: there must be a space between the URL and the
wdescriptor, 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:
THUMBNAIL_FORMATin settings sets the default for every thumbnail.format="WEBP"as a tag option overrides it for one thumbnail (used in the snippets above).THUMBNAIL_PRESERVE_FORMAT = Truekeeps 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 = DEBUGNotes from real projects:
- Use Redis for the KVStore at scale.
cached_dbis fine for small sites, but a busy gallery hammers it; theredis_kvstorebackend is markedly faster and easier to flush. - Generated thumbnails are written to
THUMBNAIL_STORAGE(yourDEFAULT_FILE_STORAGEby default), so they work transparently with S3, GCS, or a CDN-backed bucket. - Pin Pillow in
requirements.txtand rebuild your image after upgrades — AVIF/WebP behavior tracks the Pillow (andlibavif/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 heroloading="eager"(orfetchpriority="high") and lazy-load everything below.decoding="async"lets the browser decode the image off the main thread so it does not block rendering.widthandheight(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 likeimg { 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.