Skip to content

Blog / Playwright Python and pytest

Playwright Python and pytest

Playwright Python: Sync or Async API?

When the sync API is the right default, when async earns its complexity, and how each works with pytest.

October 23, 2026 · Blendra Team · 12 min read

Playwright for Python ships two APIs: playwright.sync_api and playwright.async_api. They have the same classes and the same methods. The only difference is how your code waits. This guide compares Playwright Python sync vs async with the same script written both ways. It shows how each one runs under pytest, explains the two event-loop failures people hit most (one error message and one silent hang), and ends with a short rule for choosing.

Versions used, as of October 2026: Python 3.12, Playwright for Python 1.64.0, pytest 9.1.1, pytest-playwright 0.10.0, pytest-playwright-asyncio 0.10.0 and pytest-asyncio 1.4.0. Every script and test below was run against a small local site before it went into this post.

TL;DR

  • Use the sync API by default. It is easier to read, and pytest-playwright (the official pytest plugin) is built on it.
  • Use the async API when your code already runs on asyncio: an async web app, an async scraper, a Jupyter notebook, or a script that must drive many pages at the same time.
  • Async does not make a pytest suite faster. pytest-asyncio runs tests one after another. For parallel tests, use pytest-xdist with either API.
  • Async tests need the session event loop. Set asyncio_default_test_loop_scope = "session" and asyncio_default_fixture_loop_scope = "session". Without them, tests hang with no error.
  • The sync API inside a running event loop fails with "It looks like you are using Playwright Sync API inside the asyncio loop." Switch that code to the async API, or move the sync code into a worker thread.

The two APIs side by side

The examples use a local site on http://127.0.0.1:8027. Its report pages take about one second to answer, so there is something real to wait for. The companion code includes the small server, which uses only Python's standard library.

Here is a script that opens three reports one after another with the sync API:

import time

from playwright.sync_api import sync_playwright, expect

BASE_URL = "http://127.0.0.1:8027"
REPORTS = ["sales", "stock", "returns"]


def main() -> None:
    started = time.perf_counter()
    with sync_playwright() as p:
        browser = p.chromium.launch()
        page = browser.new_page()
        for name in REPORTS:
            page.goto(f"{BASE_URL}/reports/{name}")
            expect(page.get_by_role("heading", level=1)).to_contain_text("report")
            print(page.title())
        browser.close()
    print(f"done in {time.perf_counter() - started:.1f} s")


if __name__ == "__main__":
    main()

And here is the same script with the async API:

import asyncio
import time

from playwright.async_api import async_playwright, expect

BASE_URL = "http://127.0.0.1:8027"
REPORTS = ["sales", "stock", "returns"]


async def main() -> None:
    started = time.perf_counter()
    async with async_playwright() as p:
        browser = await p.chromium.launch()
        page = await browser.new_page()
        for name in REPORTS:
            await page.goto(f"{BASE_URL}/reports/{name}")
            await expect(page.get_by_role("heading", level=1)).to_contain_text("report")
            print(await page.title())
        await browser.close()
    print(f"done in {time.perf_counter() - started:.1f} s")


if __name__ == "__main__":
    asyncio.run(main())

Both print the three titles. On our machine, each took between 3.4 and 4.0 seconds over two runs. That is the first lesson: async code that awaits one thing at a time is no faster than sync code. It does the same work in the same order.

What changes when you convert

The conversion is mechanical, but await does not go on everything. A call needs await when it talks to the browser. Creating a locator or reading a property does not.

Sync Async
Import from playwright.sync_api import ... from playwright.async_api import ...
Start with sync_playwright() as p: async with async_playwright() as p:
Browser actions page.goto(url) await page.goto(url)
Assertions expect(page).to_have_title("...") await expect(page).to_have_title("...")
Locators page.get_by_role("button") page.get_by_role("button") (no await)
Properties page.url page.url (no await)
Methods that read heading.inner_text() await heading.inner_text()
Waiting for an event with page.expect_response(...) as info: then info.value async with page.expect_response(...) as info: then await info.value

The last row trips people up most. Here is the async version in context:

# Fragment: inside an async function, after page.goto(...)
async with page.expect_response("**/reports/sales") as response_info:
    await page.get_by_role("link", name="Sales").click()
response = await response_info.value
print(response.status)

Under the hood, the two APIs are one library. The sync API runs the async implementation on its own private event loop, and it uses greenlet to switch between your code and that loop. You can see this in playwright/sync_api/_context_manager.py in the installed package. This is why their features match. In 1.64.0, the Page class has the same 120 public members in both.

When async does save time

Async pays off when you start several slow things and wait for all of them at once. This version opens the three reports concurrently, each in its own browser context, with asyncio.gather:

import asyncio
import time

from playwright.async_api import Browser, async_playwright

BASE_URL = "http://127.0.0.1:8027"
REPORTS = ["sales", "stock", "returns"]


async def report_title(browser: Browser, name: str) -> str:
    context = await browser.new_context()  # one isolated context per task
    try:
        page = await context.new_page()
        await page.goto(f"{BASE_URL}/reports/{name}")
        return await page.title()
    finally:
        await context.close()


async def main() -> None:
    started = time.perf_counter()
    async with async_playwright() as p:
        browser = await p.chromium.launch()
        titles = await asyncio.gather(*(report_title(browser, n) for n in REPORTS))
        await browser.close()
    print("\n".join(titles))
    print(f"done in {time.perf_counter() - started:.1f} s")


if __name__ == "__main__":
    asyncio.run(main())

It printed done in 1.3 s and done in 1.4 s on two runs, compared with about 3.5 seconds for the sequential scripts. The three one-second waits overlap. That is the case async is for: a crawler, a monitoring script, or a tool that checks many pages from one process. If your pages answer fast, there is less waiting to overlap, and you will see much less difference.

The sync API cannot do this in one thread. Playwright's docs say the API is not thread-safe, and that you should create one Playwright instance per thread if you use threads.

Sync with pytest

For tests, the official plugin is pytest-playwright, and it is sync. It gives each test a fresh page fixture:

from playwright.sync_api import Page, expect


def test_home_links_every_report(page: Page) -> None:
    page.goto("/")
    expect(page.get_by_role("heading", name="Status board")).to_be_visible()
    expect(page.get_by_role("navigation").get_by_role("link")).to_have_count(3)


def test_sales_report_opens(page: Page) -> None:
    page.goto("/")
    page.get_by_role("link", name="Sales").click()
    expect(page).to_have_title("Report: Sales")

With base_url = "http://127.0.0.1:8027" under [tool.pytest.ini_options] in pyproject.toml, pytest -v reports 2 passed. The full setup is in Playwright with pytest: a practical pytest-playwright setup. It covers fixtures, markers, traces and pytest-xdist. Nothing in it changes for this post.

Async with pytest-playwright-asyncio

For async tests, Playwright's test runner docs point to a separate plugin, pytest-playwright-asyncio. It needs pytest-asyncio 0.26 or later, and installing the plugin pulls that in for you. Use a separate virtual environment from any sync suite:

python -m venv .venv
source .venv/bin/activate
pip install pytest-playwright-asyncio
playwright install chromium

Then set the loop scopes in pyproject.toml:

[tool.pytest.ini_options]
base_url = "http://127.0.0.1:8027"
asyncio_default_test_loop_scope = "session"
asyncio_default_fixture_loop_scope = "session"
filterwarnings = [
    "error:coroutine .* was never awaited:RuntimeWarning",
    "error::pytest.PytestUnraisableExceptionWarning",
]

The filterwarnings lines are explained below. The tests import from async_api, are async def, and carry the asyncio marker with loop_scope="session", as the Playwright docs show:

import pytest
from playwright.async_api import Page, expect


@pytest.mark.asyncio(loop_scope="session")
async def test_home_links_every_report(page: Page) -> None:
    await page.goto("/")
    await expect(page.get_by_role("heading", name="Status board")).to_be_visible()
    await expect(page.get_by_role("navigation").get_by_role("link")).to_have_count(3)


@pytest.mark.asyncio(loop_scope="session")
async def test_sales_report_opens(page: Page) -> None:
    await page.goto("/")
    await page.get_by_role("link", name="Sales").click()
    await expect(page).to_have_title("Report: Sales")

Your own async fixtures use pytest_asyncio.fixture, with the same loop scope. This one lives in conftest.py:

import pytest_asyncio
from playwright.async_api import Page


@pytest_asyncio.fixture(loop_scope="session")
async def stock_page(page: Page) -> Page:
    await page.goto("/reports/stock")
    return page

And a test that uses it:

import pytest
from playwright.async_api import Page, expect


@pytest.mark.asyncio(loop_scope="session")
async def test_stock_report_has_heading(stock_page: Page) -> None:
    await expect(stock_page.get_by_role("heading", level=1)).to_have_text("Stock report")

pytest -v reports 3 passed. With pytest-xdist installed, pytest --numprocesses 2 also passes all three, so parallel runs work the same way as in a sync suite.

Two facts to know before you switch a suite:

  • Async tests still run one at a time. The pytest-asyncio docs say it runs async tests sequentially, on purpose, to keep tests isolated. An async suite is not faster than the same suite written sync.
  • The two plugins cannot be installed together. With both in one environment, every pytest run stops at startup with RuntimeError: pytest-playwright and pytest-playwright-asyncio are not compatible. Please use only one of them. Keep one plugin per environment. If you are stuck with both for a while, pytest -p no:playwright-asyncio runs a sync suite and pytest -p no:playwright runs an async one.

A missing await passes silently

This is the async pitfall that costs the most, because nothing fails:

import pytest
from playwright.async_api import Page, expect


@pytest.mark.asyncio(loop_scope="session")
async def test_title_without_await(page: Page) -> None:
    await page.goto("/reports/sales")
    expect(page).to_have_title("This title is wrong")  # missing await: never checked

The title is wrong, but pytest reports 1 passed, 1 warning. Without await, to_have_title returns a coroutine that never runs. Python only notices when the coroutine is garbage-collected:

RuntimeWarning: coroutine 'PageAssertions.to_have_title' was never awaited

The two filterwarnings lines in the pyproject.toml above turn this into a failure. You need both. With only the first line, the warning is raised during garbage collection, where pytest reports it as a PytestUnraisableExceptionWarning and the test still passes. With only the second line, nothing changes either. With both, the test fails. The first line matches only the "never awaited" message, so other libraries' runtime warnings stay warnings.

A type checker catches the same mistake before the code runs. mypy (2.4.0 here) flags it with its default settings:

tests/test_reports.py:8: error: Value of type "Coroutine[Any, Any, None]" must be used  [unused-coroutine]
tests/test_reports.py:8: note: Are you missing an await?

The sync API has no equivalent trap. A sync expect(...) call always runs.

The event loop errors

"It looks like you are using Playwright Sync API inside the asyncio loop"

Here is the smallest way to cause this error:

import asyncio

from playwright.sync_api import sync_playwright


async def main() -> None:
    # Wrong: the sync API, called from code that runs on an asyncio event loop.
    with sync_playwright() as p:
        browser = p.chromium.launch()
        browser.close()


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

sync_playwright() checks for a running event loop on the current thread when it starts, and it refuses to start if it finds one. The sync API needs its own loop, and a thread can only run one loop at a time. You get this error when the sync API is called from:

  • an async def function, directly or through a helper, as above;
  • a Jupyter notebook. The IPython docs explain that ipykernel keeps an asyncio loop running for every cell;
  • a request handler or task in an async framework.

There are two fixes. The first is to use the async API in that code. Notebooks accept top-level await, so you can start Playwright in a cell with playwright = await async_playwright().start(). The Playwright docs show the same line for the python -m asyncio REPL, and it works there as written.

The second fix is for when you must keep sync code. Run it in a worker thread, which has no running loop:

import asyncio

from playwright.sync_api import sync_playwright

BASE_URL = "http://127.0.0.1:8027"


def read_title(url: str) -> str:
    # Runs in a worker thread, which has no running event loop.
    with sync_playwright() as p:
        browser = p.chromium.launch()
        page = browser.new_page()
        page.goto(url)
        title = page.title()
        browser.close()
        return title


async def main() -> None:
    title = await asyncio.to_thread(read_title, f"{BASE_URL}/reports/sales")
    print(title)


asyncio.run(main())

It prints Report: Sales. Keep the whole Playwright session inside that one function and thread. Don't pass a sync page between threads, because the API is not thread-safe.

The same mistake looks different inside pytest. If a test becomes async def (with pytest-asyncio installed and the asyncio marker) but still uses pytest-playwright's sync page fixture, the failure is:

RuntimeError: Runner.run() cannot be called from a running event loop

The fix is the same: use one API throughout. Switch the test back to def, or move the suite to pytest-playwright-asyncio and async_api.

Async tests that hang with no error

This one is worse, because nothing is printed. Take the async suite above, remove the two asyncio_default_*_loop_scope lines, and drop loop_scope="session" from the marker:

import pytest
from playwright.async_api import Page, expect


# Wrong: no loop_scope, so the test runs on a new function-scoped loop
# while the Playwright fixtures live on the session loop.
@pytest.mark.asyncio
async def test_sales_report_opens(page: Page) -> None:
    await page.goto("/reports/sales")
    await expect(page).to_have_title("Report: Sales")

pytest prints the test name and then nothing. Playwright's 30-second navigation timeout never fires. We killed the run after 300 seconds. With -o faulthandler_timeout=15, pytest dumps the stack, and it shows the test parked in the event loop's select call, waiting for a message that never arrives.

Two panels. Left: with loop_scope session, the playwright, browser, context and page fixtures and the test share one running session loop, and the test passes. Right: with the default, the fixtures sit on a paused session loop while the test runs on its own function loop, so await page.goto gets no reply and the test hangs.

The cause is in the plugin's source: pytest-playwright-asyncio creates playwright, browser, context and page on the session-scoped loop. By default, pytest-asyncio gives each test a new function-scoped loop. The test awaits the page, but the page's messages are handled by the session loop, and that loop does not run while the test's loop does.

The same hang happens with your own fixture when asyncio_default_fixture_loop_scope is unset and the fixture has no loop_scope. We reproduced that too (killed after 60 seconds). Either of these fixes it:

  • set asyncio_default_test_loop_scope = "session" and asyncio_default_fixture_loop_scope = "session" in your config, which covers every test and fixture; or
  • pass loop_scope="session" on every @pytest.mark.asyncio and @pytest_asyncio.fixture.

We use both. The config catches anything new, and the explicit marker documents the intent in each file.

Also put a hard time limit on CI, so a hang like this fails the build instead of blocking it. A job-level timeout always works. If you use pytest-timeout (2.4.0 here), pick its thread method: pytest --timeout 60 --timeout-method thread. On this hang, the thread method printed the stack and ended the run with exit code 1 after the limit. The default signal method marked the test as failed, but the run still did not finish.

Playwright Python sync vs async: which one to choose

Choose sync when:

  • you are writing an end-to-end test suite with pytest. pytest-playwright is the plugin that Playwright's getting-started guide installs, and its first test is sync;
  • the people reading the tests are QA engineers rather than asyncio specialists;
  • you want a missed await to be impossible, rather than something you catch with warning filters.

Choose async when:

  • the code that drives the browser already runs on asyncio, such as an async web service, a bot or an async task queue. Calling the sync API there fails, as shown above;
  • you work in Jupyter;
  • one process has to drive many pages at the same time, and the waits are long enough to overlap. A test suite is not this case: use pytest-xdist workers for that.

If you are unsure, start sync. Converting a file later is mechanical: change the import, add async, and add await to every call that talks to the browser. Converting a whole pytest suite also means changing the plugin and the loop settings, so decide early if the suite will live inside async code.

What Blendra's export uses

Blendra records flows in your app and exports them as a test suite in your repo. Its Playwright Python export is in Beta and available on the Pro+ and Team plans. It uses the sync API with pytest-playwright. The generated test methods are plain def. The helper modules import from playwright.sync_api. requirements.txt pins pytest-playwright, not the asyncio plugin. Because the export is sync, the loop-scope settings in this post don't apply to it. Blendra's own cloud runs keep running your recordings with Playwright TypeScript, whichever framework a project exports.

Run the examples yourself

Download the examples. The zip has the local server (serve.py), both scripts, the concurrent version, a sync suite and an async suite, each with its own requirements.txt, plus one folder per pitfall above. Start the server with python serve.py, then run each example from its folder with the matching virtual environment. HOW-TO-RUN.txt lists every command with the result we got.

FAQ

Is the async API faster than the sync API?

Not by itself. The browser does the same work either way. Async is faster only when you overlap waits, for example several pages loading at once with asyncio.gather. A pytest suite gets no speed-up from async, because pytest-asyncio runs tests one at a time. Use pytest-xdist to run tests in parallel with either API.

Can I mix sync and async tests in one pytest suite?

Not with the Playwright plugins. pytest-playwright and pytest-playwright-asyncio refuse to load together, and an async def test can't use the sync page fixture. Keep one API per suite. If you need both, use two suites with two virtual environments.

Why does my code work in a script but fail in Jupyter?

A notebook already runs an asyncio event loop, so sync_playwright() raises "It looks like you are using Playwright Sync API inside the asyncio loop." In a notebook, use the async API with top-level await: playwright = await async_playwright().start(), then await playwright.stop() when you are done.

Do page objects work with the async API?

Yes. The methods become async def, and every call that talks to the browser gets an await. Locator attributes built in __init__ stay plain assignments. See page objects in Playwright Python for the sync version of the pattern.

Does the async API need anything special on Windows?

Playwright runs its driver in a subprocess, so on Windows asyncio has to use the ProactorEventLoop, as the Playwright library docs note. That has been Python's default on Windows since Python 3.8. It only matters if something in your code switches to the selector loop.

PlaywrightpytestPython

Start with the flow you test by hand every release.

Start free →