Browser Automation with Playwright
What is Playwright?
When you browse the web, your browser fetches pages, renders content, runs JavaScript, and responds to your clicks and keystrokes. Normally a person controls it: you click a link, type into a search box, scroll down. But what if you wanted a script to do those things automatically?
Browser automation is writing code that drives a browser the same way a human would. You write instructions like “go to this URL”, “find this button and click it”, or “fill this text field with this value”. The browser follows your instructions without anyone sitting in front of it.
Playwright, created by Microsoft, brings browser automation to Python. It supports Chromium (the engine behind Google Chrome), Firefox, and WebKit (the engine behind Safari), all through a single Python API. You do not need separate code for each browser. See the official documentation at playwright.dev/python for the full API reference.
The standout feature that makes Playwright pleasant to work with is auto-waiting. When you tell Playwright to click a button, it waits for that button to appear and be ready before clicking. Older automation tools would try to click immediately, even if the page was still loading, leading to flaky failures. Playwright handles that timing for you.
Installation
Before you write any code, you need the Python package and the browser binaries that Playwright controls.
Check your Python version first:
python3 --version
# output: Python 3.8.0 (or higher)
Playwright requires Python 3.8 or newer. We recommend installing it inside a virtual environment to keep dependencies isolated from your system Python. The playwright package includes the synchronous and asynchronous APIs, built-in assertions, and the auto-waiting engine. Install the package with pip:
pip install playwright
The pip package provides the Python bindings, but Playwright also needs the actual browser binaries it will control. Unlike Selenium, which requires you to download a separate driver executable for each browser, Playwright bundles its own browser builds that are guaranteed to be compatible with the API version you installed. Run the installer to download Chromium, Firefox, and WebKit:
playwright install
This downloads several hundred megabytes of browser binaries and may take a few minutes on a slow connection. The browsers are stored in a cache directory specific to your operating system, so they persist across package upgrades. On CI servers where disk space is tight, or when your automation only targets a single browser engine, install just the one you need:
playwright install chromium # Google Chrome / Chromium only
playwright install firefox # Firefox only
playwright install webkit # Safari (WebKit) only
Your first script: launching a browser
The first thing any Playwright script does is launch a browser. Think of this like opening a new browser window. By default it runs in headless mode, which means there is no visible window. Headless mode is useful for scripts that run on servers or in continuous integration pipelines.
You start by importing sync_playwright and using it as a context manager, which handles cleanup automatically:
from playwright.sync_api import sync_playwright
with sync_playwright() as p:
browser = p.chromium.launch(headless=True)
# A "context" is like an incognito window, isolated from other sessions
context = browser.new_context(
viewport={"width": 1280, "height": 720},
locale="en-US",
)
# A "page" is a single tab within that context
page = context.new_page()
page.goto("https://example.com", wait_until="domcontentloaded")
print(page.title())
# output: Example Domain
page.screenshot(path="screenshot.png")
context.close()
browser.close()
The object hierarchy is: Playwright → Browser → BrowserContext → Page. Most of your code interacts with the Page object.
Headless vs headed mode
By default, Playwright runs headlessly. No browser window appears. This is ideal for servers and CI pipelines. When you are writing or debugging a script and want to see the browser, run headed mode:
browser = p.chromium.launch(headless=False) # Shows the browser window
In pytest, use the --headed flag to see the browser while tests run, or --headed --slowmo 100 to slow each action by 100ms so you can follow along step by step. The slow-motion mode is particularly helpful when debugging a test that passes 90% of the time but flakes occasionally, since watching the browser at full speed rarely reveals the timing issue.
Navigating and reading page content
The page.goto() method fetches a page and waits for it to reach a certain state. The wait_until parameter controls that state:
"load": wait for the full page including all images and scripts (the default)"domcontentloaded": wait for the HTML to be parsed, but not necessarily all images"networkidle": wait until there are no more network requests for at least 500ms
For fast, reliable pages, "domcontentloaded" is usually sufficient. For pages that load data dynamically, "networkidle" gives you more confidence the page is fully ready.
Once on a page, the Page object gives you information about its state:
page.goto("https://example.com", wait_until="domcontentloaded")
title = page.title() # Text in the <title> tag
current_url = page.url # The current URL
html = page.content() # Full HTML source
print(f"Title: {title}")
print(f"URL: {current_url}")
# output: Title: Example Domain
# output: URL: https://example.com/
Take screenshots whenever you need a visual record of what the page looks like at a particular moment. Screenshots are especially valuable for debugging layout issues, capturing UI state in CI test reports, or documenting visual regressions. Playwright can capture the full scrollable page or just the visible viewport:
page.screenshot(path="full_page.png") # The entire page
page.screenshot(path="viewport.png", full_page=False) # Just the visible area
How do I find elements on a page?
Almost every automation task involves finding an element on the page. Playwright calls these locators, and page.locator() is the main way to create them.
A locator is not the element itself. It is a description of how to find it. When you call an action like .click() on a locator, Playwright searches for a matching element that is ready and auto-waits for it.
The most flexible approach is a CSS or XPath selector:
# CSS selector — finds the element with class "submit-button"
page.locator(".submit-button").click()
# XPath selector — finds a button containing the text "Submit"
page.locator("xpath=//button[contains(text(), 'Submit')]").click()
XPath’s text() returns the first direct text node child, so it can miss buttons with mixed content like <button><span>Submit</span></button>. For those cases, the semantic locators below are more reliable.
Playwright also provides semantic locators that target elements by what they mean, not how they are implemented:
# Find a button by its accessible name
page.get_by_role("button", name="Submit").click()
# Find an element by the text inside it
page.get_by_text("Sign in").click()
# Find a form field by its associated label text
page.get_by_label("Email address").fill("alice@example.com")
# Find an input by its placeholder text
page.get_by_placeholder("Search...").fill("playwright")
# Find an element by its data-testid attribute
page.get_by_test_id("submit-btn").click()
Semantic locators are preferred. They are more readable and less likely to break when a developer renames a CSS class or restructures the DOM. A locator can match multiple elements on the page. When that happens, you need to narrow down to the specific one you want. Playwright provides filtering and positional methods so you can pick the right match without writing fragile nth-child selectors:
links = page.get_by_role("link")
# has_text searches the entire subtree, not just direct text
filtered = links.filter(has_text="Example")
print(filtered.count()) # How many matching links exist
first_link = filtered.first
last_link = filtered.last
How do I interact with elements?
Playwright provides separate methods for each type of interaction so your code clearly expresses what you are doing. The most common action on any page is clicking a button, a link, or another interactive element. The click() method handles all of these the same way, dispatching a proper sequence of mouse events so that JavaScript event listeners fire correctly:
page.get_by_role("button", name="Submit").click()
Filling text fields is equally common in form automation. The fill() method clears whatever is currently in the field and types the new value, which is faster and more reliable than simulating keystrokes one at a time with type():
page.get_by_label("Full name").fill("Alice Smith")
page.get_by_label("Email").fill("alice@example.com")
Checkboxes and radio buttons have two distinct states, so Playwright provides check() and uncheck() instead of a generic click. Using the dedicated methods makes your intent explicit and avoids the subtle bug where clicking an already-checked checkbox toggles it off when you meant to ensure it was on:
page.locator("#subscribe-checkbox").check()
page.locator("#subscribe-checkbox").uncheck()
Native <select> dropdowns are handled with select_option(), which accepts either the value attribute of the <option> element or the human-readable label text. This distinction matters when the visible text differs from the internal value, such as country codes versus country names:
# Select by the value attribute of the option
page.locator("select[name='country']").select_option(value="us")
# Or select by the visible text
page.locator("select[name='country']").select_option(label="United States")
The example below ties together every interaction method covered so far. It navigates to httpbin’s form testing endpoint, populates text fields, selects a dropdown option, checks a checkbox, and clicks the submit button. The final expect() call verifies that the server responded with a success confirmation:
from playwright.sync_api import sync_playwright, expect
with sync_playwright() as p:
browser = p.chromium.launch(headless=True)
context = browser.new_context()
page = context.new_page()
page.goto("https://httpbin.org/forms/post")
page.get_by_label("Your name").fill("Alice Smith")
page.get_by_label("Your email").fill("alice@example.com")
page.locator("select[name='status']").select_option(value="married")
page.locator("#contact").check()
page.get_by_role("button", name="Submit").click()
expect(page.locator(".status")).to_contain_text("Success", timeout=5000)
context.close()
browser.close()
File uploads
File inputs are one of the few elements that cannot be interacted with through click or fill. Browsers restrict programmatic access to the file picker dialog for security reasons, so Playwright bypasses it entirely: you set the file path directly on the hidden <input type="file"> element using set_input_files(). The path must be an absolute path or relative to the current working directory:
page.locator("input[type='file']").set_input_files("report.pdf")
Clearing a file selection is just as straightforward as setting one. Pass an empty list to set_input_files() and Playwright resets the input element to its initial empty state. This is useful when testing form validation that should fire after a user removes a previously selected file, or when you need to test the “no file chosen” UI state between upload attempts:
page.locator("input[type='file']").set_input_files([])
Some web applications hide the actual file input behind a styled button. When a click on that custom button triggers the native file chooser dialog, Playwright cannot use set_input_files() directly on the file input because the dialog opens asynchronously. Instead, register an expectation for the file chooser before clicking the trigger element, then fulfill it programmatically:
with page.expect_file_chooser() as fc_info:
page.get_by_role("button", name="Attach file").click()
file_chooser = fc_info.value
file_chooser.set_files("document.pdf")
File downloads
Downloads follow the same expectation pattern as file choosers. Before clicking the element that triggers the download, register a download handler. Playwright captures the download stream and lets you save it to any location on disk, which is particularly useful in CI environments where you need to verify the contents of a generated file without manual intervention:
with page.expect_download() as download_info:
page.get_by_role("button", name="Download PDF").click()
download = download_info.value
download.save_as("/path/to/save/report.pdf")
print(f"Downloaded: {download.suggested_filename}")
How do I run JavaScript from Python?
page.evaluate() executes arbitrary JavaScript inside the browser’s context and returns the result to Python. This is one of Playwright’s most powerful escape hatches: when the high-level API does not cover a DOM operation you need, you can drop down to raw JavaScript. Any variables the JavaScript references must be defined inside the string you pass, since the code runs in the browser’s isolated scope, not in your Python process. You can also pass Python values as a second argument and receive them as function parameters:
# Read the page title via JavaScript
title = page.evaluate("document.title")
print(title)
# output: Example Domain
# Read computed styles, trigger events, or extract complex DOM data
element_text = page.evaluate(
"document.querySelector('#main h1').textContent"
)
# Pass Python values into JavaScript using the second argument
page.evaluate(
"([x, y]) => x + y",
[3, 4]
)
# output: 7
page.evaluate() is useful for extracting data that is only accessible via JavaScript, reading computed styles, triggering custom events, or calling DOM APIs that have no Playwright equivalent.
How do I wait for elements without blocking?
One of the most common mistakes is reaching for time.sleep() when you need to pause. Do not do this. It blocks the entire Python process and has no awareness of the page state. In async scripts, it freezes the event loop.
Instead, wait for something specific to happen on the page:
# Wait up to 10 seconds for an element to appear
page.wait_for_selector("#confirmation-message", state="visible", timeout=10000)
# Wait for the page to finish loading resources
page.wait_for_load_state("networkidle")
# Pause briefly — only for debugging, never in production
page.wait_for_timeout(2000)
For debugging interactively, page.pause() enters the Playwright debugger (or use --ui-mode when launching the browser) so you can step through locators and inspect the page state.
Assertions with expect()
Playwright’s expect() function checks that something is true and raises an error if it is not. The key feature is that expect() auto-retries: it keeps checking until the condition passes or the timeout expires. This means you do not need to manually wait before asserting:
from playwright.sync_api import expect
# Assert that the page title matches a pattern
expect(page).to_have_title(re.compile("Example"))
# Assert that the URL matches a pattern
expect(page).to_have_url("https://example.com")
# Assert that an element is visible
expect(page.locator("#content")).to_be_visible()
# Assert that an element contains specific text
expect(page.locator(".status")).to_contain_text("Success")
# Assert that a checkbox is checked
expect(page.locator("#agree")).to_be_checked()
# Negate any assertion with .not_
expect(page.locator(".error")).not_to_be_visible()
The retry behaviour is the reason raw assert statements are unreliable in browser tests. A plain assert page.locator(".status").text_content() == "Success" runs once, immediately, and fails if the element has not loaded yet. Playwright’s expect() keeps polling the DOM until the assertion holds, eliminating the need for manual sleep calls before each check. Do not combine expect() with manual waits since the function already handles waiting for you:
# Bad — wait is redundant
page.wait_for_timeout(1000)
expect(locator).to_be_visible()
# Good — expect() retries on its own
expect(locator).to_be_visible(timeout=5000)
The default timeout for all Playwright assertions and actions is 30 seconds, which is generous enough for most pages but may be too long when you expect a quick failure. Adjust it per-call for individual operations or globally for the entire test suite:
# Per-call timeout
page.get_by_role("button", name="Submit").click(timeout=5000)
# Global timeout (affects all operations in this context)
context.set_default_timeout(10000)
Managing multiple pages and frames
Browser automation often requires handling popups, new tabs, and embedded iframes. Playwright treats each tab as a separate Page object, and the key rule is that you must register an event listener before the action that triggers the new tab. If you try to access the popup after the fact, Playwright has already discarded the event:
with sync_playwright() as p:
browser = p.chromium.launch(headless=True)
context = browser.new_context()
page = context.new_page()
with context.expect_page() as new_page_info:
page.get_by_role("link", name="View Wikipedia").click()
popup = new_page_info.value
popup.wait_for_load_state("domcontentloaded")
print(f"Popup title: {popup.title()}")
print(f"Popup URL: {popup.url}")
popup.close()
browser.close()
Iframes embedded within a page require a different approach. Since an iframe has its own document context, regular page-level locators cannot reach inside it. Playwright provides frame_locator() which creates a locator scoped to the iframe’s document. This means you use the same familiar locator API but target elements inside the embedded frame:
frame = page.frame_locator("#my-iframe")
frame.get_by_role("button", name="Confirm").click()
How do I intercept and mock network requests?
You can intercept network requests and replace responses without ever touching a real server. This is useful for testing against fake backends, simulating API errors, or blocking analytics and tracking scripts that would slow down your tests. Use page.route() with a URL glob pattern to intercept matching requests before they leave the browser:
context = browser.new_context()
page = context.new_page()
# Intercept all API calls and return fake data
page.route("**/api/**", lambda route: route.fulfill(
status=200,
content_type="application/json",
body='{"status": "ok", "user": "Alice"}'
))
page.goto("https://example.com/api/user")
print(page.locator("#status").text_content())
# output: ok
Not every intercepted request needs to be mocked. Sometimes you want to let most requests through while blocking or modifying only a subset. The route.continue_() method passes the request to the server unchanged, which is the pattern you use when you only want to intercept specific URL patterns:
page.route("**/analytics/**", lambda route: route.continue_())
The third option is to abort a request entirely, preventing the browser from even attempting to fetch the resource. This is faster than mocking because no response body is generated. It is commonly used to block images, fonts, or tracking scripts that are irrelevant to your test assertions:
page.route("**/*.{png,jpg,gif}", lambda route: route.abort())
Capturing console output
Browser console messages are invisible in headless mode but can reveal JavaScript errors, warnings, and application logs. To capture console.log() calls from the page, register an event handler on the Page object before navigating. The handler fires for every console message the page produces, including those emitted by third-party scripts:
console_messages = []
page.on("console", lambda msg: console_messages.append(msg.text))
page.goto("https://example.com")
page.evaluate("console.log('Hello from the browser')")
print(console_messages)
# output: ['Hello from the browser']
page.on("console") is useful for debugging, monitoring JavaScript errors in automated tests, or capturing application logs.
Handling dialogs
Browser dialogs (window.alert, window.confirm, window.prompt) are auto-dismissed by default in Playwright. To handle them explicitly, register a dialog handler before the action that triggers the dialog:
def handle_dialog(dialog):
print(f"Dialog type: {dialog.type}")
print(f"Dialog message: {dialog.message}")
dialog.accept() # Click OK / Accept
# dialog.dismiss() # Or click Cancel
page.on("dialog", handle_dialog)
page.goto("https://example.com")
page.evaluate("window.confirm('Are you sure?')")
Browser contexts and cookies
A browser context is an isolated session, like opening a new incognito window. Cookies, local storage, and session data are not shared between contexts, which means you can simulate multiple logged-in users in a single test by giving each one its own context:
context1 = browser.new_context()
context2 = browser.new_context()
page1 = context1.new_page()
page2 = context2.new_page()
Logging in before every test is slow and makes tests brittle when the authentication flow changes. Save the authenticated state after a single login and reuse it across tests. The storage state is a JSON-serializable snapshot of cookies and local storage that Playwright can restore into a fresh context:
# After logging in normally, save the state
storage_state = context.storage_state()
# Later, create a new context with the saved state
context2 = browser.new_context(storage_state=storage_state)
When you need fine-grained control over individual cookies rather than a full state snapshot, Playwright provides direct cookie manipulation methods. You can read all cookies set for the current context, add new ones manually (useful for injecting authentication tokens directly), or clear them to simulate a logout:
# Get all cookies
cookies = context.cookies()
# Add a cookie manually
context.add_cookies([{
"name": "session_id",
"value": "abc123",
"url": "https://example.com",
}])
# Clear all cookies
context.clear_cookies()
Device emulation
Testing how your site behaves on mobile devices does not require a physical phone or a cloud device lab. Playwright ships with built-in device descriptors for dozens of popular phones and tablets. Pass a device descriptor to new_context() and Playwright configures the viewport size, user agent string, device pixel ratio, and touch support automatically to match the real device:
# Emulate an iPhone 13
context = browser.new_context(
**playwright.devices["iPhone 13"]
)
Available properties include viewport, user_agent, device_scale_factor, is_mobile, and has_touch. This is useful for testing responsive designs and mobile-specific behaviour.
When should I use the async API?
Playwright comes in two flavours. The sync API blocks on each operation. The async API works with Python’s asyncio and lets you run multiple browser operations concurrently.
The async API is identical to the sync API, with the same methods and the same parameters, but everything is a coroutine you must await:
import asyncio
from playwright.async_api import async_playwright
async def main():
async with async_playwright() as p:
browser = await p.chromium.launch()
# Each new_context() call is sync — no await needed
context1 = browser.new_context()
context2 = browser.new_context()
page1 = await context1.new_page()
page2 = await context2.new_page()
# Navigate both pages concurrently
await asyncio.gather(
page1.goto("https://example.com"),
page2.goto("https://httpbin.org/get"),
)
print(f"Page 1 title: {await page1.title()}")
print(f"Page 2 URL: {page2.url}")
await context1.close()
await context2.close()
await browser.close()
asyncio.run(main())
Use the async API when you are building a web server, managing many concurrent browser sessions, or need non-blocking behaviour. For one-off scripts and test files, the sync API is simpler.
Using Playwright with pytest
The pytest-playwright plugin integrates Playwright directly into pytest. It provides a page fixture that your test functions receive automatically, and handles browser lifecycle per test.
Install it:
pip install pytest-playwright
The page fixture gives each test a fresh browser context with a single page, so tests are isolated from each other. You can type-hint the fixture with Page from playwright.sync_api for IDE autocompletion. The expect function is also available for assertions that automatically retry until the condition is met. Here are two tests that demonstrate the basic pattern:
import re
from playwright.sync_api import Page, expect
def test_homepage_title(page: Page):
"""The homepage should have the correct title."""
page.goto("https://example.com")
expect(page).to_have_title(re.compile("Example"))
def test_form_submission(page: Page):
"""Submitting the form should show a success message."""
page.goto("https://httpbin.org/forms/post")
page.get_by_label("Your name").fill("Bob Jones")
page.get_by_role("button", name="Submit").click()
expect(page.locator(".status")).to_contain_text("Success")
Pytest-playwright adds several command-line flags that control how the browser runs. By default tests execute headlessly, but you can override this for debugging sessions. The slow-motion flag inserts a pause between each action so you can watch the browser step through the test visually:
pytest # Run all tests headlessly
pytest --headed # Show the browser while tests run
pytest --slowmo 100 # Slow actions by 100ms
pytest --tracing retain-on-failure # Record a replay on failure
--tracing retain-on-failure is particularly useful. When a test fails, you get a full recording you can replay in the Playwright Trace Viewer, which shows a timeline of every action, network request, and DOM snapshot leading up to the failure. Beyond browser interactions, pytest-playwright also provides a request fixture for making direct HTTP assertions within your tests, which is handy for verifying API responses without loading a page:
def test_api_response(page: Page, request):
"""The page should display data from the API."""
response = request.get("https://httpbin.org/json")
assert response.status_code == 200
What mistakes do new users make?
A few things trip up new Playwright users.
Do not use time.sleep() in production code. It blocks the event loop in async scripts and has no awareness of page state. Use page.wait_for_selector() or page.wait_for_load_state() instead.
Playwright is not thread-safe. Each thread needs its own Playwright instance and browser context.
force=True lets Playwright interact with an element regardless of whether it is visible or enabled. Use it only when genuinely needed. For example, clicking a button that is temporarily covered by an overlay:
page.locator("#hidden-button").click(force=True)
See Also
- Web Scraping with Python: extends browser automation to extract data from complex pages
- Testing with pytest: pytest patterns that pair well with browser automation
- The httpx Guide: making HTTP requests without a browser, useful for API-only workflows
- Playwright for Python: official API reference and getting-started guide