Home / Blog / Playwright: TypeScript or Python?
Playwright in TypeScript or Python: what the choice costs you
Playwright's browser API is the same in TypeScript and Python: the same locators, the same assertions, near-identical method names. The test runner is Node-only, so UI mode, projects, retries, sharding, visual comparison and the HTML reporter are not available from pytest. Python drives Playwright through a pytest plugin instead. Choose Python anyway if your team reads Python.
Your backend is Python, the unit tests are pytest, and pip install pytest-playwright
gave you a passing browser test on the first afternoon. Then you watched a Playwright talk and
every line of it was TypeScript, driven from a playwright.config.ts your install does
not have, through a --ui flag it does not accept.
That gap is real and it has edges.
Everything below was run on 1 September 2026 against Playwright 1.62.0 in both languages:
@playwright/test 1.62.0 on Node 20.19.6, and playwright 1.62.0 with
pytest-playwright 0.9.0 and pytest 9.1.1 on Python 3.13.1. Where a claim came from a
run rather than from a documentation page, the page says so.
Is Playwright for Python an official binding or a port?
It is official. Playwright ships four bindings, all maintained by Microsoft: TypeScript and JavaScript, Python, Java and .NET. The vendor's own framing, from its Supported languages page, is that Playwright "is available in multiple languages that share the same underlying implementation. All core features for automating the browser are supported in all languages, while testing ecosystem integration is different."
The minor version matches everywhere; the patch does not. On 1 September 2026 the release notes
at the JavaScript, Python and Java doors all topped out at Version 1.62, and the registries that
day put npm's playwright at 1.62.1 against PyPI's 1.62.0, with no 1.62.1 published
for Python at all. A minor reaches both ecosystems; a patch need not. The 1.62.0 Node install
every sample below ran against was therefore pinned rather than resolved, because an unpinned
npm install that day would have fetched 1.62.1. Both registries move, so read this
as what they said on the day.
Two other things look like evidence of a lag and are not. The Python CI guide prints a
v1.61.0-noble Docker tag and the Java intro ships a pom.xml pinned to
1.61.0, while the three pages whose job is to state the version all say 1.62. A stale snippet
inside a guide says nothing about what your package manager will hand you.
So the choice is between two supported languages. We build Playwright suites in all four bindings, which is why the answer to this question is not a sales position for us: framework development starts from the language a team already writes. Teams arriving here from a Python Selenium suite have a second decision underneath this one, and that comparison has its own page.
How different is the code, really?
Barely different. Below is the documentation's own first test, at the TypeScript door and at the Python door. Both files were run, not transcribed.
import { test, expect } from '@playwright/test';
test('has title', async ({ page }) => {
await page.goto('https://playwright.dev/');
await expect(page).toHaveTitle(/Playwright/);
});
test('get started link', async ({ page }) => {
await page.goto('https://playwright.dev/');
await page.getByRole('link', { name: 'Get started' }).click();
await expect(page.getByRole('heading', { name: 'Installation' })).toBeVisible();
});
Playwright 1.62.0 · TypeScript · tests/example.spec.ts · 4 passed on chromium and webkit
import re
from playwright.sync_api import Page, expect
def test_has_title(page: Page):
page.goto("https://playwright.dev/")
expect(page).to_have_title(re.compile("Playwright"))
def test_get_started_link(page: Page):
page.goto("https://playwright.dev/")
page.get_by_role("link", name="Get started").click()
expect(page.get_by_role("heading", name="Installation")).to_be_visible()
Playwright 1.62.0 · Python · tests/test_pair.py · 4 passed on chromium and webkit
The translation is mechanical and one sentence covers it: camelCase becomes snake_case.
getByRole is get_by_role, toHaveTitle is
to_have_title, waitForLoadState is wait_for_load_state. An
engineer who knows the API in one language knows it in the other, and a table of two hundred
equivalences would add nothing to that rule.
Three things in those files do not follow from that rule. TypeScript takes a regular expression
literal, /Playwright/, where Python takes re.compile("Playwright") from
the standard library. Named options arrive as an object in TypeScript and as keyword arguments in
Python. And every TypeScript call is awaited, while the Python file has no await in
it at all, which a later section comes back to.
Locators, assertions, auto-waiting, network interception, storage state and codegen behave the same way on both sides, because they are the same code with a different surface on it. To see that surface put to work, the tutorial builds a suite from scratch.
The two projects stop looking alike one directory up. Here is the scaffolding that runs those tests on two browsers, in parallel, with tracing and retries.
import { defineConfig } from '@playwright/test';
export default defineConfig({
testDir: './tests',
fullyParallel: true,
retries: 2,
reporter: 'html',
use: { trace: 'on-first-retry' },
projects: [
{ name: 'chromium', use: { browserName: 'chromium' } },
{ name: 'webkit', use: { browserName: 'webkit' } },
],
});
Playwright 1.62.0 · TypeScript · playwright.config.ts · ran the suite above, 4 passed
pip install pytest-playwright pytest-xdist
playwright install chromium webkit
pytest --browser chromium --browser webkit --numprocesses auto --tracing on
Playwright 1.62.0 · Python · 8 workers, 4 passed, a trace.zip per test
The second install is not optional and the documentation mentions it in a parenthesis that is
easy to read past. pip install pytest-playwright on its own gives you
unrecognized arguments: --numprocesses; parallelism comes from pytest-xdist, which
is a separate package. Retries have no line in that command at all, which the next section gets
to.
So the difference is not in the tests, but in everything around them.
What does Python not get from the test runner?
The vendor states the split in one sentence, under JavaScript and TypeScript: "Playwright for Node.js comes with its own test runner that provides great parallelization mechanism, screenshot assertions, html reporter, automatic tracing etc." Four named capabilities and an open-ended etc.
That page is served at the Python door as well, at
playwright.dev/python/docs/languages. Both were pulled whole with curl
and the article bodies came back identical, character for character. Playwright's Python
documentation tells a Python reader, in Playwright's own words, that the test runner belongs to
Node.
The shape of the split shows up in the two command-line tools. Both installs ship a
playwright executable, and both help screens were captured on the same machine on
the same day.
$ npx playwright --help # Node, 21 commands
$ python -m playwright --help # Python, 15 commands
Commands in the Node CLI and not in the Python CLI:
test run tests with Playwright Test
show-report show HTML report
merge-reports merge multiple blob reports (for sharded tests) into a single report
clear-cache clears build and test caches
init-agents Initialize repository agents
init-skills Install Playwright agent skills
Commands in the Python CLI and not in the Node CLI:
(none)
Playwright 1.62.0 · both CLIs, 1 September 2026 · the descriptions are the tools' own help text
Every command Python has, Node has. The six Node carries on top are the runner and the things
that hang off it. test is the runner itself, and show-report and
merge-reports only mean something once a runner has produced a report to show or
blobs to merge.
The inventory, one capability at a time
Each row was checked against a 1.62.0 install rather than inferred from a missing documentation page, because a 404 proves a page is absent and not a capability.
| Capability | In the Node runner | What a Python suite does instead |
|---|---|---|
| UI mode | npx playwright test --ui, a time-travelling explorer with watch mode and
pick-locator |
Nothing equivalent. pytest --ui exits with
unrecognized arguments: --ui. You debug through the Inspector while the test
runs, and read the trace after it fails. |
| Fixtures | test.extend, worker-scoped and automatic fixtures, option fixtures, fixture
timeouts and a declared execution order |
pytest fixtures in conftest.py, plus the plugin's own:
browser_context_args, browser_type_launch_args and the rest.
Mature and widely understood, with a different set of scopes and guarantees. The long form
is on the fixtures page. |
| Projects | Named configuration groups with dependencies, setup and teardown projects, and a
per-project use block |
--browser, repeated. pytest --browser chromium --browser webkit
runs both, which covers the commonest use of projects. The dependency graph, the setup
project and the per-project options have no equivalent. |
| Retries | retries in the config, a flaky classification in the report, serial mode |
Not in the plugin. The flag list pytest --help prints under
Playwright holds eleven entries and none of them is a retry. Retrying means
reaching for a third-party pytest plugin. |
| Sharding | --shard=1/4 across machines, then merge-reports into one
report |
No --shard: pytest exits with
unrecognized arguments: --shard=1/2. Splitting across machines means dividing
the suite yourself, by path or by marker, and there is no merge step to put the results
back together. |
| The HTML reporter | The built-in HTML report, show-report, and the other reporters alongside
it |
pytest's reporting ecosystem, which is large and predates Playwright.
show-report is not a command in the Python CLI. |
| Visual comparison | expect(page).toHaveScreenshot(), which stores a reference image the first time
it runs and diffs every later run against it |
Absent. In the installed 1.62.0 Python package, to_have_screenshot is on
neither the page nor the locator assertion class. --screenshot captures an
image; nothing in the binding compares two. |
| One config file | playwright.config.ts, type-checked, holding every option in one place |
CLI flags, an addopts line in pytest.ini, and fixture overrides
in conftest.py. The same settings exist, spread across three files instead of
one. |
| Component testing | Mounting a component and testing it without the application around it | Node only. A Python suite tests the rendered application. |
What Python does get, and it is most of the framework
A list of losses with no credits beside it would mislead, so here are the credits, all verified on the same install.
- The trace viewer, in full.
pytest --tracing onwrote atrace.zipfor each of the four tests in the run above, andshow-traceis a command in the Python CLI. The most useful debugging artifact Playwright produces transfers in one piece. The config-driven form does not:trace: 'on-first-retry'needs a config file and a retries model, and the plugin offerson,offandretain-on-failure. - Parallel execution. Through pytest-xdist rather than the runner's worker model, and it works: that four-test run finished across eight worker processes.
- ARIA snapshot testing.
to_match_aria_snapshot()is on both Python assertion classes and passed against a form in a scratch file. Accessibility-tree snapshots are a different feature from screenshot comparison, and confusing the two is how an article ends up denying something that exists. - The Playwright Inspector, codegen and the assertion library. Twenty-six locator assertions and three page assertions in the Python package, counted from the installed classes.
- Network mocking, API testing, storage state, browser installation and the CI images. None of these live in the runner, so none of them are touched by anything above.
Java and .NET sit on the same side of this line, which makes it structural rather than a slight aimed at Python. Playwright's guidance for Java is that "you can choose any testing framework such as JUnit or TestNG based on your project requirements", and "Playwright for .NET comes with MSTest, NUnit, xUnit, and xUnit v3 base classes for writing end-to-end tests." One binding ships a runner and three delegate to the one their language already had.
The day this bites you
It is the morning the Python suite crosses forty minutes and somebody asks for it on four
machines. In TypeScript that is --shard=1/4 in the pipeline and
merge-reports at the end, and the change is measured in lines of YAML. In Python you
write the split yourself: divide the suite by directory or by marker, keep the division balanced
as the suite grows, run four jobs that know nothing about each other, and live with four separate
result sets, because nothing merges them.
It is a solvable afternoon and then a thing somebody owns. Sharding is the row on that table with a bill attached, and a lead with a long suite should read it twice.
Sync or async, and what else feels different
TypeScript has one API and it is asynchronous. Every call is awaited, and a forgotten
await leaves a floating promise and a test that passes without having checked
anything.
Python has two. sync_playwright reads top to bottom with no await
anywhere, and async_playwright exists for a project already built on asyncio. The
pytest plugin's page fixture is the synchronous one, which is why the Python sample
above carries no keyword that TypeScript needed twice in every test. A Python team gets
straight-line test code, and that is an ergonomic win rather than a consolation prize.
The clearest case is waiting for something a click causes. Both files below were run.
import { test, expect } from '@playwright/test';
test('the invoice opens in a new tab', async ({ page }) => {
await page.setContent('<a href="https://playwright.dev/" target="_blank">View invoice</a>');
const popupPromise = page.waitForEvent('popup');
await page.getByText('View invoice').click();
const popup = await popupPromise;
await expect(popup).toHaveTitle(/Playwright/);
});
Playwright 1.62.0 · TypeScript · tests/popup.spec.ts · 1 passed on chromium
import re
from playwright.sync_api import Page, expect
def test_the_invoice_opens_in_a_new_tab(page: Page):
page.set_content('<a href="https://playwright.dev/" target="_blank">View invoice</a>')
with page.expect_popup() as popup_info:
page.get_by_text("View invoice").click()
popup = popup_info.value
expect(popup).to_have_title(re.compile("Playwright"))
Playwright 1.62.0 · Python · tests/test_popup.py · 1 passed on chromium
TypeScript needs three statements and a variable holding a promise you must be careful not to
await too early. Python puts the click inside a with block and the language handles
the ordering. There are eleven expect_* context managers on the Python
Page class, covering downloads, responses, requests, console messages and file
choosers. Python here is not TypeScript with underscores; it has its own shape, and in this case
the shape is nicer.
The snake_case rule costs something small many times a day. Conference talks, blog posts, Stack Overflow answers and model completions about Playwright are in camelCase, and a Python engineer translates in their head on the way past. It is a real tax even though each payment is trivial.
One footgun is documented and it is the mistake a Python developer new to browser automation
makes on day one. time.sleep() leaves the page in an outdated state: Playwright
drives the browser through asynchronous operations internally, and blocking the thread with the
standard library stops those from being processed. The Known issues section of the
Python library guide says so and points
at page.wait_for_timeout() instead. Better
again is to wait for nothing and let auto-waiting do its job.
Where the Python documentation is thinner, with a worked example
The Pytest Plugin Reference shows
how to override context options for a single test with the
browser_context_args marker. Run its sample verbatim on 1.62.0 and it fails. The
sample sets a timezone and a locale, then asserts that window.navigator.userAgent
equals "Europe/Berlin" and that window.navigator.languages equals
["de-DE"] after asking for en-GB. The marker is correct and the
assertions underneath it check the wrong things.
import pytest
@pytest.mark.browser_context_args(timezone_id="Europe/Berlin", locale="en-GB")
def test_the_context_takes_the_marker(page):
assert page.evaluate("Intl.DateTimeFormat().resolvedOptions().timeZone") == "Europe/Berlin"
assert page.evaluate("navigator.languages") == ["en-GB"]
Playwright 1.62.0 · Python · the documented marker, with assertions that pass · 1 passed on chromium
The mechanism works exactly as advertised. Assert against the option you set and the test goes green. Read it as calibration: the guides at the Python door are less exercised than the TypeScript ones, so budget for reading the binding's source occasionally instead of only its documentation.
When is Python the right answer?
Often, and the case is stronger than the table above makes it look.
A suite nobody on the team can read is worse than a suite with no UI mode. UI mode is a debugging convenience. Fluency decides whether tests get written at all, whether they get fixed the week they break, and whether the third engineer is willing to touch them. A Python team writing TypeScript they half-know produces a suite that decays, and a decayed suite has no features whatsoever.
Then there is everything the tests need in order to run, which is usually the largest cost in
this decision and stays invisible until the work starts. A Python shop already has a
conftest.py, model factories, a database fixture that rolls back, an app factory and
a seeded user. Choosing TypeScript rebuilds all of it from nothing, in a language the people who
wrote the first version do not use. The tests themselves are the small half of that job.
The test data path is the sharpest version of the same point. If a browser test needs a seeded order in a particular state, sharing a language with the backend means calling the code that already knows how to build one. From TypeScript it means an HTTP endpoint somebody writes and maintains for the tests alone.
The remaining arguments land in the same direction. One language in the repository is one toolchain, one linter, one CI cache and one set of people who can review a pull request. And you are not hiring a Playwright specialist here; you are asking Python engineers you already employ to write browser tests, and they will be quicker at it in the language they think in. The structural questions do not change with the binding: whether a page object class is worth the layer is argued the same way in either.
The boundary runs where the losses stop being about convenience. Sharding matters once wall time is a release constraint rather than an annoyance. Visual comparison matters if catching visual regressions is the reason you are buying end-to-end tests at all. Neither is solved by fluency, and a team that needs either has a harder decision than a team that does not.
So which one should we choose?
Three rules, each with a condition attached.
Greenfield suite, no strong language constraint, a team that could read either: TypeScript. The runner is a large part of what you are buying, and every tutorial, talk and answer you find will assume you have it. Starting there costs you nothing you already own.
The team writes Python all day and the backend is Python: Python. Treat the inventory above as a price list, pay it knowingly, and put the sharding question on the roadmap before the suite is long enough to need an answer. You are buying fluency and a set of fixtures that already exist, and both are worth more than a debugging UI.
Sharding or visual comparison is a written requirement: TypeScript, or Python with a named plan for getting those another way. This is the one case where the second rule does not survive contact, because neither gap closes with practice.
If your situation is the second rule and somebody on the team is arguing for a rewrite, the next question answers that one.
Questions
Does Playwright support Python?
Yes, officially. Python is one of four bindings Microsoft maintains, alongside TypeScript and JavaScript, Java and .NET, and they share the same underlying implementation. On 1 September 2026 the release notes at the JavaScript, Python and Java doors all read Version 1.62, though the registries that day differed at the patch level: npm listed playwright at 1.62.1 and PyPI at 1.62.0, with no 1.62.1 for Python. Install it with pip install pytest-playwright.
Is the Playwright test runner available in Python?
No. The runner is JavaScript and TypeScript only, and Python uses the Playwright pytest plugin instead. That costs you UI mode, projects, the retries model, sharding with merged reports, visual screenshot comparison and the built-in HTML reporter. It does not cost you the browser API, the trace viewer, codegen, the Inspector, network mocking, ARIA snapshots or parallel execution, all of which work in Python.
Can I use UI mode with Python?
No. UI mode belongs to the Node runner and starts with npx playwright test --ui; pytest rejects the flag outright. Python users reach for the Playwright Inspector while a test runs and for the trace viewer afterwards. Tracing is a plugin flag, pytest --tracing on, and the resulting trace.zip opens in the same viewer through playwright show-trace, which is a command in the Python CLI.
Should we rewrite our Python Playwright tests in TypeScript?
Usually no. A rewrite trades a working suite for a feature list, and the team maintaining it afterwards is the same team that could not read it. The answer changes on two conditions: the suite is long enough that sharding it across machines has become a release constraint, or visual regression is the thing you are buying end-to-end tests to catch. Neither improves with practice, so if either is written down, weigh the rewrite seriously.
What does your team write, and what has to be true of the suite?
Two answers decide this and neither takes long to send: the language the people who will maintain the tests write every day, and anything the suite has to do that is already written down. A wall-time budget for CI, visual regressions you have been burned by, a pipeline that has to stay under a certain cost. Those turn the table above into a recommendation that fits your situation.