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.
Translation table
| Intent | CSS | XPath |
|---|---|---|
| By id | #login | //*[@id='login'] |
| By class token | .btn | //*[contains(concat(' ', normalize-space(@class), ' '), ' btn ')] |
| By tag and class | button.primary | //button[contains(@class, 'primary')] |
| Attribute equals | input[name="email"] | //input[@name='email'] |
| Attribute exists | input[required] | //input[@required] |
| Attribute starts with | a[href^="/orders"] | //a[starts-with(@href, '/orders')] |
| Attribute contains | a[href*="order"] | //a[contains(@href, 'order')] |
| Attribute ends with | a[href$=".pdf"] | no direct form; see functions |
| Descendant | form input | //form//input |
| Direct child | ul > li | //ul/li |
| Next sibling | label + input | //label/following-sibling::input[1] |
| Any later sibling | label ~ input | //label/following-sibling::input |
| Previous sibling | not possible | //input/preceding-sibling::label[1] |
| Parent | not possible | //input/.. or //input/parent::div |
| Ancestor | not possible | //input/ancestor::form |
| First child | li:first-child | //li[1] (per parent) |
| Last child | li:last-child | //li[last()] |
| Nth child | li:nth-child(3) | //li[3] |
| Nth of type | p:nth-of-type(2) | //p[2] |
| First match on page | not possible (:first-child is per parent) | (//li)[1] |
| Exact text | not possible | //button[normalize-space()='Save'] |
| Partial text | not possible | //button[contains(., 'Save')] |
| Container that contains X | tr:has(td.error) | //tr[td[@class='error']] |
| Not | input:not([disabled]) | //input[not(@disabled)] |
| Or | button, a.btn | //button | //a[contains(@class,'btn')] |
| And | input[type=text][required] | //input[@type='text' and @required] |
| Checked (attribute) | input[checked] | //input[@checked] |
| Checked (live state) | input:checked | not possible |
| Empty element | p:empty | //p[not(node())] |
| Count | not possible | count(//li) |
Only XPath can
- Match text. CSS has never had a standard text selector. jQuery’s
:containsis not CSS. - Go up.
parent::,ancestor::,preceding-sibling::. CSS:has()covers “parent that contains”, but cannot select “the label before this input”. - Return values.
count(),string(),@hrefas a result. Useful for assertions and scraping. - Compute.
position()arithmetic,count(preceding-sibling::td)+1for column lookup, numeric comparisons on cell text.
Only CSS can
- Pierce shadow DOM in Playwright, Cypress and Selenium’s shadow-root context. XPath stops at the boundary everywhere.
- See live state pseudo-classes:
:checked,:focus,:hover,:disabledreflect 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:
| Locator | CSS | XPath |
|---|---|---|
#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 match | n/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.
Related reading
- Locator Strategy in 2026
- Introduction to XPath, which has the beginner-level comparison