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 bareplaywrightcommand. 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-playwrighton macOS,~/.cache/ms-playwrighton Linux). --with-depsneeds root on Linux because it callsapt-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
- Playwright Screenshots: Tested Recipes and Pitfalls Take screenshots with Playwright: page.screenshot options, deviceScaleFactor, locators, masks, measured format sizes, auth, Linux fonts and production setup.
- Playwright Full Page Screenshot: Limits and Lazy Images Playwright full page screenshot guide: fullPage limits measured per engine, a wheel-scroll helper for lazy images and the style option for sticky headers.
- Playwright toHaveScreenshot: Visual Tests That Hold Set up Playwright visual comparisons with toHaveScreenshot: thresholds that mean something, OS font drift, Docker baselines and CI artifacts. Measured diffs.
- Playwright Wait for Page Load: What to Wait For How to make Playwright wait for a page to load before a screenshot: load states, why networkidle is discouraged, locator auto-waiting, and timed results.
- Playwright on AWS Lambda: Slim Chromium Screenshots Run Playwright on AWS Lambda with @sparticuz/chromium: handler code, package size vs Lambda limits, missing fonts, local testing with the runtime emulator.
- How to Use Playwright with Cloudflare Browser Rendering A practical tutorial on running Playwright inside Cloudflare Workers using the Browser Rendering API — installation, locators, auto-waiting, session reuse, and gotchas from running it in production.