Playwright Python is the only major test framework that ships two complete APIs — one synchronous, one asynchronous — that do exactly the same thing. The TypeScript version doesn't give you a choice; everything is async/await. The Python version does, and the choice matters for how every test in your suite reads. This lesson explains both APIs, when each fits, and why pytest-playwright (and therefore this whole course) defaults to sync.
Two APIs, one engine
Both APIs talk to the same browser binary over the same protocol. The difference is purely how Python code expresses "wait for this to finish."
- Sync API — every action blocks until the browser confirms it's done. Code reads top-to-bottom, no
await, no event loop to manage. - Async API — every action returns an awaitable. You write
await page.goto(...)exactly like the TypeScript version, and an asyncio event loop drives concurrency.
Pick one per project. Mixing them in the same test triggers RuntimeError: This event loop is already running because the sync API spawns its own event loop under the hood.
Sync API — the recommended default
The sync API reads like ordinary Python. No async def, no asyncio.run, no await:
from playwright.sync_api import sync_playwright
with sync_playwright() as p:
browser = p.chromium.launch(headless=False)
page = browser.new_page()
page.goto("https://myapp.com")
page.get_by_label("Email").fill("alice@test.com")
page.get_by_role("button", name="Submit").click()
assert "dashboard" in page.url
browser.close()Read it like a script. Each line waits for the browser before the next runs — page.goto doesn't return until navigation completes; fill doesn't return until the text is in the input. Auto-waiting still happens (Playwright waits for elements to be actionable), it just doesn't surface as await.
This is the API the Playwright with TypeScript course models, minus the async/await ceremony. If you wrote await page.goto("/dashboard") in TS, in Python sync it's page.goto("/dashboard").
Sync API in pytest-playwright — even cleaner
The standalone sync example above shows the raw API. In a real test suite, you'll never write with sync_playwright() as p: yourself — pytest-playwright does it for you and gives you the page fixture:
from playwright.sync_api import Page, expect
def test_login(page: Page):
page.goto("/login")
page.get_by_label("Email").fill("alice@test.com")
page.get_by_label("Password").fill("password123")
page.get_by_role("button", name="Sign in").click()
expect(page).to_have_url("/dashboard")The fixture handles Playwright, Browser, BrowserContext, and Page lifecycle automatically — your test only sees the Page. Every Playwright method is sync. This is the form you'll use for 99% of the course.
Async API — when concurrency matters
The async API exists for cases where you genuinely want concurrent operations within a single test or you're integrating with an async framework like FastAPI or aiohttp:
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://myapp.com")
await page.get_by_label("Email").fill("alice@test.com")
await page.get_by_role("button", name="Submit").click()
assert "dashboard" in page.url
await browser.close()
asyncio.run(main())Same test, different shape. Every action has await, the entrypoint is async def, and asyncio.run provides the event loop.
The async API is the right choice when you need things like:
- Parallel page loads inside one test —
await asyncio.gather(page1.goto(...), page2.goto(...))actually runs concurrently. The sync API would block on the first goto before starting the second. - Integrating with an async backend test harness — a FastAPI or aiohttp test server already uses asyncio; the async Playwright API plugs into the same event loop.
- Async data setup — if your test data layer (e.g.,
asyncpgfor Postgres,httpx.AsyncClientfor APIs) is already async, matching the test API avoids two event loops fighting.
If none of those apply — and for almost every QA automation suite, none do — the sync API wins on simplicity.
pytest-playwright is sync — by default
pytest-playwright's page, browser, context, and playwright fixtures are all built on the sync API. Test functions are plain def, not async def:
def test_login(page: Page):
page.goto("/login") # no await
page.get_by_label("Email").fill("alice@test.com")To use the async API with pytest you'd reach for pytest-playwright-async (a separate, less-maintained plugin) plus pytest-asyncio. The wiring is fiddlier and the gain is real only if your test data setup is genuinely async-bound. For pure browser automation, stick with sync.
Sync vs async — the practical comparison
Two APIs, same engine — when to reach for which
Sync API (recommended)
Pattern: page.goto('/x') — no await, no asyncio
Reads top-to-bottom like a normal script — easy to teach, easy to read
pytest-playwright defaults to this — no extra plugins needed
Right tool for: 99% of QA test automation
Async API (advanced)
Pattern: await page.goto('/x') — every action awaited
Needs async def, asyncio.run, and a sound mental model of the event loop
Mirrors the TypeScript Playwright runner exactly
Right tool for: real concurrency, async data layers, FastAPI test suites
A side-by-side comparison — the same login test, both ways
Sync — what you'll write in this course:
from playwright.sync_api import Page, expect
def test_login_sync(page: Page):
page.goto("/login")
page.get_by_label("Email").fill("alice@test.com")
page.get_by_label("Password").fill("password123")
page.get_by_role("button", name="Sign in").click()
expect(page).to_have_url("/dashboard")Async — what you'd write if your test suite were async-first:
import pytest
from playwright.async_api import Page, expect
@pytest.mark.asyncio
async def test_login_async(page: Page):
await page.goto("/login")
await page.get_by_label("Email").fill("alice@test.com")
await page.get_by_label("Password").fill("password123")
await page.get_by_role("button", name="Sign in").click()
await expect(page).to_have_url("/dashboard")Six identical actions. The sync version has zero awaits, no decorator, and reads like a recipe. The async version has six awaits, a @pytest.mark.asyncio decorator, and looks structurally identical to a TypeScript Playwright test. Same engine, same browser commands, same accessibility-first locators.
Coming from Playwright TypeScript?
If you're cross-trained on the TypeScript course, the mapping is direct:
- TS
await page.goto('/x')→ Python syncpage.goto("/x") - TS
await expect(locator).toBeVisible()→ Python syncexpect(locator).to_be_visible() - TS
test('name', async ({ page }) => { ... })→ Python syncdef test_name(page: Page): ...
The async Python API is essentially a 1:1 port of the TS API — same awaits, same shape — but you'd only choose it if you want the async ergonomics. In Python, the language gives you a simpler option, and most QA teams take it.
Performance — they're the same
A common misconception: "async must be faster." For a single sequential test, the sync and async APIs are equally fast. They speak the same wire protocol to the same browser; the wait-for-action time is identical. Async only buys speed when you actually parallelise — asyncio.gather of two independent operations finishes when the slower one finishes, not when their sum does. For a typical "fill form, click submit, assert URL" flow, there's nothing to parallelise inside the test, and sync wins on readability.
⚠️ Common mistakes
- Mixing
sync_playwrightandasync_playwrightimports in the same project. Pick one. Importing both leaves you one carelessawaitaway fromRuntimeError: There is no current event loop in thread. The fix is removing the unused import — and only keeping the API that matches your fixtures. - Reaching for async because the TS Playwright course used
awaiteverywhere. The TS course usedawaitbecause TypeScript's Playwright API is async-only. Python gives you a sync alternative that the JS world doesn't have. Don't carry the async habit over for ceremony's sake — write sync unless you have a concrete reason. - Using
awaitinside adef test_(...)function in pytest-playwright. The fixture is sync, the test is sync,awaitoutside anasync defraisesSyntaxError: 'await' outside async function. Pytest-playwright'spagefixture only exists in sync form — change the test signature, not the syntax.
🎯 Practice task
Compare both APIs hands-on. 20-25 minutes.
-
Continue in your
playwright-python-tests/project. Make sure your virtualenv is active andpytest-playwrightis installed. -
Create
tests/test_sync_login.py:from playwright.sync_api import Page, expect def test_saucedemo_login_sync(page: Page): page.goto("/") page.get_by_placeholder("Username").fill("standard_user") page.get_by_placeholder("Password").fill("secret_sauce") page.get_by_role("button", name="Login").click() expect(page).to_have_url("/inventory.html")Run with
pytest tests/test_sync_login.py -v. The test passes — six actions, noawaitanywhere. -
Now create
tests/test_async_login.py— the same test, async style. This one runs outside pytest because pytest-playwright is sync-only:import asyncio from playwright.async_api import async_playwright, expect async def main(): async with async_playwright() as p: browser = await p.chromium.launch(headless=False) page = await browser.new_page() await page.goto("https://www.saucedemo.com/") await page.get_by_placeholder("Username").fill("standard_user") await page.get_by_placeholder("Password").fill("secret_sauce") await page.get_by_role("button", name="Login").click() await expect(page).to_have_url("https://www.saucedemo.com/inventory.html") await browser.close() asyncio.run(main())Run it directly with
python tests/test_async_login.py. Same test, sixawaits, same green outcome. -
Try to break sync with an
await. Intest_sync_login.pychange one line toawait page.goto("/"). Re-run pytest. You'll seeSyntaxError: 'await' outside async function— Python won't even compile the file. Remove theawait; the test runs again. -
Stretch: rewrite the async test to log in twice in parallel — open two pages, navigate both at once with
asyncio.gather. Confirm both reach/inventory.html. This is the kind of speedup the async API genuinely offers; the sync API would run the two logins serially. For your actual test suite, you almost never need this, but it's worth doing once to see the model.
The rest of this course uses the sync API throughout. The next lesson is your first proper pytest-playwright test — fixtures, type hints, expect, and the project conventions you'll lean on for every chapter that follows.