Browser Automation with Playwright

· 12 min read · Updated March 21, 2026 · intermediate
playwright browser-automation python testing web-scraping

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.

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. Install the package with pip:

pip install playwright

The package gives you the Python API, but you still need the browser programs. Run the installer to download Chromium, Firefox, and WebKit:

playwright install

This downloads several hundred megabytes of browser code and may take a few minutes. If you only need one browser, install just that one:

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: PlaywrightBrowserBrowserContextPage. 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.

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 any time you want to see what the page looks like:

page.screenshot(path="full_page.png")              # The entire page
page.screenshot(path="viewport.png", full_page=False)  # Just the visible area

Locators: Selecting Elements

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.

If a locator matches multiple elements, filter or pick specific ones:

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

Interacting with Elements

Click a button or any clickable element:

page.get_by_role("button", name="Submit").click()

Type into a text field with fill(), which replaces any existing text:

page.get_by_label("Full name").fill("Alice Smith")
page.get_by_label("Email").fill("alice@example.com")

Checkboxes and radio buttons use check() or uncheck():

page.locator("#subscribe-checkbox").check()
page.locator("#subscribe-checkbox").uncheck()

Dropdown menus (the <select> element) use select_option():

# 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")

Here is a complete example that fills out a form and submits it:

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

To upload a file via <input type="file">, use set_input_files():

page.locator("input[type='file']").set_input_files("report.pdf")

To clear a file selection, pass an empty list:

page.locator("input[type='file']").set_input_files([])

For file choosers triggered by clicking something other than the input directly, set up a handler before the click:

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

When a click triggers a download, use expect_download() and download.save_as():

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}")

Running JavaScript in the Browser

page.evaluate() runs JavaScript in the browser’s context and returns the result to Python. Any variables must be defined inside the JavaScript string:

# 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.

Waiting Without time.sleep()

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()

Do not combine expect() with manual waits — 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. Set it per-call or globally:

# 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

When a link opens in a new tab, set up a listener before you click:

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 are accessed using a frame locator:

frame = page.frame_locator("#my-iframe")
frame.get_by_role("button", name="Confirm").click()

Network Interception and Mocking

You can intercept network requests and replace responses without ever touching a server. This is useful for testing against fake backends or simulating API errors.

Use page.route() to intercept requests matching a URL pattern:

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

To let a request pass through unmodified, call route.continue_():

page.route("**/analytics/**", lambda route: route.continue_())

To abort a request (for example, blocking an image or tracking script):

page.route("**/*.{png,jpg,gif}", lambda route: route.abort())

Capturing Console Output

To read console.log() messages from the page’s JavaScript, register a handler before the page runs:

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.

context1 = browser.new_context()
context2 = browser.new_context()

page1 = context1.new_page()
page2 = context2.new_page()

Save and reuse login state so you do not need to log in on every run:

# 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)

Work with cookies directly:

# 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

You can emulate mobile devices by passing a device descriptor when creating a context:

# 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.

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 — same methods, 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

Here are two basic tests:

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")

Run the tests:

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.

pytest-playwright also provides a request fixture for making HTTP assertions within your tests:

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

Common Pitfalls

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