# Dynamic OG images in Django: Pillow, Playwright or an API

> Give every Django page its own og:image: draw it with Pillow, screenshot HTML with Playwright, or call an API — with on_commit, Celery and a template tag.

Published 2026-10-24 · https://shareglint.com/en/guides/dynamic-og-images-django

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

```python
# 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](https://pillow.readthedocs.io/en/stable/reference/ImageDraw.html#PIL.ImageDraw.ImageDraw.textlength)) and shrink the font when the title needs more than four lines.

## Option 2: Playwright screenshots of a Django template

```python
# 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.

```python
# 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`](https://docs.djangoproject.com/en/stable/topics/db/transactions/#performing-actions-after-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.

```python
# 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}"
```

```django
{% 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](https://shareglint.com/en/docs#signed).

## The base template

```django
{# 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

```python
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](https://developers.facebook.com/tools/debug/): it shows the image it picked, its size, and any warning about a missing or too-small `og:image`.

## FAQ

### Where should the og:image be generated in Django?

In a background task started from transaction.on_commit after the model is saved, not in the view. A link preview crawler gives up after a few seconds, and generating in the request also blocks a gunicorn worker.

### Can Pillow generate Open Graph images?

Yes. Pillow can draw text on a 1200×630 canvas and save PNG or JPEG. You have to wrap and size text yourself, and it does not lay out colour emoji or complex scripts the way a browser does, so it suits simple, predictable cards.

### How do I add og:image to a Django template?

Print a meta tag in the base template's head, filled from a block or a context variable: <meta property="og:image" content="{{ og_image }}">. The URL must be absolute (https://…), not a relative static path.

### Do I need Celery?

No. transaction.on_commit with a plain function works for low volume; it runs in the request thread after commit. Use Celery, RQ or Django's tasks framework when renders should not delay the response.
