A dynamic Open Graph image in Django is a 1200×630 PNG per object — a blog post, a product, a docs page — whose absolute URL you print in <meta property="og:image">. You can draw it with Pillow, take a screenshot of an HTML template with Playwright, or send the fields to an image API. Whatever makes the pixels, the plumbing is the same: render after the transaction commits, store the URL on the model, print it in the base template.
The three options
| Pillow | Playwright (Chromium) | HTTP API | |
|---|---|---|---|
| Layout | Python drawing calls | HTML + CSS template | HTML + CSS template |
| Server needs | Pillow wheel | Chromium (~400 MB with deps) | Nothing extra |
| Speed | Milliseconds | 1–3 s per render | About a second, elsewhere |
| Text wrapping, emoji | Manual | Browser handles it | Browser handles it |
Pillow is the right call for a logo and one line of text. Playwright is the right call if you already run browsers in CI or workers. An API is the right call when you want HTML templates but not a browser fleet in production.
Option 1: Pillow
# blog/og.py
import textwrap
from io import BytesIO
from PIL import Image, ImageDraw, ImageFont
def draw_card(title: str, site: str) -> bytes:
img = Image.new("RGB", (1200, 630), "#111827")
draw = ImageDraw.Draw(img)
big = ImageFont.truetype("static/fonts/Inter-Bold.ttf", 64)
small = ImageFont.truetype("static/fonts/Inter-Regular.ttf", 28)
lines = textwrap.wrap(title, width=28)[:4] # ~28 characters a line at 64 px
draw.multiline_text((80, 110), "\n".join(lines), font=big, fill="#f9fafb", spacing=18)
draw.text((80, 540), site, font=small, fill="#9ca3af")
out = BytesIO()
img.save(out, "PNG", optimize=True)
return out.getvalue()
textwrap.wrap counts characters, not pixels, so "WWWW" and "iiii" wrap at the same place. For exact wrapping measure with draw.textlength() (Pillow docs) and shrink the font when the title needs more than four lines.
Option 2: Playwright screenshots of a Django template
# blog/og.py
from django.template.loader import render_to_string
from playwright.sync_api import sync_playwright
def screenshot_card(post) -> bytes:
html = render_to_string("og/post.html", {"post": post})
with sync_playwright() as p:
browser = p.chromium.launch()
page = browser.new_page(viewport={"width": 1200, "height": 630})
page.set_content(html, wait_until="networkidle")
png = page.screenshot(type="png")
browser.close()
return png
Install the browser on the worker image with playwright install --with-deps chromium. Starting Chromium per render is slow; for more than a handful of images a day keep one browser open in a long-lived worker. And install fonts: a slim Debian image renders titles in a fallback face unless the font is in the template as @font-face or installed in the system.
Option 3: an image API, after commit
This version keeps the template in HTML but sends only JSON from Django. It uses the built-in blog-og template of Shareglint; your own template works the same with its slug.
# blog/signals.py
import requests
from django.conf import settings
from django.db import transaction
from django.db.models.signals import post_save
from django.dispatch import receiver
from .models import Post
def render_og_image(post_id: int) -> None:
post = Post.objects.select_related("author").get(pk=post_id)
response = requests.post(
"https://shareglint.com/v1/images?wait=true",
headers={"Authorization": f"Bearer {settings.OG_API_KEY}"},
json={
"template": "blog-og",
"modifications": {
"title": post.title,
"author": post.author.get_full_name(),
"date": post.published_at.date().isoformat(),
"site": "yourblog.com",
},
},
timeout=30,
)
response.raise_for_status()
# .update() does not send post_save, so this does not loop.
Post.objects.filter(pk=post_id).update(og_image=response.json()["url"])
@receiver(post_save, sender=Post)
def post_saved(sender, instance, created, update_fields=None, **kwargs):
if instance.published_at is None:
return
if update_fields is not None and not {"title", "author"} & set(update_fields):
return
transaction.on_commit(lambda: render_og_image(instance.pk))
transaction.on_commit matters: without it the task can start before the row is visible to another connection, or run for a save that is then rolled back. With Celery, replace the lambda body with render_og_image_task.delay(instance.pk).
The same fields sent twice return the existing image and do not use quota, so a retry is harmless. If a render takes longer than the sync window, the API answers 503 with the render id — retry the task, nothing is lost.
Or skip storage: sign the URL in a template tag
A signed URL renders on the first request and redirects to the cached file afterwards, so there is nothing to store and no task to run. The secret stays on the server; only the signature is public.
# blog/templatetags/og.py
import hashlib
import hmac
from urllib.parse import quote
from django import template
from django.conf import settings
register = template.Library()
@register.simple_tag
def og_image(template_slug, **fields):
path = f"/i/{settings.OG_ACCOUNT_ID}/{template_slug}.png"
query = "&".join(
f"{quote(str(k), safe='~')}={quote(str(v), safe='~')}"
for k, v in sorted(fields.items())
)
s = hmac.new(settings.OG_SIGNING_SECRET.encode(), f"{path}?{query}".encode(), hashlib.sha256).hexdigest()[:32]
return f"https://shareglint.com{path}?{query}&s={s}"
{% load og %}
<meta property="og:image" content="{% og_image 'blog-og' title=post.title site='yourblog.com' %}">
quote(…, safe='~') encodes everything except A–Z a–z 0–9 - _ . ~, which is RFC 3986 — the encoding the signature expects. The algorithm and a test vector are in the API docs.
The base template
{# templates/base.html #}
<head>
<meta property="og:title" content="{% block og_title %}Your site{% endblock %}">
<meta property="og:image" content="{% block og_image %}{{ default_og_image }}{% endblock %}">
<meta property="og:image:width" content="1200">
<meta property="og:image:height" content="630">
<meta name="twitter:card" content="summary_large_image">
</head>
{# templates/blog/post_detail.html #}
{% block og_image %}{{ post.og_image|default:default_og_image }}{% endblock %}
Two details catch Django sites. {% static %} gives a relative path unless STATIC_URL is absolute — crawlers need https://…, so build it with request.build_absolute_uri(). And if the og_image column is still empty when the first person shares the link, the platform caches a preview without an image; a default image avoids that.
Testing
from unittest import mock
def test_render_stores_url(db, post):
fake = mock.Mock(status_code=200)
fake.json.return_value = {"status": "completed", "url": "https://cdn.test/og.png"}
with mock.patch("blog.signals.requests.post", return_value=fake) as post_call:
render_og_image(post.pk)
post.refresh_from_db()
assert post.og_image == "https://cdn.test/og.png"
assert post_call.call_args.kwargs["json"]["modifications"]["title"] == post.title
Before calling it done, paste a real URL into Facebook's Sharing Debugger: it shows the image it picked, its size, and any warning about a missing or too-small og:image.