The Testing Academy · Playwright Practice

XPath Master Cheat Sheet:
Functions, Axes, Operators, Examples

Everything XPath can do for an automation tester on one page: the anatomy, every function you will actually use, all 13 axes with a family-tree diagram, operators, the indexing gotchas, dynamic-element patterns, and table strategies, with copy-paste examples for Selenium and Playwright.

20+ functions All 13 axes + diagram Dynamic element patterns Table strategies Selenium + Playwright
On this page
  1. Anatomy of an XPath
  2. Quick reference: the basics
  3. Text matching
  4. All the functions
  5. All 13 axes + diagram
  6. Operators
  7. Indexing and its gotcha
  8. Dynamic element patterns
  9. Table strategies
  10. Selenium and Playwright usage
  11. XPath vs CSS
  12. FAQ

1. Anatomy of an XPath

//tag[@attribute='value'][position()]/axis::tag
│  │   │                  │          │
│  │   │                  │          │── walk to related nodes (parent::, following-sibling::)
│  │   │                  │── pick one from the matched set ([1], [last()])
│  │   │── predicate: the condition in [ ] (@id='x', text()='y', contains(...))
│  │── the element tag, or * for any
│── // = search at ANY depth   / = direct child only

2. Quick reference: the basics

ExpressionMeaningExample
//tagEvery tag, any depth//button
//*Any element//*[@data-testid='save']
//tag[@attr='v']Attribute equals//input[@name='email']
//tag[@attr]Attribute exists//img[@alt]
//tag[@a='x' and @b='y']Multiple conditions//input[@type='text' and @required]
//A//BB anywhere under A//form//button
//A/BB direct child of A//ul/li
//A | //BUnion of two sets//input | //textarea
. and ..Current node, parent//td[.='Total']/.. the row of that cell

3. Text matching

ExpressionMatchesWatch out
//td[text()='Delivered']Exact own-text matchFails on stray whitespace or nested spans
//td[contains(text(),'Deliv')]Substring of own textAlso matches "Not Delivered": exactness is your job
//td[.='Delivered']Full string value incl. childrenUse when the text sits inside a nested span
//td[normalize-space()='Delivered']Trimmed, whitespace-collapsedThe safe default for exact text
//td[contains(normalize-space(.),'Delivered')]Trimmed substring, incl. childrenThe safe default for partial text
Rule of thumb. text() reads only the node's own text; . reads everything inside it. A cell rendered as <td><span>Delivered</span></td> has EMPTY text(): that is the number one "my XPath works in theory" bug. When in doubt, use normalize-space(.).

4. All the functions you will actually use

FunctionWhat it doesExample
contains(a, b)Substring test//div[contains(@class,'error')]
starts-with(a, b)Prefix test, great for dynamic ids//input[starts-with(@id,'user_')]
text()The node's own text//a[text()='Login']
normalize-space(s)Trim + collapse whitespace//h2[normalize-space()='My Orders']
translate(s, from, to)Character mapping: the case-insensitive trick//a[contains(translate(text(),'ABCDEFGHIJKLMNOPQRSTUVWXYZ','abcdefghijklmnopqrstuvwxyz'),'login')]
substring(s, start, len)Slice a string (1-based)substring(@id, 1, 4)
substring-before(s, d) / substring-after(s, d)Split around a delimitersubstring-after(@href, '=') a query value
string-length(s)Length//td[string-length(text()) > 0] non-empty cells
concat(a, b, ...)Join strings; the quote-escape trick//td[text()=concat("It", "'", "s done")]
count(nodes)How many matches//tr[count(td)=5] rows with exactly 5 cells
position()Index within the current set//tr[position() <= 3] first three rows
last()The final index//tr[last()], //tr[last()-1]
not(expr)Negation//tr[not(th)] data rows only
name() / local-name()The tag name (local-name ignores namespaces: SVG!)//*[local-name()='svg']//*[local-name()='path']
number(s) / boolean(x)Type casts for comparisons//td[number(text()) > 100]
Browser XPath is version 1.0. ends-with(), matches(), lower-case() are XPath 2.0+: they do NOT work in Selenium or browsers. Emulate ends-with: //input[substring(@id, string-length(@id) - 5) = '_email'], and lower-case with the translate() trick above.

5. All 13 axes, with the family tree

Axes are XPath's superpower over CSS: from any node you can walk in every direction. Read them as axis::node, "from here, go that way".

ancestor parent self preceding-sibling following-sibling preceding following everything BEFORE self everything AFTER self child descendant attribute (@) namespace on the node itself, not in the tree ancestor-or-self / descendant-or-self = include self 13 total: self, parent, child, ancestor(-or-self), descendant(-or-self), preceding(-sibling), following(-sibling), attribute, namespace
Stand on any node and you can walk the whole tree. CSS only ever walks down.
AxisSelectsReal example
self::The node itself//td[self::td] (mostly used in checks)
parent::The one direct parent (same as ..)//input[@id='q']/parent::div
child::Direct children (the default axis)//tr/child::td = //tr/td
ancestor::All parents up to the root//span[text()='Anita']/ancestor::tr the row containing a deep cell
ancestor-or-self::Ancestors + the node//td/ancestor-or-self::*[@data-row]
descendant::Everything inside, any depth//tr[descendant::button[@title='Delete']]
descendant-or-self::Inside + the node (what // expands to)//table/descendant-or-self::td
following-sibling::Later siblings, same parent//label[text()='Email']/following-sibling::input
preceding-sibling::Earlier siblings, same parent//input[@id='q']/preceding-sibling::label
following::Every node after self in the document//h2[text()='Orders']/following::table[1] the first table after a heading
preceding::Every node before self//table/preceding::h2[1] the nearest heading above
attribute::Attributes (same as @)//input/attribute::name = //input/@name
namespace::Namespace nodes (XML; rare on the web)You will likely never write this one
The two that pay rent: ancestor:: to climb from a unique deep anchor (a cell's text) up to its row, and following-sibling:: to hop from a label to its field. Master those two and 90% of "impossible" locators fall.

6. Operators

OperatorMeaningExample
and / orCombine predicates//input[@type='text' or @type='email']
not()Negate (a function, not a keyword)//tr[not(contains(@class,'header'))]
= / !=Equality//td[@colspan!='2']
> >= < <=Numeric comparison//tr[position() > 1] skip the header
+ - * div modArithmetic (div, not /)//tr[position() mod 2 = 0] even rows
|Union of node sets//button[@type='submit'] | //input[@type='submit']

7. Indexing, and the gotcha that breaks everyone once

// XPath counts from 1, not 0
//table//tr[1]                 // first row
//table//tr[last()]            // last row
//table//tr[last()-1]          // second to last
//table//tr[position()<=3]     // first three

// THE GOTCHA: [n] binds to the nearest step, not the whole expression
//div/a[2]      // the 2nd link INSIDE EACH div (can match many!)
(//div/a)[2]    // the 2nd link of ALL matched links (exactly one)
If an index "randomly" matches multiple elements, you hit the binding gotcha: wrap the whole expression in parentheses before indexing. (//a[contains(@href,'download')])[3] is the third such link on the page, full stop.

8. Dynamic element patterns

ProblemPattern
Id changes per build: input-8347-email//input[starts-with(@id,'input-') and contains(@id,'-email')]
Class soup from frameworksAnchor on stable attrs: //*[@data-testid='row-actions']
Text with an apostrophe//td[text()=concat("O", "'", "Brien")]
Case differs across environmentstranslate() lower-case trick from the functions table
SVG icons (namespaced)//*[local-name()='svg' and @data-icon='trash']
Element identified only by its neighbor//label[normalize-space()='Email']/following-sibling::input[1]
Nth card with some property(//div[contains(@class,'card')][.//span[text()='Active']])[2]

9. Table strategies, end to end

Same demo table as the CSS sheet, so you can compare the two languages line by line:

OrderCustomerStatusAction
#1001PramodDelivered
#1002AnitaPending
#1003RahulDelivered
// The row containing Anita (cell text anchors the row)
//tr[td[normalize-space()='Anita']]

// Anita's STATUS (3rd cell of that row)
//tr[td[normalize-space()='Anita']]/td[3]

// Click Delete on Anita's row
//tr[td[normalize-space()='Anita']]//button

// Same, but climbing UP from the anchor instead (ancestor axis)
//td[normalize-space()='Anita']/ancestor::tr//button

// Column by HEADER NAME, not position: Status cell of Anita's row
//tr[td[.='Anita']]/td[count(//th[normalize-space()='Status']/preceding-sibling::th)+1]

// Second Delivered row (parentheses first, then index)
(//tr[td[normalize-space()='Delivered']])[2]

// All data rows, skipping the header row
//tr[not(th)]
The header-name trick explained. count(//th[.='Status']/preceding-sibling::th)+1 computes the column NUMBER of the Status header (count of headers before it, plus one), then td[that number] picks the same column in the data row. Reorder the columns and the locator still works: that is the payoff over td[3].

10. Using XPath in Selenium and Playwright

# Selenium (Python)
driver.find_element(By.XPATH, "//tr[td[normalize-space()='Anita']]//button").click()

// Selenium (Java)
driver.findElement(By.xpath("//label[text()='Email']/following-sibling::input")).sendKeys("[email protected]");

// Playwright (TypeScript) - xpath= prefix, or // is auto-detected
await page.locator("xpath=//tr[td[normalize-space()='Anita']]/td[3]").innerText();
await page.locator("//h2[text()='Orders']/following::table[1]").screenshot();

// DevTools console - test any XPath live
$x("//tr[td[normalize-space()='Anita']]")
Test before you commit. $x("...") in the browser console evaluates any XPath instantly. In Playwright, prefer user-facing locators (getByRole, getByText) where they work; reach for XPath when you need axes: climbing to ancestors or hopping siblings.

11. XPath vs CSS: when to use which

NeedCSSXPath
Id / class / attributeWinner: shorter, faster to readWorks, wordier
Text matching (portable)Not in standard CSSWinner: text(), contains(), normalize-space()
Walk UP the treeOnly :has()Winner: parent::, ancestor::
Sibling hops+ and ~ (forward only)Winner: both directions, with conditions
Column by header nameNot expressibleWinner: the count(th/preceding-sibling) trick
Playwright text search:has-text() etc. (Playwright-only)Portable everywhere

12. FAQ

What is the difference between / and // ?

/ steps to a direct child (from the root when leading); // searches any depth. //input finds every input on the page; /html/body/input only a direct child of body.

Does XPath start at 0 or 1?

1. //tr[1] is the first row. And remember the binding gotcha: (//a)[2] vs //div/a[2] are different questions.

How do I select by text?

//tag[normalize-space()='Exact'] for exact, //tag[contains(normalize-space(.),'part')] for partial. Avoid bare text()= on real apps: whitespace and nested spans break it.

Which axes should I learn first?

ancestor:: and following-sibling::. They solve the two everyday problems: climb from a unique cell to its row, and hop from a label to its input.

Drill these on real markup in the tables practice page and the Playwright practice library, or let SnapLocator generate and heal locators for you.

Pair it with the CSS Selector Cheat Sheet →