Skip to content
QA Knowledge Hub

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

GoalXPathCSS
Any input anywhere//inputinput
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/inputform > input
Any descendant//form//inputform 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 withno direct XPath 1.0 functiona[href$='.pdf']
Attribute contains//a[contains(@href,'login')]a[href*='login']
Not//input[not(@disabled)]input:not([disabled])

Text

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

GoalXPath
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 id or data-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 matches btn-large and not-btn. When it matters, use contains(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.