XPath is a query language used to locate elements in an HTML or XML document. It allows automation engineers to find elements using relationships, attributes, text, hierarchy and position.
Attribute Based
Locate an element using attributes such as @id, @name or @data-testid.
Text Based
Locate elements based on their visible text.
Relationship Based
Navigate from parent to child, sibling or another related element.
Playwright can use XPath through the standard locator() API.
page.locator("//button").click()
page.locator("//input[@id='email']").fill(
"john@example.com"
)
page.locator("//input[@name='password']").fill(
"Password123"
)
Attribute XPath is useful when the application provides stable attributes.
# ID
page.locator("//input[@id='username']").fill("john")
# Name
page.locator("//input[@name='email']").fill(
"john@example.com"
)
# Class
page.locator("//button[@class='login-button']").click()
# Data attribute
page.locator("//button[@data-testid='login']").click()
# Multiple attributes
page.locator(
"//input[@type='email' and @name='email']"
).fill("john@example.com")
XPath can locate elements using their text content.
# Exact text
page.locator(
"//button[text()='Login']"
).click()
# Text contained inside an element
page.locator(
"//button[contains(text(),'Login')]"
).click()
# Link text
page.locator(
"//a[text()='Orders']"
).click()
# Heading text
page.locator(
"//h1[text()='Customer Dashboard']"
).is_visible()
Imagine a banking application with this HTML:
<div class="login-panel">
<input id="customerId"
name="customerId"
type="text">
<input id="password"
name="password"
type="password">
<button class="login-btn">
Login
</button>
</div>
A Playwright test can use XPath like this:
from playwright.sync_api import expect
def test_banking_login(page):
page.goto("https://example.test/banking/login")
page.locator(
"//input[@id='customerId']"
).fill("CUST10001")
page.locator(
"//input[@id='password']"
).fill("Secret123")
page.locator(
"//button[contains(normalize-space(), 'Login')]"
).click()
expect(page).to_have_url(
r".*/dashboard"
)
One of XPath's biggest strengths is navigating relationships between elements.
Suppose there are 50 customer cards and every card has an Edit button. We want to click Edit only for John Smith.
page.locator(
"//div[contains(@class,'customer-card')]"
"[.//span[normalize-space()='John Smith']]"
"//button[normalize-space()='Edit']"
).click()
Imagine an e-commerce application containing many products.
<div class="product-card">
<h3>Laptop Pro 15</h3>
<span class="price">$1299</span>
<button>Add to Cart</button>
</div>
We want to add only Laptop Pro 15 to the cart.
page.locator(
"//div[contains(@class,'product-card')]"
"[.//h3[normalize-space()='Laptop Pro 15']]"
"//button[normalize-space()='Add to Cart']"
).click()
XPath can move from a known child element back to its parent.
# Find the input and move to its parent
page.locator(
"//input[@name='email']/parent::*"
)
# Find customer name and move to customer card
page.locator(
"//span[normalize-space()='John Smith']/ancestor::div[contains(@class,'customer-card')]"
)
XPath can locate an element based on another element next to it.
<label>Email</label> <input type="email"> <label>Password</label> <input type="password">
Find the input following the Password label:
page.locator(
"//label[normalize-space()='Password']"
"/following-sibling::input[1]"
).fill("Password123")
Tables are a common place where relationship-based XPath is useful.
<tr>
<td>ORD-1001</td>
<td>John Smith</td>
<td>Paid</td>
<td>
<button>View</button>
</td>
</tr>
Find the row for order ORD-1001 and click View.
order_row = page.locator(
"//tr[.//td[normalize-space()='ORD-1001']]"
)
order_row.locator(
".//button[normalize-space()='View']"
).click()
Modern applications often generate dynamic IDs and classes.
<button id="submit-btn-983472">
Submit Order
</button>
<button id="submit-btn-239812">
Submit Order
</button>
If the changing part is not important, XPath can target the stable portion.
page.locator(
"//button[contains(@id,'submit-btn-')]"
).click()
Real applications may contain extra whitespace around text.
<button>
Save Customer
</button>
page.locator(
"//button[normalize-space()='Save Customer']"
).click()
XPath can select elements by position.
# First matching button
page.locator(
"(//button[contains(@class,'action')])[1]"
).click()
# Second matching button
page.locator(
"(//button[contains(@class,'action')])[2]"
).click()
Both Playwright and Selenium support XPath. The important difference is not that XPath itself becomes a different language. The difference is how XPath fits into the overall automation framework.
| Capability | Playwright | Selenium |
|---|---|---|
| XPath support | Yes | Yes |
| CSS support | Yes | Yes |
| Locator abstraction | Strong locator API through locator() | Uses WebElement-oriented APIs and locator strategies |
| Auto-waiting / actionability | Playwright actions wait for relevant actionability conditions | Selenium provides explicit and implicit wait mechanisms; synchronization is commonly designed separately |
| Web-first assertions | expect() supports retrying assertions | Assertions are commonly provided through the test framework |
| Locator composition | Supports filtering and chaining locators | Supports locating elements, but the API model differs |
| User-facing locators | Role, text, label and other locator APIs | Commonly implemented with CSS, XPath, ID, name, link text and other strategies |
Selenium
from selenium.webdriver.common.by import By
driver.find_element(
By.XPATH,
"//input[@id='customerId']"
).send_keys("CUST10001")
driver.find_element(
By.XPATH,
"//input[@id='password']"
).send_keys("Secret123")
driver.find_element(
By.XPATH,
"//button[normalize-space()='Login']"
).click()
Playwright
page.locator(
"//input[@id='customerId']"
).fill("CUST10001")
page.locator(
"//input[@id='password']"
).fill("Secret123")
page.locator(
"//button[normalize-space()='Login']"
).click()
Playwright lets you combine a broad locator with filtering and then perform another locator operation inside the matched region.
XPath Approach
page.locator(
"//div[contains(@class,'product-card')]"
"[.//h3[normalize-space()='Laptop Pro 15']]"
"//button[normalize-space()='Add to Cart']"
).click()
Playwright Locator Composition
product = page.locator(
".product-card"
).filter(
has_text="Laptop Pro 15"
)
product.get_by_role(
"button",
name="Add to Cart"
).click()
XPath is powerful, but it should not automatically be your first choice for every element.
Complex XPath
page.locator(
"//div[@class='header']"
"//nav//ul/li[2]"
"//a[contains(text(),'Orders')]"
).click()
Semantic Locator
page.get_by_role(
"link",
name="Orders"
).click()
Imagine an admin page containing hundreds of customers. Each customer has Edit, Disable and Delete buttons.
Find customer:
John Smith
Then:
Click Edit
Do NOT:
Click another customer's Edit button
XPath Solution
customer = page.locator(
"//div[contains(@class,'customer-card')]"
"[.//*[normalize-space()='John Smith']]"
)
customer.locator(
".//button[normalize-space()='Edit']"
).click()
Locator Composition Alternative
customer = page.locator(
".customer-card"
).filter(
has_text="John Smith"
)
customer.get_by_role(
"button",
name="Edit"
).click()
A major Playwright advantage is that the locator can be used directly with web-first assertions.
from playwright.sync_api import expect
success_message = page.locator(
"//div[contains(@class,'success-message')]"
)
expect(success_message).to_be_visible()
You can also assert text:
expect(
page.locator(
"//div[contains(@class,'success-message')]"
)
).to_contain_text("Order created")
XPath does not remove the need for synchronization. The automation framework still needs to wait for the application state you actually care about.
❌ Avoid
import time
page.locator(
"//button[normalize-space()='Submit']"
).click()
time.sleep(5)
assert page.locator(
"//div[@class='success']"
).is_visible()
✓ Preferred
from playwright.sync_api import expect
page.locator(
"//button[normalize-space()='Submit']"
).click()
expect(
page.locator(
"//div[contains(@class,'success')]"
)
).to_be_visible()
Consider an order management application.
from playwright.sync_api import expect
def test_approve_order(page):
page.goto(
"https://example.test/admin/orders"
)
order = page.locator(
"//tr[.//td[normalize-space()='ORD-1001']]"
)
order.locator(
".//button[normalize-space()='Approve']"
).click()
expect(
order.locator(
".//td[normalize-space()='Approved']"
)
).to_be_visible()
| XPath | Purpose | Example |
|---|---|---|
| text() | Match direct text | //button[text()='Save'] |
| contains() | Partial match | //button[contains(@id,'save')] |
| normalize-space() | Normalize whitespace | //button[normalize-space()='Save'] |
| parent:: | Move to parent | //input/parent::div |
| ancestor:: | Find an ancestor | //span/ancestor::div |
| following-sibling:: | Find following sibling | //label/following-sibling::input |
| and | Combine conditions | //input[@type='email' and @required] |
Complex Relationships
When the target is identified through a parent, ancestor or sibling relationship.
Dynamic Attributes
When only part of an attribute is stable and contains() is appropriate.
Legacy Applications
Older applications may expose limited semantic or test-specific attributes.
Complex Tables
Find a row using one cell and interact with another cell or control inside that row.
Nested Components
XPath can express relationships through deeply nested DOM structures.
Migration Projects
Existing Selenium XPath expressions can often be moved into Playwright's locator API with relatively small changes.
XPath is powerful, but more power does not automatically mean a better locator.
❌ Unnecessarily Complex
page.locator(
"//div[@class='page']"
"//div[@class='header']"
"//nav//ul/li[2]"
"//a[contains(text(),'Orders')]"
).click()
✓ Prefer Semantic Locator
page.get_by_role(
"link",
name="Orders"
).click()
The second locator communicates the user's intent more clearly and is less tied to the page's DOM hierarchy.
In Selenium, a common pattern is to combine XPath with an explicit wait.
from selenium.webdriver.common.by import By
from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver.support import expected_conditions as EC
login_button = WebDriverWait(
driver,
10
).until(
EC.element_to_be_clickable(
(
By.XPATH,
"//button[normalize-space()='Login']"
)
)
)
login_button.click()
Playwright
page.locator(
"//button[normalize-space()='Login']"
).click()
Here is a realistic customer-order workflow using XPath, assertions and business-level steps.
from playwright.sync_api import expect
def test_customer_order_approval(page):
# --------------------------------
# 1. Login
# --------------------------------
page.goto(
"https://example.test/admin/login"
)
page.locator(
"//input[@name='email']"
).fill("admin@example.com")
page.locator(
"//input[@name='password']"
).fill("AdminPassword123")
page.locator(
"//button[normalize-space()='Sign In']"
).click()
expect(page).to_have_url(
r".*/dashboard"
)
# --------------------------------
# 2. Open Customers
# --------------------------------
page.get_by_role(
"link",
name="Customers"
).click()
# --------------------------------
# 3. Find John Smith
# --------------------------------
customer = page.locator(
"//div[contains(@class,'customer-card')]"
"[.//*[normalize-space()='John Smith']]"
)
expect(customer).to_be_visible()
# --------------------------------
# 4. Open Orders
# --------------------------------
customer.locator(
".//a[normalize-space()='Orders']"
).click()
# --------------------------------
# 5. Find order
# --------------------------------
order = page.locator(
"//tr[.//td[normalize-space()='ORD-1001']]"
)
expect(order).to_be_visible()
# --------------------------------
# 6. Approve
# --------------------------------
order.locator(
".//button[normalize-space()='Approve']"
).click()
# --------------------------------
# 7. Validate
# --------------------------------
expect(
order.locator(
".//td[normalize-space()='Approved']"
)
).to_be_visible()
A professional Playwright project should not use XPath for everything. Choose the locator based on the application's stability and the meaning of the element.
| Situation | Preferred Approach | Why |
|---|---|---|
| User-facing button | get_by_role() | Expresses user intent |
| Form field with label | get_by_label() | Clear and semantic |
| Visible message | get_by_text() | Directly expresses visible text |
| Stable automation attribute | get_by_test_id() | Explicit automation contract |
| Complex DOM relationship | XPath | Strong relationship/navigation capability |
| Stable CSS structure | CSS locator | Simple structural targeting |
- Using absolute XPath such as /html/body/div[2]/div[1]
- Depending heavily on [1], [2] and other positions
- Using generated CSS classes that change on every build
- Creating very long XPath expressions when a role or label locator would be clearer
- Using contains() without checking whether multiple elements match
- Adding hard waits instead of waiting for the actual application state
- Testing only that an XPath exists instead of validating the expected business outcome
❌ Fragile
/html/body/div[2]/div[3]/div[1]/button[2]
Depends heavily on the exact DOM hierarchy and position.
✓ More Stable
//button[
normalize-space()='Submit Order'
]
Uses meaningful information exposed by the element.
XPath itself is not inherently more powerful in Playwright. Selenium also supports XPath very well.
The practical difference is the surrounding locator ecosystem.
1. Locator API
XPath fits into Playwright's unified locator() model.
2. Locator Composition
You can combine locators, filters and nested locators instead of putting every relationship into one XPath.
3. Actionability
Playwright actions perform relevant actionability checks before interacting with elements.
4. Web-First Assertions
Locator assertions can retry while the application reaches the expected state.
5. Semantic Locators
XPath can be combined conceptually with role, text, label and test-id based locator strategies.
6. Less Synchronization Code
Many common interactions require less explicit waiting code than a manually synchronized Selenium workflow.
Think of XPath as a powerful tool in your locator toolbox, not as the only locator strategy.
# Tag
page.locator("//button")
# ID
page.locator("//input[@id='email']")
# Attribute
page.locator("//input[@name='email']")
# Multiple attributes
page.locator(
"//input[@type='email' and @required]"
)
# Exact text
page.locator(
"//button[text()='Login']"
)
# Normalized text
page.locator(
"//button[normalize-space()='Login']"
)
# Partial text
page.locator(
"//button[contains(text(),'Login')]"
)
# Partial attribute
page.locator(
"//button[contains(@id,'submit')]"
)
# Parent
page.locator(
"//input[@name='email']/parent::*"
)
# Ancestor
page.locator(
"//span[normalize-space()='John']"
"/ancestor::div[contains(@class,'card')]"
)
# Following sibling
page.locator(
"//label[normalize-space()='Email']"
"/following-sibling::input[1]"
)
# Specific row
page.locator(
"//tr[.//td[normalize-space()='ORD-1001']]"
)
# Element inside specific row
order = page.locator(
"//tr[.//td[normalize-space()='ORD-1001']]"
)
order.locator(
".//button[normalize-space()='View']"
).click()
- Prefer stable attributes over generated attributes.
- Prefer relative XPath over absolute XPath.
- Use normalize-space() when whitespace is a concern.
- Use contains() carefully for genuinely stable partial matches.
- Use relationship XPath when the target is identified by another element.
- Avoid unnecessary positional XPath.
- Prefer semantic Playwright locators when they communicate user intent more clearly.
- Use Playwright web-first assertions instead of hard waits.
- Keep complex locator logic readable and maintainable.
- Validate the business result, not just the existence of an element.
1. Does Playwright support XPath?
Yes. XPath can be used through Playwright's locator() API.
2. Is XPath more powerful in Playwright than Selenium?
The XPath language itself is not more powerful in Playwright. Both support XPath. Playwright can provide a different overall automation experience through its locator model, actionability, locator composition and web-first assertions.
3. Should XPath be used for every Playwright locator?
No. Use the simplest stable locator that communicates the target clearly.
4. When is XPath especially useful?
Complex DOM relationships, table rows, parent/ancestor relationships, sibling relationships and certain dynamic attribute scenarios are common examples.
5. What should be avoided?
Avoid fragile absolute XPath, unnecessary indexes, unstable generated attributes and unnecessarily complicated expressions.
Use XPath when the relationship matters. Use semantic locators when the user's intent is clearer. Build locators for stability and readability.