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:
- 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.
-
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>orsrcsetmarkup by hand. - 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 asxs=3("3 out of 12 columns = 25%"). WithGRID_COLUMNS: 3, the same grid isxs=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 = Falsein production settings - Configure
DEFAULT_FILE_STORAGEto use S3 or object storage - Run at least one
picturesqueue worker process -
FILE_TYPES = ["AVIF"]— AVIF is Baseline 2024, hardware-accelerated everywhere -
PIXEL_DENSITIES = [1, 2, 3]— serve 3× for flagship phones -
django_cleanupinINSTALLED_APPS— deletes all AVIF variants when a post is deleted -
image_width/image_heightfields on all models — eliminates Cumulative Layout Shift -
select_related+prefetch_relatedon feed queries — avoids N+1 database hits -
MaxSizeValidatoron allPictureFields — pair with web server upload body size limits -
GRID_COLUMNSmatches 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.
Top comments (0)