· Tutorials · By Simon Chiu

Playwright Python Screenshots: Sync and Async

Take screenshots with Playwright for Python: sync vs async API side by side, full-page and element captures, pytest failure shots, measured timings.

To take a screenshot with Playwright in Python, install the package and a browser, then call page.screenshot:

pip install playwright==1.64.0
python -m playwright install chromium
from playwright.sync_api import sync_playwright

with sync_playwright() as p:
    browser = p.chromium.launch()
    try:
        page = browser.new_page()
        page.goto('https://example.com')
        page.screenshot(path='example.png')
    finally:
        browser.close()

That is the sync API. Playwright for Python also ships an async API with the same methods, and the choice between them is the one decision the Node.js docs never make you take. This post puts both side by side, shows the full-page and element recipes in Python syntax, and measures when async is worth it. For options that work the same in every language (contexts, masks, network interception), the Playwright screenshot guide is the reference.

Tested on 2026-10-18 with Python 3.15.0, playwright==1.64.0 (Chromium 156.0.8078.4), pytest==9.1.1, pytest-playwright==0.10.0, macOS on an Apple M1 Pro. Every snippet on this page ran in a fresh virtual environment.

Install: two steps, not one

pip install playwright installs the Python bindings and a bundled driver. It does not download a browser. The second command does:

python -m venv .venv
source .venv/bin/activate
pip install playwright==1.64.0
python -m playwright install chromium            # macOS, Windows
python -m playwright install --with-deps chromium  # Debian or Ubuntu: also installs system libraries

Notes from our setup:

  • Use python -m playwright, not the bare playwright command. It runs the copy that belongs to the active interpreter, which avoids a browser build that does not match the package.
  • Pin the version. Each Playwright release expects one Chromium build. If two projects on one machine pin different versions, each downloads its own browser into the shared cache (~/Library/Caches/ms-playwright on macOS, ~/.cache/ms-playwright on Linux).
  • --with-deps needs root on Linux because it calls apt-get. In a Dockerfile, run it as root before you switch users.
  • Python versions: the package publishes wheels for current CPython releases. We ran 3.15.0.

Sync vs async, side by side

Both APIs expose the same objects and method names. The async version awaits each call and needs an event loop.

Sync (playwright.sync_api):

from playwright.sync_api import sync_playwright

with sync_playwright() as p:
    browser = p.chromium.launch()
    try:
        page = browser.new_page()
        page.goto('https://example.com')
        page.screenshot(path='sync.png')
    finally:
        browser.close()

Async (playwright.async_api), the same capture:

import asyncio
from playwright.async_api import async_playwright


async def main():
    async with async_playwright() as p:
        browser = await p.chromium.launch()
        try:
            page = await browser.new_page()
            await page.goto('https://example.com')
            await page.screenshot(path='async.png')
        finally:
            await browser.close()

asyncio.run(main())

How to choose:

You are writing Use Reason
A script, a cron job, a CLI Sync Less code, plain tracebacks
pytest tests Sync (with pytest-playwright) The plugin's fixtures are sync
A FastAPI or aiohttp handler Async The sync API refuses to run inside a running loop
A Jupyter notebook Async with top-level await Jupyter runs its own event loop
A batch of many URLs Async with a semaphore Pages wait on the network in parallel
Django views or Celery tasks Sync They are sync by default; async gains nothing without concurrency

The sync API is not a thin wrapper you can call from async code. If you try, Playwright stops with this error, which we reproduced by calling sync_playwright() inside an async def:

playwright._impl._errors.Error: It looks like you are using Playwright Sync API inside the asyncio loop.
Please use the Async API instead.

When async pays off: measured

We timed two versions of the same batch job: eight URLs, each written to a PNG. The sync version reuses one page in a loop. The async version opens a fresh context per URL and runs at most four at once.

import asyncio
import time
from playwright.async_api import async_playwright

URLS = [f'https://example.com/?n={i}' for i in range(8)]


async def capture(browser, sem, i, url):
    async with sem:
        context = await browser.new_context()
        try:
            page = await context.new_page()
            await page.goto(url)
            await page.screenshot(path=f'out/async-{i}.png')
        finally:
            await context.close()


async def main():
    start = time.perf_counter()
    async with async_playwright() as p:
        browser = await p.chromium.launch()
        try:
            sem = asyncio.Semaphore(4)
            await asyncio.gather(*(capture(browser, sem, i, u) for i, u in enumerate(URLS)))
        finally:
            await browser.close()
    print(f'async: {time.perf_counter() - start:.2f}s')

asyncio.run(main())

We ran each version against two targets: example.com (fast, tiny) and a local page that renders its content after a 1,500 ms API call, where each script waited for the content with get_by_test_id('price').wait_for(). Wall-clock time, including browser launch:

Target Sync loop Async, 4 concurrent Runs
example.com, 8 URLs 2.59 s, 1.55 s, 1.73 s 1.54 s, 1.33 s, 1.30 s 3 each
Local page with 1.5 s API delay, 8 URLs 15.26 s 5.02 s 1 each

On a fast page, most of the time is browser launch and rendering, so async saved a few hundred milliseconds. On a page that waits for data, the sync loop pays the delay eight times in a row and async pays it twice (two waves of four). That is the case async is for. Above about four to eight concurrent pages per browser, CPU and memory become the limit, not the event loop; measure on your own hardware before you raise the semaphore.

Recipes

The recipes use the sync API. For async, put await before each Playwright call. All option names are snakecase in Python: `fullpage,devicescalefactor,omitbackground,colorscheme`.

Viewport and device scale factor

Set the viewport and pixel density on the context, not on the screenshot:

context = browser.new_context(
    viewport={'width': 1200, 'height': 630},
    device_scale_factor=2,
)
page = context.new_page()
page.goto('https://example.com')
page.screenshot(path='og-2x.png')   # 2400 x 1260 pixels

The default viewport is 1280 x 720 when you call browser.new_page() with no options. device_scale_factor=2 doubles the pixel size of every screenshot from that context. More on this in the retina screenshots guide.

Full page

page.screenshot(path='full.png', full_page=True)

Playwright measures the scroll height and captures it in one image. It does not scroll the page first, so images with loading="lazy" below the fold stay empty. The full-page screenshot post has a scroll helper and the sticky-header fix.

One element

Every locator has its own screenshot method. It waits for the element to be visible and stable, scrolls it into view and crops to its bounding box:

page.get_by_role('link', name='Learn more').screenshot(path='link.png')
page.locator('p[lang="ru"]').screenshot(path='paragraph.png')

If the locator matches more than one element, Playwright raises a strict mode error. Narrow it with .first, .nth(i) or a more specific selector.

A region

clip takes CSS pixels relative to the page. With device_scale_factor=2, a 600 x 300 clip produces a 1200 x 600 image:

page.screenshot(path='clip.png', clip={'x': 0, 'y': 0, 'width': 600, 'height': 300})

Dark mode

context = browser.new_context(color_scheme='dark')

This sets prefers-color-scheme: dark. It only changes pages that style for it; example.com does, through color-scheme: light dark. Sites without dark styles render the same. The dark mode guide covers sites that toggle a class instead.

Hide what changes between runs

page.screenshot(
    path='masked.png',
    mask=[page.locator('p').first],
    mask_color='#111827',
    animations='disabled',
    caret='hide',
)

mask paints a box over each match. animations='disabled' fast-forwards finite CSS animations and transitions and cancels infinite ones. caret='hide' hides the text cursor, which otherwise blinks into some captures and not others.

Bytes instead of a file

page.screenshot() always returns the image as bytes, whether or not you pass path. To return it from a web handler, skip the file:

png = page.screenshot()
jpg = page.screenshot(type='jpeg', quality=80)
webp = page.screenshot(type='webp', quality=80)

In our run (1200 x 630 viewport at 2x, dark mode, example.com), these were 177,379 bytes (PNG), 149,008 bytes (JPEG at 80) and 81,948 bytes (WebP at 80). WebP without a quality is lossless and can be larger than PNG; the main guide has the full format table.

Screenshots of failing pytest tests

pytest-playwright gives you a page fixture and a flag that saves a screenshot when a test fails:

pip install pytest-playwright==0.10.0
pytest --screenshot=only-on-failure --full-page-screenshot
from playwright.sync_api import Page, expect


def test_learn_more_link(page: Page):
    page.goto('https://example.com')
    expect(page.get_by_role('link', name='Learn more')).to_be_visible()

With a deliberately failing test in the same file, the run wrote test-results/tests-test-home-py-test-deliberate-failure-chromium/test-failed-1.png. Change the folder with --output. Upload test-results/ as a CI artifact so you can see the page that failed without re-running it.

For screenshot comparisons (assert that a page still looks the same), the Python package has no built-in equivalent of the Node test runner's toHaveScreenshot. The visual comparisons guide covers that workflow in the Node runner, which you can run next to a Python codebase.

Troubleshooting

Symptom Cause Fix
Executable doesn't exist at .../chromium_headless_shell-... pip install ran, the browser download did not python -m playwright install chromium
Same error after an upgrade The new package expects a newer browser build Run the install command again after every upgrade
Host system is missing dependencies to run browsers Linux without Chromium's shared libraries python -m playwright install --with-deps chromium as root
Sync API inside the asyncio loop sync_playwright() inside an async def, FastAPI or Jupyter Use playwright.async_api and await
TypeError: Page.screenshot() got an unexpected keyword argument 'fullPage' Node option names in Python Use snakecase: `fullpage,omit_background`
strict mode violation: ... resolved to 6 elements A locator matched several elements Use .first, .nth() or a narrower locator
Blank areas in a full-page capture Lazy images never entered the viewport Scroll the page before you capture
Square boxes instead of Chinese or Arabic text on Linux No font on the server covers those scripts Install Noto CJK and Arabic font packages in the image
Script hangs on goto with wait_until='networkidle' Background requests never stop Wait for a locator instead; see what to wait for
Memory grows in a long-running worker Contexts or pages are never closed Close each context in finally; restart the browser every few hundred captures

The same capture with the Python SDK

When the job is "turn a URL into an image" and not "test my app", the browser does not need to live in your process. The RenderScreenshot Python SDK uses the same vocabulary:

import os
from renderscreenshot import Client, TakeOptions

client = Client(os.environ['RS_API_KEY'])

image = client.take(
    TakeOptions.url('https://example.com')
    .width(1200)
    .height(630)
    .scale(2)
    .dark_mode()
    .format('webp')
    .quality(80)
)

with open('og-2x.webp', 'wb') as f:
    f.write(image)

There is no browser to install, no --with-deps, and no event loop choice. If you also want a broader look at Python options (Selenium, desktop capture, HTTP APIs), see website screenshots in Python and the Selenium Python guide. To try the request above, get a free API key.

Related in this topic