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.
//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
/html/body/div[2]/form/input. One DOM change breaks them. Never ship one.// and anchor on something stable: //input[@name='email']. This is the default.| Expression | Meaning | Example |
|---|---|---|
//tag | Every 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//B | B anywhere under A | //form//button |
//A/B | B direct child of A | //ul/li |
//A | //B | Union of two sets | //input | //textarea |
. and .. | Current node, parent | //td[.='Total']/.. the row of that cell |
| Expression | Matches | Watch out |
|---|---|---|
//td[text()='Delivered'] | Exact own-text match | Fails on stray whitespace or nested spans |
//td[contains(text(),'Deliv')] | Substring of own text | Also matches "Not Delivered": exactness is your job |
//td[.='Delivered'] | Full string value incl. children | Use when the text sits inside a nested span |
//td[normalize-space()='Delivered'] | Trimmed, whitespace-collapsed | The safe default for exact text |
//td[contains(normalize-space(.),'Delivered')] | Trimmed substring, incl. children | The safe default for partial text |
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(.).| Function | What it does | Example |
|---|---|---|
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 delimiter | substring-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] |
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.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".
| Axis | Selects | Real 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 |
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.| Operator | Meaning | Example |
|---|---|---|
and / or | Combine 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 mod | Arithmetic (div, not /) | //tr[position() mod 2 = 0] even rows |
| | Union of node sets | //button[@type='submit'] | //input[@type='submit'] |
// 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)
(//a[contains(@href,'download')])[3] is the third such link on the page, full stop.| Problem | Pattern |
|---|---|
Id changes per build: input-8347-email | //input[starts-with(@id,'input-') and contains(@id,'-email')] |
| Class soup from frameworks | Anchor on stable attrs: //*[@data-testid='row-actions'] |
| Text with an apostrophe | //td[text()=concat("O", "'", "Brien")] |
| Case differs across environments | translate() 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] |
Same demo table as the CSS sheet, so you can compare the two languages line by line:
| Order | Customer | Status | Action |
|---|---|---|---|
| #1001 | Pramod | Delivered | |
| #1002 | Anita | Pending | |
| #1003 | Rahul | Delivered |
// 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)]
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].# 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']]")
$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.| Need | CSS | XPath |
|---|---|---|
| Id / class / attribute | Winner: shorter, faster to read | Works, wordier |
| Text matching (portable) | Not in standard CSS | Winner: text(), contains(), normalize-space() |
| Walk UP the tree | Only :has() | Winner: parent::, ancestor:: |
| Sibling hops | + and ~ (forward only) | Winner: both directions, with conditions |
| Column by header name | Not expressible | Winner: the count(th/preceding-sibling) trick |
| Playwright text search | :has-text() etc. (Playwright-only) | Portable everywhere |
/ 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.
1. //tr[1] is the first row. And remember the binding gotcha: (//a)[2] vs //div/a[2] are different questions.
//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.
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 →