---
title: "HTML to Image API for Django | Automatic Open Graph Images"
description: "Automatic Open Graph images for Django models. Official package with a model mixin, template tags, an admin panel, a management command and Celery support."
url: "https://html2img.com/integrations/django/"
---

# HTML to Image API for Django

The official Django package turns an ordinary Django template into an Open Graph image for every object you register. Saves render the card off the request cycle, unchanged inputs are skipped so routine saves cost nothing, and the resulting URL lands on the model.

- **Install:** `pip install html2img-django`
- **Registry:** [PyPI](https://pypi.org/project/html2img-django/)
- **Source:** https://github.com/html2img/html2img-django
- **Licence:** MIT
- **Requires:** Python 3.10 or newer, Django 4.2 or newer

Most Open Graph image tooling asks you to learn a card builder. This package
asks you to write a Django template. You design the card with the templating
language you already use, register the models that should have one, and every
save renders the card in real Chrome and stores the resulting URL on the object.

The package is built on the official [Python client](https://html2img.com/integrations/python/), which
is installed as a dependency. For anything other than Open Graph images
(screenshots, PDFs, named templates) reach for that client directly; the
[last section](#anything-other-than-og-images) shows how.

## What it does

- Renders a developer-authored Django template into an Open Graph image when an
  object is saved, off the request cycle, and stores the `i.html2img.com` URL on
  the model.
- Resolves settings through a cascade: project defaults, then per-model
  registration options, then per-object overrides.
- Skips the render when the inputs are unchanged, so routine saves spend no
  credits.
- Outputs the social tags itself, or hands the URL to the SEO package you already
  use.
- Ships a staff-only live preview, an admin panel, a regenerate action and a bulk
  management command.

## Requirements

| Requirement | Version |
| --- | --- |
| Python | 3.10 or newer |
| Django | 4.2 LTS, 5.x or 6.x |
| `html2img-client` | Installed automatically as a dependency |
| API key | Free, from your [dashboard](https://app.html2img.com/register) |

> **Note: An API key is required**
>
> The package generates images through the HTML to Image API, so it needs a key to
> render anything. Accounts are free and start with 50 credits, with no card needed.
> Free-tier renders are hosted for seven days; on any paid plan they are hosted
> permanently, including everything rendered before upgrading.

## Installation

```bash
pip install html2img-django
```

Add the app:

```python
INSTALLED_APPS = [
    # ...
    "html2img_django",
]
```

Put the key in the environment. This is the canonical source:

```dotenv
HTML2IMG_API_KEY=your-api-key
```

Issuing and rotating keys is covered in the
[authentication docs](https://html2img.com/docs/authentication/).

## Quick start

**1. Add the mixin to a model and migrate.** It contributes the fields the
pipeline needs, plus the editor-facing overrides:

```python
from django.db import models
from html2img_django import OpenGraphImageMixin


class Post(OpenGraphImageMixin, models.Model):
    title = models.CharField(max_length=200)
    excerpt = models.TextField(blank=True)
    published_at = models.DateTimeField(null=True, blank=True)
```

```bash
python manage.py makemigrations && python manage.py migrate
```

**2. Register the model.** Create an `og_images.py` module in the app. It is
imported automatically at startup, the same way `admin.py` is:

```python
# blog/og_images.py
from html2img_django import og_images

from .models import Post

og_images.register(Post)
```

**3. Output the tags** in your base template:

```jinja
{% load og_image %}
<head>
    {% og_image_meta object %}
</head>
```

**4. Add the preview routes.** Optional, but this is how you design the card:

```python
# urls.py
urlpatterns = [
    path("og-images/", include("html2img_django.urls")),
]
```

**5. Save a post.** The card renders in the background and the URL lands on the
object. Confirm it worked:

```bash
python manage.py html2img_test
```

Everything below is customisation.

## Designing the card

The bundled default (`html2img_django/default.html`) is a complete 1200x630 card,
and it is deliberately plain so that you replace it. Copy it into your project and
point the settings at your copy:

```python
HTML2IMG = {
    "TEMPLATE": "og/post.html",
}
```

Or per model, which is the usual case once you have more than one content type:

```python
og_images.register(Post, template="og/post.html")
og_images.register(Product, template="og/product.html", height=800)
```

The template is rendered to a string and posted to the API, so anything a browser
can render works: web fonts from a CDN, gradients, `object-fit`,
`-webkit-line-clamp` for truncation, SVG, even inline JavaScript.

### Template context

Every card is rendered with:

| Variable | What it is |
| --- | --- |
| `object` | The model instance. Also available under its model name, so a `Post` is `post`. |
| `og_headline` | The `og_image_headline` override, falling back to `title`, then `str(obj)`. |
| `og_subtitle` | The `og_image_subtitle` override, if set. |
| `site_name` | `HTML2IMG["SITE_NAME"]`, or the current `Site` name. |
| `site_logo` | `HTML2IMG["SITE_LOGO"]`, an absolute URL. |

Add your own by overriding `og_image_context()` on the model:

```python
class Post(OpenGraphImageMixin, models.Model):
    def og_image_context(self):
        return {
            "author": self.author.get_full_name(),
            "date": self.published_at,
            "image": self.cover.url if self.cover else "",
        }
```

or with a `context` callable at registration, which keeps the model clean:

```python
og_images.register(
    Post,
    template="og/post.html",
    context=lambda post: {"reading_time": post.reading_time()},
)
```

Guard optional fields with `{% if %}` so one template can serve several models:

```jinja
{% if author %}<span class="author">{{ author }}</span>{% endif %}
{% if image %}<img src="{{ image }}" alt="">{% endif %}
```

### The preview loop

Design in the browser. The package ships a preview route that renders your
template at the exact configured dimensions, with no API key required and no
credits spent, because the browser renders the same HTML the API does:

- `/og-images/preview/` for the card with representative sample data.
- `/og-images/preview/blog.Post/1/` for the card of a real object.

Both are staff-only. The admin also embeds the live preview next to the last
render, which is the parity check between what you designed and what the API
produced. Once it looks right, save the object (or use the admin's **Regenerate
Open Graph images** action) to render it for real.

> **Warning: Local media is invisible to the renderer**
>
> Renders happen on our servers in real Chrome, so every URL in your template must
> be reachable from the public internet. In production your media and static URLs
> already are. On a development site an image served from `localhost:8000` or
> `*.ddev.site` shows as missing in the rendered PNG even though the browser preview
> looks right. Reference publicly hosted assets, or expose the site with a tunnel
> (`cloudflared tunnel --url ...`, `ddev share`, `ngrok http 8000`) and build
> absolute URLs against it while you test. Google Fonts always work.

## Configuration

Everything lives in one `HTML2IMG` dict. Anything you leave out uses the default:

```python
HTML2IMG = {
    "API_KEY": None,  # falls back to $HTML2IMG_API_KEY
    "TEMPLATE": "html2img_django/default.html",
    "WIDTH": 1200,
    "HEIGHT": 630,
    "DPI": 2,
    "FORMAT": "png",
    "STORAGE": "cdn",  # or "media"
    "MEDIA_PATH": "og-images/{app_label}/{model_name}/{pk}.{extension}",
    "SITE_NAME": None,  # falls back to the Sites framework
    "SITE_LOGO": None,
    "DEFAULT_IMAGE": None,  # fallback when an object has no image
    "ON_SAVE": "thread",  # "thread", "sync" or "off"
    "ENABLED": True,
    "TIMEOUT": 35.0,
    "BASE_URL": None,  # only for private deployments
}
```

| Key | Default | Purpose |
| --- | --- | --- |
| `API_KEY` | `$HTML2IMG_API_KEY` | Sent as the `X-API-Key` header. Keep it out of version control. |
| `TEMPLATE` | the bundled default | The template rendered into the image. |
| `WIDTH` / `HEIGHT` | `1200` / `630` | Image size in CSS pixels. 1200x630 is the standard OG size. |
| `DPI` | `2` | Device pixel ratio, 1 to 4. 2 is retina. |
| `FORMAT` | `"png"` | `"png"` or `"pdf"`. |
| `STORAGE` | `"cdn"` | `"cdn"` keeps the CDN URL; `"media"` downloads into Django storage. |
| `MEDIA_PATH` | see above | Path template for `"media"` storage. |
| `SITE_NAME` | the current `Site` | Passed to every card template. |
| `SITE_LOGO` | none | Absolute URL of a logo, passed to every card template. |
| `DEFAULT_IMAGE` | none | Used by the tags when an object has no image of its own. |
| `ON_SAVE` | `"thread"` | How a save is handled; see [when images are generated](#when-images-are-generated). |
| `ENABLED` | `True` | Master switch. Set `False` in tests and local development. |
| `TIMEOUT` | `35.0` | Request timeout in seconds. |

An unknown key raises `ImproperlyConfigured` at startup rather than being silently
ignored, so a typo shows up immediately.

### A custom API client

All requests go through one client, so you can supply your own for retry
middleware, a proxy, or request logging:

```python
# blog/apps.py
from django.apps import AppConfig
from html2img import Html2img
from html2img_django.client import set_client


class BlogConfig(AppConfig):
    name = "blog"

    def ready(self):
        set_client(Html2img(transport=my_retrying_transport))
```

The transport contract is documented on the
[Python integration page](https://html2img.com/integrations/python/#custom-transports).

## Registering models

`og_images.register()` takes the model and any per-model overrides:

```python
og_images.register(
    Post,
    template="og/post.html",                               # the card design
    width=1200,
    height=630,
    dpi=2,
    format="png",                                          # or "pdf"
    storage="cdn",                                         # or "media"
    media_path="social/{app_label}/{pk}.{extension}",
    context=lambda post: {"reading_time": post.reading_time()},
    queryset=lambda: Post.objects.filter(published=True),  # what bulk regeneration walks
)
```

It also works as a decorator:

```python
@og_images.register(template="og/product.html")
class Product(OpenGraphImageMixin, models.Model): ...
```

Registrations belong in an `og_images.py` module in any installed app; they are
imported for you at startup. An unknown option, or registering a model that does
not use `OpenGraphImageMixin`, raises `ImproperlyConfigured` with an explanation
rather than failing later at render time.

### Per-object overrides

The mixin gives editors three escape hatches, all optional:

- **`og_image_headline`** and **`og_image_subtitle`** override the text on the
  card without touching the title.
- **`og_image_custom`** is an image URL that bypasses generation entirely.
  Override `get_og_custom_image()` to point it at an uploaded file instead:

  ```python
  def get_og_custom_image(self):
      return self.social_image.url if self.social_image else ""
  ```

- **`og_image_disabled`** never generates an image for this object.

## Template tags

### Standalone

```jinja
{% load og_image %}

<head>
    <title>{{ object.title }}</title>
    {% og_image_meta object %}
</head>
```

`og_image_meta` writes `og:image`, `og:image:width`, `og:image:height`,
`og:image:type`, `og:image:alt`, `twitter:card` and `twitter:image`, resolving the
cascade: the custom image, then the generated image, then `DEFAULT_IMAGE`. If
there is no image at all it writes nothing rather than empty tags.

`og_image_url` returns just the URL, for feeds, JSON-LD, emails or an `img` tag:

```jinja
<meta property="og:image" content="{% og_image_url object %}">
```

### With an existing SEO package

If you already run [django-meta](https://pypi.org/project/django-meta/),
[wagtail-metadata](https://pypi.org/project/wagtail-metadata/) or your own meta
layer, skip the tags and feed it the URL. `get_og_image_url()` on the model
resolves the same cascade:

```python
class Post(OpenGraphImageMixin, models.Model):
    def as_meta(self, request=None):
        meta = super().as_meta(request)
        meta.image = self.get_og_image_url()

        return meta
```

## When images are generated

`HTML2IMG["ON_SAVE"]` decides what a save does. In every mode the work is deferred
to `transaction.on_commit`, so nothing renders against a state that then rolls
back:

- **`"thread"`** (the default) renders in a background thread, so the save returns
  immediately. Good for the admin and for small to medium sites; the thread gets
  its own database connection and closes it when done.
- **`"sync"`** renders inline. Simple and predictable, but the save waits for the
  API, which is a few seconds.
- **`"off"`** does nothing automatically. Use this when you have a real task queue
  and want to drive it yourself.

A render is skipped when the card's inputs are unchanged, so routine saves cost
nothing. That fingerprint is what keeps a busy editorial workflow from spending a
credit every time somebody fixes a typo in the body copy.

## Celery, RQ and Huey

For anything busy, set `ON_SAVE` to `"off"` and dispatch from your own task, which
gives you retries, rate limiting and visibility:

```python
# blog/tasks.py
from celery import shared_task
from django.apps import apps
from html2img_django import generate


@shared_task(bind=True, max_retries=3)
def generate_og_image(self, label: str, pk: int) -> None:
    model = apps.get_model(label)
    obj = model.objects.filter(pk=pk).first()

    if obj is not None:
        generate(obj)
```

```python
# blog/signals.py
from django.db import transaction
from django.db.models.signals import post_save
from django.dispatch import receiver

from .models import Post
from .tasks import generate_og_image


@receiver(post_save, sender=Post)
def queue_og_image(sender, instance, **kwargs):
    transaction.on_commit(lambda: generate_og_image.delay(sender._meta.label, instance.pk))
```

`generate(obj, force=False)` is the single entry point: it resolves the settings,
renders the card, calls the API and stores the result. It returns the stored URL,
or `None` when nothing was rendered.

When you need to know **what** happened, and in particular whether a credit was
actually spent, use `generate_result()`:

```python
from html2img_django import generate_result

result = generate_result(post)

result.url       # str | None
result.rendered  # True only when the API was called and returned an image
result.reused    # True when the card was unchanged, so nothing was rendered
result.ok        # True when the object ended up with an image, either way
result.reason    # "unchanged", "opted-out", "disabled", "error", "no-url", "unsaved"
```

## Storage modes

- **`"cdn"`** (default) stores the `i.html2img.com` URL on the object. Nothing to
  serve, and the CDN handles the traffic. Renders are permanent on paid plans.
- **`"media"`** downloads the render into your Django storage (`default_storage`,
  so S3 and friends work through
  [django-storages](https://django-storages.readthedocs.io/)) and stores that URL
  instead. Use it when you would rather not depend on a third-party URL in your
  markup.

```python
HTML2IMG = {
    "STORAGE": "media",
    "MEDIA_PATH": "social/{app_label}/{model_name}/{pk}.{extension}",
}
```

If the download or the write fails, the CDN URL is kept, so a storage problem never
loses a render.

> **Tip: Free-tier renders expire after seven days**
>
> On the free tier, `"media"` storage is the safer default: the image lives in your
> own storage and keeps working after the CDN copy expires. On any paid plan CDN
> renders are permanent, and upgrading makes earlier renders permanent too.

## The admin

Mix `OpenGraphImageAdminMixin` into a `ModelAdmin` for a live preview, the last
render, and a regenerate action:

```python
from django.contrib import admin
from html2img_django.admin import OpenGraphImageAdminMixin

from .models import Post


@admin.register(Post)
class PostAdmin(OpenGraphImageAdminMixin, admin.ModelAdmin):
    list_display = ("title", "published_at", "og_image_status")
    readonly_fields = ("og_image_preview",)
```

`og_image_status` reports whether an object has a generated image, a custom one,
or has opted out. The preview panel needs the package's URLs to be included. The
mixin also adds a **Regenerate Open Graph images** bulk action to the changelist.

## Management commands

After changing a card template, regenerate across your registered models:

```bash
python manage.py generate_og_images
python manage.py generate_og_images --model blog.Post --force
python manage.py generate_og_images --dry-run
python manage.py generate_og_images --model blog.Post --limit 50
```

- `--force` ignores the input fingerprint and re-renders everything, which spends
  a credit per object.
- Without `--force`, objects whose card has not changed are reported as unchanged
  and cost nothing.
- `--dry-run` lists what would be rendered without calling the API.

The summary separates the two, so you always know what a run cost:

```text
blog.Post: 3 object(s)
  How real Chrome rendering changes social images: https://i.html2img.com/abc123.png
  Designing a card that survives a very long title: unchanged, kept https://i.html2img.com/def456.png
  Why the fingerprint matters: skipped (opted out or has a custom image)
Done. 1 rendered, 1 unchanged, 1 skipped, 0 failed.
```

The health check reports the resolved settings, the registered models and whether
your card template can be found, then renders a small test image:

```bash
python manage.py html2img_test
python manage.py html2img_test --no-render   # configuration only, no credit spent
```

## Errors and logging

Nothing in the pipeline raises into your request cycle. A failed render is logged
and the object keeps whatever image it had, so a hiccup at the API never breaks a
save or a page. Everything is logged under the `html2img_django` logger:

```python
LOGGING = {
    "version": 1,
    "loggers": {
        "html2img_django": {"handlers": ["console"], "level": "INFO"},
    },
}
```

At `DEBUG` you also see why an object was skipped (unchanged inputs, opted out,
custom image, rendering disabled). The messages carry the API's `code` and HTTP
status, which map to the codes listed in the
[getting started guide](https://html2img.com/docs/getting-started/). To distinguish "nothing needed
doing" from "something failed" in your own code, use `generate_result()` and check
`result.reason`.

## Testing your project

Switch rendering off so your test suite never calls the API:

```python
# settings/test.py
HTML2IMG = {"ENABLED": False}
```

With `ENABLED` set to `False`, saves do nothing and `generate()` returns `None`.
When you do want to assert on the pipeline, inject a client backed by a fake
transport:

```python
from html2img import Html2img
from html2img_django.client import reset_client, set_client


def fake_transport(*, method, url, headers, body, timeout):
    return 200, b'{"success": true, "url": "https://i.html2img.com/test.png"}'


set_client(Html2img("test-key", transport=fake_transport))
# ... exercise your code ...
reset_client()
```

## Anything other than OG images

The package is deliberately focused on Open Graph images. The same account and key
drive the whole API through the [Python client](https://html2img.com/integrations/python/), which is
already installed:

```python
from django.template.loader import render_to_string
from html2img import Html2img

client = Html2img()

# A screenshot of a live URL
client.screenshot("https://example.com/pricing", fullpage=True, dpi=2)

# An invoice as a vector PDF
client.html(render_to_string("invoices/show.html", {"invoice": invoice}), format="pdf")

# A ready-made template, no markup of your own
client.template("invoice-image", {"invoice_number": "INV-1042", "total": "£240.00"})
```

Screenshots suit link previews, article thumbnails and monitoring; the
[HTML to PDF API](https://html2img.com/html-to-pdf/) suits invoices, tickets and reports, where text
stays selectable and long content paginates automatically. The
[Python page](https://html2img.com/integrations/python/) covers the client in full, including the
async client, saving and downloading, error handling and the command line tool.

## Troubleshooting

**`ImproperlyConfigured: Unknown HTML2IMG settings`.** A typo in the settings
dict. The message lists the valid keys; the package refuses to start rather than
silently ignoring a misspelled `TEMPALTE`.

**No image appears after saving.** Check three things in order: `ENABLED` is not
`False` for the environment you are in, the model is registered in an
`og_images.py` inside an installed app, and `python manage.py html2img_test`
succeeds. The management command reports all three.

**The card renders but images inside it are missing.** The renderer fetches media
over the public internet, so a `localhost` or `*.ddev.site` URL is invisible to it.
Use absolute public URLs in production, and a tunnel while developing.

**Registering the model raises `ImproperlyConfigured`.** The model needs
`OpenGraphImageMixin` in its bases, and a migration after adding it. The mixin
contributes real fields, so `makemigrations` is not optional.

**Renders spend a credit on every save.** They should not: the fingerprint skips
unchanged cards. If they do, something in the card's inputs is changing every time
(a timestamp in `og_image_context()`, for instance). Use `generate_result()` and
check `result.reason` to see which inputs the pipeline thinks changed.

**Saves are slow in the admin.** `ON_SAVE` is probably `"sync"`. Switch it back to
`"thread"`, or to `"off"` with a Celery task if you want retries and visibility.

**A thread-mode render fails with a database error under a test runner.** Thread
mode opens its own connection, which does not see an open test transaction. Set
`ENABLED` to `False` in test settings, or use `"sync"` there.

## FAQ

**Do I need the Python client as well?**
It is installed automatically as a dependency. Import `html2img` directly whenever
you want a screenshot, a PDF or a named template; see
[anything other than OG images](#anything-other-than-og-images).

**Which Django versions are supported?**
Django 4.2 LTS and newer, including 5.x and 6.x, on Python 3.10 or newer.

**Can I use this with Wagtail?**
Yes, for any model you can add the mixin to. Register it as usual and feed
`get_og_image_url()` into whichever meta layer your site uses.

**Does it work with custom user-uploaded social images?**
Yes. `og_image_custom` takes a URL and wins over the generated image, and
overriding `get_og_custom_image()` lets you point that at an existing
`ImageField` rather than asking editors to paste a URL.

**How much does a busy site cost to run?**
One credit per render, and unchanged cards are not re-rendered, so the running
cost is roughly one credit per meaningful edit rather than one per save. The
management command's `--dry-run` tells you what a bulk regeneration would cost
before you run it.

**Can the card be a PDF?**
`FORMAT` accepts `"pdf"`, though it is an odd thing to put in an `og:image` tag.
It is more useful when you register a model whose "card" is really a document.

**How do I check my balance without spending a credit?**
Call [`GET /api/me`](https://html2img.com/docs/account/), or run
`python manage.py html2img_test --no-render`.
