惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

C
CXSECURITY Database RSS Feed - CXSecurity.com
B
Blog
博客园 - Franky
WordPress大学
WordPress大学
小众软件
小众软件
腾讯CDC
博客园 - 【当耐特】
S
SegmentFault 最新的问题
博客园 - 三生石上(FineUI控件)
GbyAI
GbyAI
博客园_首页
D
Darknet – Hacking Tools, Hacker News & Cyber Security
cs.CV updates on arXiv.org
cs.CV updates on arXiv.org
让小产品的独立变现更简单 - ezindie.com
让小产品的独立变现更简单 - ezindie.com
Google Online Security Blog
Google Online Security Blog
Stack Overflow Blog
Stack Overflow Blog
Jina AI
Jina AI
L
Lohrmann on Cybersecurity
E
Exploit-DB.com RSS Feed
C
Cybersecurity and Infrastructure Security Agency CISA
H
Heimdal Security Blog
N
Netflix TechBlog - Medium
Know Your Adversary
Know Your Adversary
大猫的无限游戏
大猫的无限游戏
V
Vulnerabilities – Threatpost
C
CERT Recently Published Vulnerability Notes
雷峰网
雷峰网
K
Kaspersky official blog
B
Blog RSS Feed
Recent Announcements
Recent Announcements
T
The Exploit Database - CXSecurity.com
The Cloudflare Blog
Cloudbric
Cloudbric
Y
Y Combinator Blog
D
Docker
S
Secure Thoughts
Simon Willison's Weblog
Simon Willison's Weblog
奇客Solidot–传递最新科技情报
奇客Solidot–传递最新科技情报
Cyberwarzone
Cyberwarzone
S
Securelist
Last Week in AI
Last Week in AI
OSCHINA 社区最新新闻
OSCHINA 社区最新新闻
cs.CL updates on arXiv.org
cs.CL updates on arXiv.org
Hacker News: Ask HN
Hacker News: Ask HN
W
WeLiveSecurity
MongoDB | Blog
MongoDB | Blog
P
Privacy & Cybersecurity Law Blog
The Register - Security
The Register - Security
钛媒体:引领未来商业与生活新知
钛媒体:引领未来商业与生活新知
AWS News Blog
AWS News Blog

DEV Community

Authentication Security Deep Dive: From Brute Force to Salted Hashing (With Java Examples) Why AI Systems Don’t Fail — They Drift Spilling beans for how i learn for exam😁"Reinforcement Learning Cheat Sheet" I Replaced Chrome with Safari for AI Browser Automation. Here's What Broke (and What Finally Worked) How Python Borrows Other People's Work The $40 Architecture: Processing 1 Billion API Requests with 99.99% Uptime Vibe Coding: A Workflow Guide (From Zero to SaaS) Most webhook security guides protect the wrong side. The scary part is delivery. Headless CMS for TanStack Start: Build a Blog with Cosmic EU Age Verification App "Hacked in 2 Minutes" — What Actually Happened Comfy Cloud’s delete function does not actually remove files Running AI Models on GPU Cloud Servers: A Beginner Guide Event-driven media intelligence with AWS Step Functions and Bedrock I scored 500 AI prompts across 8 quality dimensions — here's what broke How to Call Google Gemini API from Next.js (Free Tier, No Backend Needed) The Portal Protocol: Reclaiming Human Connection in the Age of AI How to Fix Your Team's Scattered Knowledge Problem With a Self-Hosted Forum Intro to tc Cloud Functors: A Graph-First Mental Model for the Modern Cloud Designing Multi-Tenant Backends With Both Ownership and Team Access I Built a Neumorphic CSS Library with 77+ Components — Here's What I Learned PostgreSQL Performance Optimization: Why Connection Pooling Is Critical at Scale Cómo construí un SaaS multi-rubro para gestionar expensas en Argentina con FastAPI + Vue 3 🚀 I Built an Ethical Hacking Scanner Tool – Open Source Project I Replaced /usage and /context in Claude Code With a Single Statusline A Pythonic Way to Handle Emails (IMAP/SMTP) with Auto-Discovery and AI-Ready Design I Collected 8.9 Million Polymarket Price Points — Here's What I Found About How Markets Really Move EcoTrack AI — Carbon Footprint Tracker & Dashboard Everyone's Using AI. No One Agrees How. 5 self-hosted ebook managers worth trying in 2026 Building Your First AI Agent with LangChain: From Chatbot to Autonomous Assistant Common SOC 2 Failures (Real World) Stop Vibe-Checking Your AI App: A Practical Guide to Evals How to Use SonarQube and SonarScanner Locally to Level Up Your Code Quality Your Next To-Do App Is Dead — I Replaced Mine with an OpenClaw AI Sign a Nostr event in 60 lines of Python using coincurve — no nostr-sdk, no nbxplorer, no rust toolchain ITGC Audit Explained Like You’re in Big 4 Patch Tuesday abril 2026: Microsoft parcha 163 vulnerabilidades y un zero-day en SharePoint Stop scraping everything: a better way to track competitor price changes Listing on MCPize + the Official MCP Registry while routing payments OUTSIDE the marketplace — how I kept 100% of my x402 revenue Building an AI-Powered Risk Intelligence System Using Serverless Architecture Why We Ripped Function Overloading Out of Our AI Toolchain Testing AI-Generated Code: How to Actually Know If It Works SaaS Churn Is Killing Your Business. Here Is What to Do About It (Without a Support Team) The Speed of AI Is No Longer Linear - And Self-Improving Models Are Why How to Implement RBAC for MCP Tools: A Practical Guide for Engineering Teams From Standard Quote to Persuasive Proposal: AI Automation for Arborists I built a CLI that scaffolds complete multi-tenant SaaS apps Axios CVE-2025–62718: The Silent SSRF Bug That Could Be Hiding in Your Node.js App Right Now The dashboard that ended our friendship Data Pipelines Explained Simply (and How to Build Them with Python) The Hidden Cost of AI Systems Nobody Talks About. undefined vs undeclared, and how typeof behaves Switching from file-based jobs to NATS/Kafka in Rust without changing code io_uring Adventures: Rust Servers That Love Syscalls Why Agentic AI is Killing the Traditional Database The POUR principles of web accessibility for developers and designers Quantum Neural Network 3D — A Deep Dive into Interactive WebGL Visualization How To Install Caveman In Codex On macOS And Windows Automation Pipeline Reliability: Why Your Workflow Breaks When Nobody Is Watching I Built an 'Open World' AI Coding Agent — It Works From ANY Folder From Freelancing to Product: A Tech Service Company's SaaS Transformation China's AI Giants: Adding Tencent Hunyuan & ByteDance Doubao to AI University (74 Providers) On the Vibe Coders and Their Lies clerk: Auto-Summarize Your Claude Code Sessions AI Weekly — 2026/04/10–04/17 | The Model Lockdown Is Here, but the Toolchain Is the Real Battleground AI 週報 — 2026/04/10–2026/04/17 模型封鎖潮來了,但工具鏈才是真戰場 Maybe this is how Open-Source apps are born... 🚀 Fine-Tune LLMs with LoRA and QLoRA: 2026 Guide tRPC v11 + Next.js App Router: End-to-End Type Safety Without the Boilerplate ShadCN UI in 2026: Why I Stopped Installing Component Libraries and Started Owning My Components SaaS Billing in React Server Components: Stripe + Supabase Without a Single `useEffect` Join our DEV Weekend Challenge — $1,000 in Prizes Across TEN winners! Submissions Due April 20 at 6:59 AM UTC. Implementing FSRS Spaced Repetition in Flutter + Supabase — Adding Memory Science to an AI Learning App "I Texted My Localhost From the Train — Claude Code Fixed the Bug Before I Got Home" I Built a Sales Prep AI and It Went Deeper Than Expected Design to Code #2: One JSON, Eleven Outputs Solving the 100M-Row Problem: A Summary Table Pattern for High-Volume Push Notification Logs Flutter Web With Wasm: What Actually Changes For Developers I Built 50 Royalty-Free Soundtracks for My Side Project in a Weekend Using AI Music Generation The Vibe Coding Security Checklist: 7 Things to Check Before You Ship Stop Letting Googlebot Guess Fix Your React App's SEO Right Desconstruindo o Streaming do LinkedIn: Como Criar um Engine de Extração de Vídeo de Alta Performance com HLS e FFmpeg (EDA Part-1) EDA (Exploratory Data Analysis) Explained With Real Life — Why Looking at Your Data Is the Most Important Step in Machine Learning Brand Relationship Management at Scale: Our 4-Touch Outreach System for 200+ Brands Why String.fromEnvironment() Might Return an Empty String in Dart JGuardrails 1.0.0 — Hardening Java LLM Apps Against Jailbreaks, Toxicity, and Prompt Injection Plan and Schedule a Full Week of Threads Content From One Claude Conversation Coding Cat Oran Ep3, Five Tables Changed Everything Updated: BFF Pattern I'm done watching freelancers get buried by 200 proposals. So I'm building the alternative. This is my first post BFS Algorithm in Java Step by Step Tutorial with Examples Tracking LLM Pricing Monthly: An Open Dataset for 22 AI Models How We Measure Content ROI on a Comparison Site: Revenue Attribution Without Perfect Data Introducing Nova AI Ops: The AI-Native Operating System for SRE Teams I built a free desktop video downloader for Windows — Grabbit How Talkie OCR Helps Vision-Impaired & Dyslexic Users Read the World Around Them VRCFaceTracking安装和iPhone面捕配置教程,有bug Even CrowdStrike Can't See Your Agents The Automation Gold Rush: What n8n Workflows and Claude Are Opening Up for Developers Right Now
Rebuilding Instagram in 2026
Johannes Maron · 2026-06-14 · via DEV Community

Building an Instagram Clone in 2026 with Django and django-pictures

A story about a billion-dollar exit, a chance meeting at a business plan competition, and why serving the right image to the right device is still harder than it should be.


Instagram Was a Django App

Before Instagram was a Meta property worth hundreds of billions of dollars, it was a Python project running on Django. Kevin Systrom and Mike Krieger launched it in October 2010 after pivoting away from a check-in app called Burbn (named for Systrom's love of bourbon whiskey). They stripped Burbn down to its one popular feature — photo sharing — renamed it Instagram, and shipped it. The technical stack was Django as the application server, PostgreSQL for user and media data, Redis for feed caches, and Gunicorn as the WSGI server. At peak load before the acquisition, those three engineers were handling 14 million users entirely within that stack.

When Facebook came calling in April 2012, the conversation was not entirely comfortable. Internal emails later released during antitrust hearings revealed Zuckerberg telling Systrom that Facebook was “messing with our photo strategy,” and that how Instagram engaged “now will also determine how much we are partners vs. competitors down the line.” Systrom reportedly told investors he feared Zuckerberg would go into “destroy mode” if he refused to sell. In the end, Systrom and Krieger accepted $1 billion in cash and Facebook stock — a deal that closed on April 9, 2012, with just 13 employees on the Instagram payroll.

Instagram never rewrote Django out. By 2016, it was the world's largest deployment of Django, written entirely in Python. They extended the ORM for custom sharding and eventually built custom data layers for likes and media, but the Django request stack — middleware, URL routing, views — stayed intact.


A Gallery, a Competition, and a Coincidence

I had been building a fine-art gallery platform around the same time the acquisition dust settled. The premise was simple: artists upload high-res photos of their artwork, buyers browse and purchase, and the platform is uncompromising about image quality. No aggressive compression. No one-size-fits-all JPEG thumbnails. The right crop and the right file for every surface—mobile preview, full-screen lightbox, room-scale preview. That last requirement turned out to be much harder than it sounded in 2012.

A few months after the acquisition, I entered a business plan competition. One of the judges was a recently acquired Instagram employee, dubbed the 100-day millionaire, who joined Instagram just weeks before the acquisition.

My obsession to provide a high-quality online experience eventually led me to django-stdimage, a package originally authored by Stanislaus Madueke (xarg on GitHub). It had the right instincts — standardised field, variant generation, async processing hooks — but it was showing its age. I took over maintainership, kept it running for a decade, and then rewrote the whole thing from scratch as django-pictures. The old package's README now points here.


The Image Serving Problem

Before diving into code, it is worth understanding why image serving is hard and why most Django projects get it wrong for years before realizing it.

The Naive Approach

The default Django developer experience is ImageField. You store one file. You reference it in a template. Done.

# The classic approach
class Post(models.Model):
    image = models.ImageField(upload_to="posts/")

<!-- One file, served to everyone -->
<img src="{{ post.image.url }}" alt="...">

This works fine for a small internal tool. For a public-facing photo platform, it is a disaster. A 4 MB DSLR upload gets served verbatim to a user on a 4G connection. The file is JPEG even if AVIF would be 60–80% smaller. There are no alternate crops for different layouts. Every device gets the same oversized blob.

The Generation-at-Request Approach

The next generation of packages — sorl-thumbnail, easy-thumbnails, and django-imagekit — solved the resizing problem by generating thumbnails on demand. The first time a particular size is requested, the package resizes the original and caches the result. Subsequent requests hit the cache.

# sorl-thumbnail
{% thumbnail post.image "300x300" crop="center" as thumb %}
<img src="{{ thumb.url }}" width="{{ thumb.width }}" height="{{ thumb.height }}">
{% endthumbnail %}

This was a real improvement. You could have a 300×300 grid thumbnail and an 800-wide detail view without storing them upfront. But the approach has three problems that matter at scale:

  1. The first request is slow. Generating a 300×300 JPEG from a 20 MP RAW-derived JPEG takes real time. That latency lands on the first unlucky user.
  2. Responsiveness requires manual work. If you need the image to be 300px wide on mobile and 600px wide on desktop, you write two separate thumbnail tags and add your own <picture> or srcset markup by hand.
  3. No native format support. Most of these packages output JPEG or PNG. Serving AVIF requires wrapping extra processing around the thumbnail library.

The Pre-Generation Approach

django-pictures takes a different philosophy: Pre-generate every variant at upload time, just as a video streaming service generates 1080p, 720p, 480p, and 360p transcodes the moment a video is uploaded, rather than transcoding on the fly as viewers connect.

Storage is cheap. Latency is expensive. The first user to request a 375px-wide AVIF of a post image should get it from storage instantly, not wait for a Pillow process to run. Pre-generation means every variant is a static file fetch.

Approach First-request latency Responsiveness Modern formats Migration support
ImageField (plain) Fast (no processing) None None Django built-in
sorl-thumbnail Slow (generates on demand) Manual srcset Limited Basic
easy-thumbnails Slow (generates on demand) Manual srcset Limited Basic
django-imagekit Slow or pre-generate Manual srcset Limited Basic
django-pictures Fast (pre-generated) Automatic srcset + <picture> AVIF (Baseline 2024) AlterPictureField

Responsive Images: Why Device Diversity Matters

A modern device landscape looks roughly like this:

  • A budget Android phone at 360px wide with a 2× display
  • A flagship Samsung or iPhone at 390px wide with a 3× display. Meaning it needs images at up to 1170px wide for a full-width image to look crisp
  • An iPad at 768px with a 2× display
  • A 1440px desktop monitor at 1×

If you serve a single 935px JPEG to all of these, the budget phone downloads 6× more data than it needs. The flagship phone sees a slightly blurry image because 935px spread across 1170 CSS pixels is less than 1×. The desktop user sees it perfectly.

The srcset attribute lets the browser pick the right file. The sizes attribute tells the browser how wide the image will actually be rendered, so it can calculate which srcset entry to request. Writing this by hand for every image, at every breakpoint, for every layout is error-prone enough that most developers don't bother. django-pictures makes it the default.


1. Project Setup

uv add "django>=6.0" django-pictures django-cleanup

uv run django-admin startproject insta .
uv run manage.py startapp accounts
uv run manage.py startapp feed

settings.py — 3-Column Grid with 3× Pixel Density

This configuration targets a 3-column feed grid and supports 1×, 2×, and 3× pixel densities to serve flagship phones like the iPhone 15 Pro and Samsung Galaxy S24 Ultra correctly.

INSTALLED_APPS = [
    "django.contrib.admin",
    "django.contrib.auth",
    "django.contrib.contenttypes",
    "django.contrib.sessions",
    "django.contrib.messages",
    "django.contrib.staticfiles",
    "pictures",
    "django_cleanup.apps.CleanupConfig",
    "accounts",
    "feed",
]

MEDIA_URL = "/media/"
MEDIA_ROOT = BASE_DIR / "media"

PICTURES = {
    "BREAKPOINTS": {
        "xs": 576,   # portrait phone
        "s":  768,   # landscape phone / small tablet
        "m":  992,   # tablet
        "l":  1200,  # small desktop
        "xl": 1400,  # large desktop
    },
    "GRID_COLUMNS": 3,        # 3-column grid throughout — no 12-col maths needed
    "CONTAINER_WIDTH": 935,   # Instagram's classic max-width
    "FILE_TYPES": ["AVIF"],   # AVIF is Baseline 2024 — the right default in 2026
    "PIXEL_DENSITIES": [1, 2, 3],  # 3× for flagship phones
    "USE_PLACEHOLDERS": True,
    "QUEUE_NAME": "pictures",
}

# Django 6 built-in async task framework — no Celery required
TASKS = {
    "default": {
        "BACKEND": "django.core.cache.backends.db.DatabaseCache",
        "QUEUES": ["default", "pictures"],
    }
}

Why GRID_COLUMNS: 3? With a 12-column default, a 4-column card grid would be written as xs=3 ("3 out of 12 columns = 25%"). With GRID_COLUMNS: 3, the same grid is xs=1 ("1 out of 3 columns"). The numbers match how you think about the layout. Set this to match your CSS grid, not the Bootstrap convention.

Why PIXEL_DENSITIES: [1, 2, 3]? The Samsung Galaxy S24 Ultra and iPhone 15 Pro both have 3× displays. Serving a 2× image to a 3× screen means every pixel is shared between 1.5 physical pixels — you get visible softness in detailed images. On a photo platform, that is noticeable. The storage cost is acceptable: AVIF at 3× is often smaller than JPEG at 1×.


2. Models

accounts/models.py

from django.contrib.auth import get_user_model
from django.db import models
from pictures.models import PictureField
from pictures.validators import MaxSizeValidator, MinSizeValidator

User = get_user_model()


class Profile(models.Model):
    user = models.OneToOneField(User, on_delete=models.CASCADE, related_name="profile")
    bio = models.TextField(blank=True, max_length=150)
    avatar = PictureField(
        upload_to="avatars/",
        aspect_ratios=["1/1"],
        width_field="avatar_width",
        height_field="avatar_height",
        blank=True,
        validators=[MinSizeValidator(150, 150), MaxSizeValidator(4096, 4096)],
    )
    avatar_width = models.PositiveIntegerField(editable=False, default=0)
    avatar_height = models.PositiveIntegerField(editable=False, default=0)
    followers = models.ManyToManyField(
        "self", symmetrical=False, related_name="following", blank=True
    )

    def __str__(self):
        return self.user.username

feed/models.py

from django.contrib.auth import get_user_model
from django.db import models
from pictures.models import PictureField
from pictures.validators import MaxSizeValidator, MinSizeValidator

User = get_user_model()


class Post(models.Model):
    author = models.ForeignKey(User, on_delete=models.CASCADE, related_name="posts")
    caption = models.TextField(blank=True, max_length=2200)
    image = PictureField(
        upload_to="posts/%Y/%m/",
        # None = serve the original ratio on the detail page
        # "1/1" = square grid thumbnail
        # "4/5" = portrait crop (Instagram's preferred grid format)
        aspect_ratios=[None, "1/1", "4/5"],
        width_field="image_width",
        height_field="image_height",
        validators=[MinSizeValidator(320, 320), MaxSizeValidator(8192, 8192)],
    )
    image_width = models.PositiveIntegerField(editable=False, default=0)
    image_height = models.PositiveIntegerField(editable=False, default=0)
    created_at = models.DateTimeField(auto_now_add=True)

    class Meta:
        ordering = ["-created_at"]


class Like(models.Model):
    user = models.ForeignKey(User, on_delete=models.CASCADE, related_name="likes")
    post = models.ForeignKey(Post, on_delete=models.CASCADE, related_name="likes")

    class Meta:
        unique_together = ("user", "post")


class Comment(models.Model):
    author = models.ForeignKey(User, on_delete=models.CASCADE, related_name="comments")
    post = models.ForeignKey(Post, on_delete=models.CASCADE, related_name="comments")
    body = models.TextField(max_length=300)
    created_at = models.DateTimeField(auto_now_add=True)

    class Meta:
        ordering = ["created_at"]

All variants — every breakpoint width, every pixel density, every aspect ratio — are generated once at upload time. After that, every image request is a direct file fetch from storage. No runtime Pillow. No per-request latency.


3. What Gets Generated

With GRID_COLUMNS: 3, CONTAINER_WIDTH: 935, PIXEL_DENSITIES: [1, 2, 3], and the breakpoints above, django-pictures will pre-generate AVIF files at widths covering every combination. For a post image displayed at one-column width (full width on mobile, then one column of three on larger screens), the library calculates the actual pixel width at each breakpoint and multiplies it by each density:

Breakpoint CSS layout width 1× file 2× file 3× file
xs (< 576px) 100vw ≈ 375px 375w.avif 750w.avif 1125w.avif
s (576–768px) 100vw ≈ 576px 576w.avif — (overlap)
m (768–992px) 1/3 of container ~312px 624w.avif 935w.avif
l (992–1200px) 1/3 of container ~312px
xl (1400px+) 1/3 of 935px 312px 624w.avif 935w.avif

The browser downloads only the entry from srcset that matches its viewport width and device pixel ratio. A 1× desktop user downloads a 312px AVIF. A 3× flagship phone at 390px CSS width downloads a 1125px AVIF. Storage holds all of them. Bandwidth serves only the right one.


4. Migrations

python manage.py makemigrations
python manage.py migrate

When you later change a PictureField — say, adding a "16/9" ratio — the generated migration will contain migrations.AlterField. Replace it with AlterPictureField:

# In the generated migration file
import pictures.migrations
import pictures.models

class Migration(migrations.Migration):
    operations = [
        pictures.migrations.AlterPictureField(
            model_name="post",
            name="image",
            field=pictures.models.PictureField(
                aspect_ratios=[None, "1/1", "4/5", "16/9"],
                width_field="image_width",
                height_field="image_height",
                upload_to="posts/%Y/%m/",
            ),
        ),
    ]

This is one of django-pictures's most practical advantages over the older generation of packages. None of them track field configuration in migrations — meaning a change to your thumbnail sizes is invisible to the migration system and requires a manual management command to re-render existing images. AlterPictureField makes the change part of your deployment diff.


5. URL Configuration

# insta/urls.py
from django.contrib import admin
from django.urls import path, include
from django.conf import settings
from django.conf.urls.static import static
from pictures.conf import get_settings

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

if get_settings().USE_PLACEHOLDERS:
    urlpatterns += [path("_pictures/", include("pictures.urls"))]

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


6. Views

# feed/views.py
from django.contrib.auth.decorators import login_required
from django.contrib.auth.mixins import LoginRequiredMixin
from django.http import JsonResponse
from django.shortcuts import get_object_or_404
from django.views.generic import ListView, CreateView, DetailView
from django.urls import reverse_lazy

from .models import Post, Like
from .forms import PostForm


class FeedView(LoginRequiredMixin, ListView):
    model = Post
    template_name = "feed/feed.html"
    context_object_name = "posts"
    paginate_by = 12

    def get_queryset(self):
        following_ids = self.request.user.profile.following.values_list(
            "user_id", flat=True
        )
        return (
            Post.objects.filter(
                author_id__in=[*following_ids, self.request.user.id]
            )
            .select_related("author", "author__profile")
            .prefetch_related("likes", "comments")
        )


class PostCreateView(LoginRequiredMixin, CreateView):
    model = Post
    form_class = PostForm
    template_name = "feed/post_create.html"
    success_url = reverse_lazy("feed")

    def form_valid(self, form):
        form.instance.author = self.request.user
        return super().form_valid(form)


class PostDetailView(DetailView):
    model = Post
    template_name = "feed/post_detail.html"
    context_object_name = "post"

    def get_queryset(self):
        return super().get_queryset().prefetch_related("likes", "comments__author")


@login_required
def toggle_like(request, pk):
    post = get_object_or_404(Post, pk=pk)
    like, created = Like.objects.get_or_create(user=request.user, post=post)
    if not created:
        like.delete()
    return JsonResponse({"likes": post.likes.count(), "liked": created})


7. Templates — the {% picture %} Tag

With GRID_COLUMNS: 3, the column arguments map directly to your CSS grid:

  • xs=1 = full-width single column on phones (1 of 3 columns)
  • xs=1 m=1 l=1 = one column at every breakpoint (the three-column grid is always three columns)

The Explore / Feed Grid

{% extends "base.html" %}
{% load pictures %}

{% block content %}
<div class="post-grid">
  {% for post in posts %}
  <article class="post-card">

    {# Square crop for the grid thumbnail.
       1 of 3 columns at every breakpoint — django-pictures calculates
       exact widths including 1×, 2×, and 3× AVIF variants. #}
    <a href="{% url 'post_detail' post.pk %}">
      {% picture post.image
          img_alt="{{ post.caption|truncatechars:80 }}"
          img_loading="lazy"
          ratio="1/1"
          xs=1 %}
    </a>

    <footer class="post-footer">
      <div class="post-author">
        {% if post.author.profile.avatar %}
          {% picture post.author.profile.avatar
              img_alt="{{ post.author.username }}"
              img_loading="lazy"
              ratio="1/1"
              xs=1 %}
        {% endif %}
        <strong>{{ post.author.username }}</strong>
      </div>
      <p class="caption">{{ post.caption }}</p>
      <button
        hx-post="{% url 'toggle_like' post.pk %}"
        hx-swap="outerHTML"
        hx-target="closest .like-wrapper"
        aria-label="Like post">
        ♥ {{ post.likes.count }}
      </button>
    </footer>

  </article>
  {% empty %}
    <p class="empty-feed">
      Follow some people to see their posts here.
      <a href="{% url 'explore' %}">Explore →</a>
    </p>
  {% endfor %}
</div>
{% endblock %}

The rendered <picture> element for a grid thumbnail looks like this:

<picture>
  <source
    type="image/avif"
    srcset="
      /media/posts/2026/06/beach/104w.avif   104w,
      /media/posts/2026/06/beach/208w.avif   208w,
      /media/posts/2026/06/beach/312w.avif   312w,
      /media/posts/2026/06/beach/624w.avif   624w,
      /media/posts/2026/06/beach/935w.avif   935w
    "
    sizes="
      (max-width: 575px)  100vw,
      (max-width: 991px)  33vw,
      312px
    ">
  <img
    src="/media/posts/2026/06/beach.jpg"
    alt="Sunset at the beach"
    width="935"
    height="935"
    loading="lazy">
</picture>

A budget phone at 360px CSS width on a 1× display downloads the 312px AVIF. A flagship at 390px on a 3× display downloads the 935px AVIF. The desktop grid column is exactly 312px wide, so it downloads the 312px file. Storage holds all five variants. Every device downloads exactly the one it needs.

The Detail Page

On the detail page, serve the original aspect ratio — no forced square crop:

{% load pictures %}

{# Full width on mobile, centred one column on desktop.
   ratio=None means use the original aspect ratio of the upload. #}
{% picture post.image
    img_alt="{{ post.caption|truncatechars:100 }}"
    img_loading="eager"
    ratio=None
    xs=1 %}

The ratio=None variant was declared in aspect_ratios=[None, "1/1", "4/5"] on the model, so it was already pre-generated at upload time. Switching ratios between templates has zero runtime cost — it is just a different file path.


8. Async Processing with Django 6 Tasks

When a photo is uploaded, every variant needs to be generated: three aspect ratios × five or six breakpoint widths × three pixel densities = upwards of 45 AVIF files per upload. That work runs asynchronously so the HTTP upload response returns immediately.

In Django 6, the built-in task framework handles this without a message broker:

# Terminal 1
python manage.py runserver

# Terminal 2 — process the pictures queue
python manage.py run_worker pictures

In production, the worker becomes a separate systemd service or container — the same pattern as a Celery worker, but without Redis or RabbitMQ. The TASKS setting in settings.py (shown in section 1) is all the configuration required.

On Django 5.2 LTS: Set PICTURES["PROCESSOR"] = "pictures.tasks.celery_process_picture" and configure Celery as usual. The Django Tasks integration requires Django 6.


9. Forms

# feed/forms.py
from django import forms
from .models import Post

class PostForm(forms.ModelForm):
    class Meta:
        model = Post
        fields = ["image", "caption"]
        widgets = {
            "caption": forms.Textarea(attrs={"rows": 3, "placeholder": "Write a caption…"}),
        }


10. The Migration Story — Why It Matters

The single most underrated feature of django-pictures relative to sorl-thumbnail or easy-thumbnails is migration support. When you add a new aspect ratio to a PictureField, Django knows about it. The change appears in a migration. Your CI pipeline enforces that the migration was created. Deployments are reproducible.

With the older generation, changing thumbnail sizes means updating a template tag and manually running a management command against production data. There is no migration. A new server brought up from a clean deploy has no thumbnails until a management command runs. django-pictures solves this properly, the Django way.


11. Production Checklist

  • USE_PLACEHOLDERS = False in production settings
  • Configure DEFAULT_FILE_STORAGE to use S3 or object storage
  • Run at least one pictures queue worker process
  • FILE_TYPES = ["AVIF"] — AVIF is Baseline 2024, hardware-accelerated everywhere
  • PIXEL_DENSITIES = [1, 2, 3] — serve 3× for flagship phones
  • django_cleanup in INSTALLED_APPS — deletes all AVIF variants when a post is deleted
  • image_width / image_height fields on all models — eliminates Cumulative Layout Shift
  • select_related + prefetch_related on feed queries — avoids N+1 database hits
  • MaxSizeValidator on all PictureFields — pair with web server upload body size limits
  • GRID_COLUMNS matches your CSS grid — 3 for a 3-column layout, not 12

Closing Thoughts

There is something satisfying about using a framework that was validated by Instagram at scale to build something Instagram-shaped. Django's core assumptions — the ORM, the migration system, the request stack — have held up for over a decade in the most demanding environment imaginable.

django-pictures takes that same pragmatism and applies it to the image problem: declare what you need in the model, let the framework do the work, get proper migrations, and serve static files from storage. No per-request processing. No hand-rolled srcset. No format negotiation in template code. Just fast, correct, responsive images including 3× AVIF for the flagship phones in your users' pockets.

The gallery platform that started this journey is still running. The images still look better than they have any right to.