---
title: "HTML to Image in Python Without imgkit: Four Routes, Tested"
description: "HTML to image in Python without imgkit: one template through wkhtmltoimage, html2image, Playwright and an API, with real output and the failures each one hides."
url: "https://html2img.com/articles/html-to-image-python/"
section: "Tutorials"
published: "2026-10-09T19:55:35.506Z"
updated: "2026-10-09T19:55:35.506Z"
---

# HTML to Image in Python Without imgkit: Four Routes, Tested

By Mike Griffiths. https://html2img.com/articles/html-to-image-python/

![HTML to Image in Python Without imgkit: Four Routes, Tested](https://a.storyblok.com/f/320619/1200x630/e1efe333b6/og.png)

You have a Python job that turns HTML into a PNG: a weekly report card, a certificate, a share image for every post. It probably calls `imgkit.from_string()`, and it worked when the template was a table and two fonts. Then someone added a CSS grid, or a chart drawn by a script, and the image came back wrong with no error anywhere.

That is not bad luck. imgkit does not render anything itself. It shells out to `wkhtmltoimage`, a frozen WebKit build from a project that has been archived. Even so, imgkit was downloaded about 400,000 times last month according to PyPI's download stats, and its last release was 1.2.3, in February 2023.

This article runs one realistic template through four Python routes: imgkit, html2image, Playwright and the [HTML to Image API](https://html2img.com/integrations/python/). Every image below is the actual output, and each section ends with the failure that route hides from you.

## The template, and what it asks of a renderer

The test template is a 1200×630 weekly summary card for a coffee shop. Nothing in it is exotic. It uses three Google Fonts, CSS custom properties, flexbox with `gap`, a four-column CSS grid, and a short script that formats the money with `Intl.NumberFormat` and draws the bar chart:

```
<style>
  :root { --accent: #C2410C; --muted: #78716C; }
  body { display: flex; flex-direction: column; gap: 28px; }
  .grid { display: grid; grid-template-columns: 1fr 1fr 1fr 1.6fr; gap: 20px; }
  #bars rect { fill: var(--accent); }
</style>

<script>
const week = { revenue: 18240, orders: 1312, days: [2210, 2380, 2290, 2650, 2930, 3240, 2540] };
const usd = new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD', maximumFractionDigits: 0 });
document.getElementById('revenue').textContent = usd.format(week.revenue);
document.getElementById('basket').textContent = `$${(week.revenue / week.orders).toFixed(2)}`;
const max = Math.max(...week.days);
document.getElementById('bars').innerHTML = week.days.map((v, i) => {
  const h = Math.round(v / max * 120);
  return `<rect x="${i * 46}" y="${130 - h}" width="34" height="${h}" rx="6"/>`;
}).join('');
document.body.dataset.ready = 'true';
</script>
```

The last line matters later. Once the chart is drawn, the template marks the page as ready, which gives a renderer something to wait for.

This is what the template should look like, rendered by the HTML to Image API:

![The correct render of the test template: a cream card headed Northgate Coffee with a 6 Oct to 12 Oct 2026 date pill, the title Week 41 at a glance in a bold serif, four white tiles in a row showing revenue $18,240, orders 1,312, average basket $13.90 with green week-on-week deltas, and an orange seven-bar chart of daily revenue](https://i.html2img.com/image-1791575031258-987916.png)

## Route 1: imgkit and the wkhtmltoimage underneath it

The imgkit call is as short as it gets:

```
import imgkit

with open("report_card.html") as f:
    html = f.read()

imgkit.from_string(html, "report_card.png", options={
    "width": 1200,
    "height": 630,
    "format": "png",
    "quiet": "",
})
```

What comes back depends entirely on which `wkhtmltoimage` binary is on the machine, so I ran it against the two you are most likely to have. The first is Ubuntu 24.04's `apt install wkhtmltopdf`, version 0.12.6, which identifies itself as AppleWebKit 602.1: the engine Safari 10 shipped in 2016. The second is the official 0.12.6.1 "patched Qt" build that many Dockerfiles download from the project's releases. That one reports AppleWebKit 534.34, a WebKit from 2011.

![Two broken renders of the same template side by side. On the left, imgkit with Ubuntu's wkhtmltoimage 0.12.6: the title overlaps the brand name, the four tiles are stacked as full-width rows, and the numbers and chart are missing, with the note RangeError: maximumFractionDigits is out of range. On the right, imgkit with the official patched Qt build: no background colour, the header stacked on two lines, labels and deltas run together on one line, no numbers, no chart, with the note SyntaxError: Parse error](https://i.html2img.com/image-1791575337053-508576.png)

Neither engine supports CSS grid or `gap` in flexbox, so the four tiles collapse into full-width rows and the spacing disappears. The 2011 build also ignores CSS custom properties, which takes out the background, the accent colour and every muted grey, and it predates the modern flexbox syntax, so the header falls onto two lines.

The script fails in both, for different reasons. The 2016 engine parses it but throws on the currency formatter, because `maximumFractionDigits: 0` is out of range for a currency in older `Intl` implementations. Every modern browser accepts it. The 2011 engine cannot parse a template literal, so the script never runs at all. Either way, the numbers and the chart never appear.

Here is the part that should worry you. imgkit returned normally both times and wrote a file. The only place either error shows up is wkhtmltoimage's own output, and only if you ask for it:

```
wkhtmltoimage --debug-javascript --width 1200 --height 630 report_card.html out.png
# Warning: file:///report_card.html:63 RangeError: maximumFractionDigits is out of range
```

Two smaller details from the same run: both builds wrote 3 MB PNGs, against 170 to 180 KB for the same pixels from Chrome, and neither is going to improve. The [wkhtmltopdf project is archived](https://html2img.com/articles/wkhtmltopdf-wkhtmltoimage-alternative/), so the engine you have is the engine you keep. You can write ES5 and avoid grid and the card will come back right. That is the trade: you design every template for a browser from 2011.

## Route 2: html2image

html2image takes the obvious next step. Instead of bundling an engine, it drives the headless mode of a Chrome, Chromium or Edge that is already installed, through the browser's own `--screenshot` flag:

```
from html2image import Html2Image

hti = Html2Image(
    size=(1200, 630),
    custom_flags=["--no-sandbox", "--hide-scrollbars"],
)
hti.screenshot(html_str=html, save_as="report_card.png")
```

Because it is Chrome doing the drawing, the output matched the reference, in about 1.7 seconds per image. The problems are all around the edges, and all three are quiet.

**It needs a browser on the box.** On a machine without one you get `FileNotFoundError: Could not find a Chrome executable on this machine, please specify it yourself.` That error at least is loud. In a slim Docker image the fix is installing Chromium, which took 393 MB here, plus the fonts your templates expect.

**It does not tell you when Chrome fails to start.** Docker runs as root by default, and Chrome refuses to run as root without `--no-sandbox`. Leave the flag out and html2image still returns the path it meant to write:

```
from pathlib import Path

paths = hti.screenshot(html_str=html, save_as="report_card.png")
print(paths)                    # ['/app/report_card.png']
print(Path(paths[0]).exists())  # False
```

No exception, no file. If you use html2image in production, check the file exists after every call.

**It cannot wait for your content.** Chrome's screenshot flag fires shortly after the page loads. To test that, I moved the chart and the numbers into a 600 ms `setTimeout`, which is roughly what a data fetch or an animated chart library does:

![Two renders of the same template through html2image. On the left, with default flags, the tiles show labels and deltas but no numbers and the chart tile is empty, captioned Captured before the 600 ms timer fired. On the right, with --virtual-time-budget=2000, the numbers and the orange bar chart are present, captioned Same HTML, with a time budget you have to guess](https://i.html2img.com/image-1791575388629-909238.png)

Passing `--virtual-time-budget=2000` in `custom_flags` fixes it, but it is a number you pick by hand, and it either wastes time on every render or turns out to be too short on the day the data is slow.

## Route 3: Playwright

Playwright gives you the browser itself, which means you can wait for exactly the thing you care about:

```
from playwright.sync_api import sync_playwright

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")
    page.wait_for_selector("body[data-ready]")
    page.screenshot(path="report_card.png")
    browser.close()
```

The render was correct on the first try, the async version was correct too, and a missing selector raises a `TimeoutError` instead of saving half a card. A cold run, launching the browser each time, took 1.75 seconds. Keeping one browser open and opening a fresh page per render brought that to about a second, most of it spent waiting for the fonts and for the network to go quiet.

The cost is what you now run. `pip install playwright` is 140 MB with its Node driver, and the browsers `playwright install chromium` puts on disk took another 654 MB here: a 393 MB Chromium plus a 261 MB headless shell (`--only-shell` skips the first). Add `--with-deps` for the system libraries, then the fonts, then a browser process per worker, its memory, and an upgrade every few weeks. If you already run browsers for end-to-end tests, that is a cost you have paid. If you only wanted images, the [Playwright comparison](https://html2img.com/compare/playwright/) sets out where the line sits, and on Lambda the [same browser has its own list of problems](https://html2img.com/articles/puppeteer-lambda-alternative-screenshots/).

## Route 4: the HTML to Image API from Python

The last route sends the same HTML to a Chrome that someone else keeps patched. The official client is a 160 KB package with no dependencies:

```
pip install html2img-client
export HTML2IMG_API_KEY=your-key
```

```
from html2img import Html2img

client = Html2img()  # reads HTML2IMG_API_KEY

response = client.html(
    html,
    width=1200,
    height=630,
    wait_for_selector="body[data-ready]",
)
client.save(response, "report_card.png")
print(response.url)  # the hosted copy on the CDN
```

That request produced the reference image at the top of this article. It came back at 2400×1260, because the HTML endpoint renders at a [device pixel ratio of 2](https://html2img.com/docs/parameters/dpi/) by default; pass `dpi=1` when you need exactly 1200×630. `wait_for_selector` does the same job as in Playwright, so the async version of the template rendered correctly without a guessed delay.

At `dpi=1` a render took about 2.1 seconds, including the round trip from the test machine and downloading the file. That is slower than a warm local browser, and it is the only route here that adds nothing to your server image.

Failures arrive as exceptions you can catch, not as a missing file:

```
from html2img import Html2img, Html2imgError, InsufficientCreditsError

client = Html2img()

try:
    response = client.html(html, width=1200, height=630, dpi=1)
    client.save(response, "report_card.png")
except InsufficientCreditsError:
    notify_billing()        # top up, or pause the job
except Html2imgError as exc:
    log.error("render failed: %s", exc)
    raise
```

The package also ships a command line tool, which is handy in a cron job or a Makefile:

```
html2img html report_card.html --width 1200 --height 630 --dpi 1 --out report_card.png
```

There is an `AsyncHtml2img` client for FastAPI and other asyncio code, and Django projects can use the [html2img-django package](https://html2img.com/integrations/django/) to generate an Open Graph image for every model instance. The [Python integration guide](https://html2img.com/integrations/python/) covers Flask, Celery and the rest of the client. If you want to check a template before writing any code, paste it into the [HTML to Image Converter](https://html2img.com/tools/html-to-image/) and see what a current Chrome makes of it.

## The four routes side by side

The same template, the same machine, the median of three runs each:

|  | imgkit | html2image | Playwright | HTML to Image API |
| --- | --- | --- | --- | --- |
| Engine | QtWebKit from 2011 or 2016 | The Chrome you install | Chromium | Chrome, kept current for you |
| Rendered this template | No | Yes | Yes | Yes |
| Waits for late content | `--javascript-delay` or `--window-status` | A guessed time budget | Any selector | Any selector |
| Extra install | 48 MB binary or Qt5 WebKit | Chrome, 393 MB here | 654 MB of browsers plus system libraries | Nothing |
| Rendering failures | Silent | Silent | Exceptions | Typed exceptions |
| Time per render | 0.4 to 1.3 s | 1.7 s | 1.75 s cold, 1.0 s warm | 2.1 s including network |

![Diagram of what each route installs. imgkit, a 68 KB wrapper, needs the wkhtmltoimage binary and draws with QtWebKit from 2011 or 2016, marked archived upstream. html2image, 212 KB, needs Chrome or Chromium installed, 393 MB here, and fails silently when Chrome will not start. Playwright, 140 MB with its driver, needs 654 MB of browser builds and gives correct output that you run yourself. html2img-client, 160 KB with no dependencies, needs nothing else and sends one HTTPS request to Chrome run by the API](https://i.html2img.com/image-1791575432026-968980.png)

## Moving an imgkit call to the API

Most imgkit code maps across in a few lines. The three entry points become two methods:

| imgkit | HTML to Image client |
| --- | --- |
| `imgkit.from_string(html, path)` | `client.save(client.html(html), path)` |
| `imgkit.from_file("card.html", path)` | `client.html(Path("card.html").read_text())` |
| `imgkit.from_url(url, path)` | `client.screenshot(url)`, then `client.save(...)` |
| `width`, `height` | `width`, `height` |
| `javascript-delay` | `ms_delay`, or better, `wait_for_selector` |
| `window-status` | `wait_for_selector` |
| `crop-x`, `crop-y`, `crop-w`, `crop-h` on a URL | `selector`, to capture one element |
| `format: jpg` | PNG by default, or `format="pdf"` |

One difference catches people out. wkhtmltoimage runs on your machine, so templates often point at local files with `file://` paths or rely on `--enable-local-file-access`. A hosted renderer cannot read your disk. Inline small assets as data URIs, put fonts behind a Google Fonts `@import`, and serve images from a public URL. For URL captures, the [Screenshot API](https://html2img.com/screenshot-api/) takes the same key and covers full-page and single-element shots.

If the same job also produces PDFs through `pdfkit`, that is the same dead engine wearing a different name, and the [WeasyPrint alternative guide](https://html2img.com/articles/weasyprint-alternative-python-html-to-pdf/) walks through moving the PDF side over. For charts specifically, [rendering Chart.js server-side from Python](https://html2img.com/articles/chartjs-server-side-rendering-image-python/) shows the same approach with a real charting library.

## When the older routes are still fine

imgkit is still a reasonable choice for templates you fully control, laid out with tables and floats, with no scripts, on a machine that cannot make outbound requests. Pin the binary and treat it as finished software, because it is.

html2image is fine for a one-off script on a laptop that already has Chrome, where you will look at every image yourself.

Playwright is the right call if you already run browsers in CI, render at high volume, and want the lowest latency per image. A warm browser beat every other correct route in this test.

For everything that runs unattended in a web app, a queue worker, a container or a function, the deciding question is whether you want a browser in your deployment. If not, the API route is the one with nothing to install and nothing that fails quietly.

---

Need report cards, certificates or share images rendered from Python without a browser on your servers? [Browse the templates gallery](https://html2img.com/templates/) or [read the docs](https://html2img.com/docs/) to get started.
