playwright
Microsoft's Python bindings for Playwright: browser automation that drives Chromium, Firefox, and WebKit with a single API. It ships both a sync and an async flavor, and its locators auto-wait for elements to be actionable, which removes most of the sleep() calls that plague Selenium suites. Browser builds are pinned to the library version and installed by its own CLI. Teams use it for end-to-end testing (usually via the separate pytest-playwright plugin) and for scraping JavaScript-heavy sites.
The current default for browser automation and E2E testing in Python: auto-waiting alone eliminates a whole class of flaky tests. The price is heavyweight browser downloads and a hard dependency on Microsoft's release train.
Use it if
- You need cross-browser automation (Chromium, Firefox, WebKit) from Python behind one API
- You write E2E tests in pytest and want auto-waiting locators and retrying assertions instead of hand-rolled sleeps and flake
- You scrape JS-heavy pages and need network interception, storage-state auth reuse, screenshots, or PDF export
- You want parity with the Node, Java, and .NET Playwrights so knowledge transfers across teams
- You are testing plain HTTP APIs or server-rendered HTML; httpx or requests plus a parser is orders of magnitude lighter than driving a real browser
- CI image size and time matter a lot: playwright install downloads hundreds of MB of browser builds, re-downloaded on most version bumps
- Your org runs on Selenium Grid or needs vendor browsers Playwright does not drive (real Safari rather than WebKit builds, legacy IE modes)
- Your code runs inside an existing asyncio event loop such as Jupyter; the sync API refuses to run there and you must use the async API
- You need to automate stealthily at scale; stock Playwright is detectable by anti-bot systems and that is not a supported use case
Setup reality
pip install playwright is only half the install: you then run `playwright install` to download browser builds, several hundred MB, and on fresh Linux CI you want `playwright install chromium --with-deps` so system libraries get apt-installed too. Sync and async APIs are separate imports that cannot be mixed in one context. pytest integration is a separate pytest-playwright package rather than part of this one. Browser builds are version-pinned, so upgrading the pip package without re-running the install command gives you an executable-not-found error, which is the single most common setup complaint.
Patterns
Install the package and browsersinstall-browsers
pip install playwright
playwright install chromium
# on fresh Linux CI:
playwright install chromium --with-depspip install alone is not enough; forgetting the browser install step causes the classic "Executable doesn't exist" error after every version bump.
Open a page and screenshot (sync API)sync-basic
from playwright.sync_api import sync_playwright
with sync_playwright() as p:
browser = p.chromium.launch()
page = browser.new_page()
page.goto("https://playwright.dev")
page.screenshot(path="example.png")
browser.close()launch() is headless by default; pass headless=False to watch it. The sync API raises if called inside a running asyncio loop (e.g. Jupyter).
Async API for asyncio appsasync-basic
import asyncio
from playwright.async_api import async_playwright
async def main():
async with async_playwright() as p:
browser = await p.chromium.launch()
page = await browser.new_page()
await page.goto("https://playwright.dev")
print(await page.title())
await browser.close()
asyncio.run(main())Sync and async APIs are separate imports with identical shapes; pick one per process and do not mix them.
Find elements and interactlocate-and-interact
page.get_by_role("textbox", name="Email").fill("a@b.co")
page.get_by_label("Password").fill("hunter2")
page.get_by_role("button", name="Sign in").click()Locators auto-wait until the element is visible and actionable, so explicit waits and sleeps are almost never needed; prefer role/label locators over CSS selectors.
Retrying assertions with expectassert-page-state
from playwright.sync_api import expect
expect(page.get_by_role("heading", name="Dashboard")).to_be_visible()
expect(page).to_have_url("https://app.example.com/home")
expect(page.get_by_test_id("count")).to_have_text("3")expect() polls until the condition holds or times out (5s default); plain Python asserts on page state reintroduce the flake expect exists to kill.
E2E tests with pytestpytest-integration
# pip install pytest-playwright
from playwright.sync_api import Page, expect
def test_homepage(page: Page):
page.goto("https://playwright.dev")
expect(page).to_have_title("Playwright") # substring? no: exact
# run: pytest --browser firefox --headedThe page fixture comes from the separate pytest-playwright package and gives each test a fresh isolated context; to_have_title matches exactly unless you pass a regex.
Log in once, reuse auth statereuse-login-state
# after logging in once:
context.storage_state(path="auth.json")
# in later runs/tests:
context = browser.new_context(storage_state="auth.json")
page = context.new_page()storage_state captures cookies and localStorage; it goes stale when sessions expire, so regenerate it in CI rather than committing it.
Block or mock network requestsintercept-network
# block images to speed up scraping
page.route("**/*.{png,jpg,svg}", lambda route: route.abort())
# mock an API response
page.route("**/api/user", lambda route: route.fulfill(
status=200, json={"name": "Test User"}))Routes must be registered before goto/navigation triggers the requests; patterns are glob by default, regex also accepted.
Evaluate JavaScript in the pagerun-javascript
title = page.evaluate("() => document.title")
links = page.evaluate(
"() => [...document.querySelectorAll('a')].map(a => a.href)")Return values must be JSON-serializable; DOM nodes come back as handles, not usable objects, so map to primitives inside the page function.
Handle a file downloaddownload-file
with page.expect_download() as download_info:
page.get_by_text("Export CSV").click()
download = download_info.value
download.save_as("/tmp/report.csv")Wrap the click in expect_download before it happens; downloads are discarded when the context closes unless you save_as them.
Generate code by recording actionsrecord-codegen
playwright codegen https://example.comOpens a browser plus inspector that writes Python for your clicks; great scaffolding, but the generated selectors usually deserve cleanup.
Debug with the Playwright inspectordebug-inspector
PWDEBUG=1 pytest test_login.py
# or pause anywhere in code:
page.pause()PWDEBUG=1 forces headed mode and steps through actions; page.pause() drops you into the inspector mid-script.
Alternatives
| Package | Registry | Pick it when |
|---|---|---|
| selenium | PyPI | You need Grid infrastructure, vendor-provided drivers, or the broadest legacy browser coverage |
| seleniumbase | PyPI | Batteries-included Selenium framework, popular for scraping setups that need undetected modes |
| puppeteer | npm | Your team lives in Node and Chromium-only automation is enough |