XPath cheat sheet
Updated 2026-11-12 · 3 min read
XPath 1.0 syntax as browsers support it, grouped by what you want to do, with the CSS selector alternative where one exists. Keep it open next to the XPath and CSS tester.
Browsers implement XPath 1.0, so functions from 2.0 (such as matches() and lower-case()) do not work. Try everything below in the XPath and CSS tester.
Basics
| Goal | XPath | CSS |
|---|---|---|
Any input anywhere | //input | input |
| By id | //*[@id='email'] | #email |
| By class (contains the class) | //*[contains(@class,'btn')] | .btn |
| By exact attribute | //input[@name='q'] | input[name='q'] |
| Several attributes | //input[@type='text' and @name='q'] | input[type='text'][name='q'] |
| Either of two | //button[@id='a' or @id='b'] | #a, #b |
| Direct child | //form/input | form > input |
| Any descendant | //form//input | form input |
| First, last, nth match | (//li)[1], (//li)[last()], (//li)[3] | li:first-child, li:last-child, li:nth-child(3) |
| Attribute starts with | //a[starts-with(@href,'/docs')] | a[href^='/docs'] |
| Attribute ends with | no direct XPath 1.0 function | a[href$='.pdf'] |
| Attribute contains | //a[contains(@href,'login')] | a[href*='login'] |
| Not | //input[not(@disabled)] | input:not([disabled]) |
Text
| Goal | XPath |
|---|---|
| Exact text | //button[text()='Sign in'] |
| Text ignoring extra spaces | //button[normalize-space()='Sign in'] |
| Contains text | //p[contains(text(),'Welcome')] |
| Contains text including child tags | //p[contains(.,'Welcome')] |
CSS has no text matching. That is the main reason to use XPath. Playwright adds getByText, and CSS in Playwright supports :has-text("...").
Moving around the tree (axes)
| Goal | XPath |
|---|---|
| Parent | //input[@id='email']/.. or //input[@id='email']/parent::div |
| Ancestor | //input[@id='email']/ancestor::form |
| Following sibling | //label[text()='Email']/following-sibling::input |
| Preceding sibling | //td[text()='Asha']/preceding-sibling::td |
| Next element after | //h2[text()='Prices']/following::table[1] |
CSS can go down and across (+ next sibling, ~ later siblings) but cannot go up to a parent in a standard way; :has() now covers many "parent that contains" cases in modern browsers.
Patterns you will use all the time
An input next to its label
//label[normalize-space()='Email']/following-sibling::input
//label[normalize-space()='Email']/@for (then find the input whose id equals that value)
A table cell in the row containing certain text
//tr[td[normalize-space()='Asha']]/td[3]
Read it as: the third cell of the row that has a cell with text "Asha".
A button in a specific card
//div[contains(@class,'card')][.//h3[text()='Pro plan']]//button
The row number of a table row to click its Delete button
(//table//tr)[2]//button[normalize-space()='Delete']
Elements with a data attribute for tests
//*[@data-testid='submit'] or [data-testid='submit']
Habits
- Prefer a stable
idordata-testid. Use XPath for text and for going up the tree. - Do not copy an absolute path (
/html/body/div[3]/...). Any layout change breaks it. //at the start searches the whole document and is slower than starting from a known container://form[@id='login']//input.contains(@class,'btn')also matchesbtn-largeandnot-btn. When it matters, usecontains(concat(' ',normalize-space(@class),' '),' btn ').- Elements in an iframe or shadow DOM need the driver to switch frames or pierce the root first, whatever the selector.
Want a project you can show in interviews?
The portfolio project kit walks you through testing a deliberately buggy app with Selenium or Playwright and API tests, then explaining it. The app itself is free.
