Strategy

XPath vs CSS Selectors: A Side-by-Side Reference

Every common locator written both ways, the four things only XPath can do, the two things only CSS can do, and the performance question answered with numbers instead of folklore.

csscomparisonreference

Translation table

IntentCSSXPath
By id#login//*[@id='login']
By class token.btn//*[contains(concat(' ', normalize-space(@class), ' '), ' btn ')]
By tag and classbutton.primary//button[contains(@class, 'primary')]
Attribute equalsinput[name="email"]//input[@name='email']
Attribute existsinput[required]//input[@required]
Attribute starts witha[href^="/orders"]//a[starts-with(@href, '/orders')]
Attribute containsa[href*="order"]//a[contains(@href, 'order')]
Attribute ends witha[href$=".pdf"]no direct form; see functions
Descendantform input//form//input
Direct childul > li//ul/li
Next siblinglabel + input//label/following-sibling::input[1]
Any later siblinglabel ~ input//label/following-sibling::input
Previous siblingnot possible//input/preceding-sibling::label[1]
Parentnot possible//input/.. or //input/parent::div
Ancestornot possible//input/ancestor::form
First childli:first-child//li[1] (per parent)
Last childli:last-child//li[last()]
Nth childli:nth-child(3)//li[3]
Nth of typep:nth-of-type(2)//p[2]
First match on pagenot possible (:first-child is per parent)(//li)[1]
Exact textnot possible//button[normalize-space()='Save']
Partial textnot possible//button[contains(., 'Save')]
Container that contains Xtr:has(td.error)//tr[td[@class='error']]
Notinput:not([disabled])//input[not(@disabled)]
Orbutton, a.btn//button | //a[contains(@class,'btn')]
Andinput[type=text][required]//input[@type='text' and @required]
Checked (attribute)input[checked]//input[@checked]
Checked (live state)input:checkednot possible
Empty elementp:empty//p[not(node())]
Countnot possiblecount(//li)

Only XPath can

  1. Match text. CSS has never had a standard text selector. jQuery’s :contains is not CSS.
  2. Go up. parent::, ancestor::, preceding-sibling::. CSS :has() covers “parent that contains”, but cannot select “the label before this input”.
  3. Return values. count(), string(), @href as a result. Useful for assertions and scraping.
  4. Compute. position() arithmetic, count(preceding-sibling::td)+1 for column lookup, numeric comparisons on cell text.

Only CSS can

  1. Pierce shadow DOM in Playwright, Cypress and Selenium’s shadow-root context. XPath stops at the boundary everywhere.
  2. See live state pseudo-classes: :checked, :focus, :hover, :disabled reflect the DOM property, not the initial attribute.

Readability

For simple attribute selection, CSS is shorter and most developers already know it. For structural queries, XPath expressions are longer but say what they mean; the CSS equivalent often does not exist. A mixed convention is normal: CSS by default, XPath where CSS runs out.

Performance

Measured in Chromium with performance.now() around 1,000 evaluations on a 5,000-node page:

LocatorCSSXPath
#id~0.002 ms~0.01 ms
button.primary~0.02 ms~0.05 ms
form input[name=email]~0.03 ms~0.06 ms
text matchn/a~0.2 ms

Per lookup, in fractions of a millisecond. A single WebDriver round trip is a few milliseconds and a wait is seconds. The selector engine is never the bottleneck in a modern browser. The old “XPath is slow” advice dates from Internet Explorer, which had no native engine. See Performance and Efficiency.

Choosing

  • Team fluent in CSS, elements have ids and test ids, Playwright or Cypress: CSS or role locators, XPath for the odd structural case.
  • Selenium, tables and lists, text-driven flows, legacy markup: XPath as the default for anything non-trivial.
  • Mixed tools sharing a locator library: XPath, because it is the only language all of them accept unchanged.

Converting

The CSS to XPath converter handles the common cases automatically. Going the other way is only possible when the XPath uses no text, no axes and no functions, which is exactly when you should have used CSS.