xpath
XPath vs CSS Selectors: When Each One Wins, with 12 Copy-Paste Expressions
CSS can't match by text and needs :has() to look up the tree. XPath does both. 12 side-by-side expressions for scraping and tests, plus the //tag[1] trap.
Every scraping tutorial picks a side: XPath is the power tool, or XPath is legacy and CSS is cleaner. Both are right about different jobs: CSS selectors are the better default for ids, classes and attributes, and XPath wins the moment you need to match on text, select an attribute or text node itself, or pick the first match across a whole page. The old line that “CSS can’t select a parent” is mostly out of date, because :has() now works in every major browser. Text matching is still XPath-only.
Below is a 12-row table of the same task written both ways, then the traps that make XPath return the wrong thing: //tag[1], text(), class matching and default namespaces. Every expression in the table was run against the sample markup further down, and the XPath rules are checked against the W3C XPath 1.0 recommendation. You can paste the markup and any of these expressions into our XPath tester to see the matches live.
The sample markup
<nav class="site-nav">
<a href="/">Home</a>
<a href="https://example.com/docs" class="external">Docs</a>
<a href="http://example.com/blog" class="external link">Blog</a>
</nav>
<main id="content">
<h2>Lunch</h2>
<ul class="products">
<li class="product featured" data-sku="A1"><span class="name">Lunch box</span> <span class="price">$12</span></li>
<li class="product" data-sku="B2"><span class="name">Water bottle</span> <span class="price">$8</span></li>
<li class="product-card" data-sku="C3"><span class="name">Thermos</span></li>
</ul>
<h2>Snacks</h2>
<ul class="products">
<li class="product" data-sku="D4"><span class="name">Crackers</span> <span class="price">$3</span></li>
<li class="product" data-sku="E5"><span class="name">Grapes</span></li>
</ul>
<form>
<label for="email">Email</label>
<input id="email" type="email">
<button type="submit"> Sub<b>scribe</b> now </button>
</form>
</main>
Two lists, a near-miss class (product-card), and a button whose text is split by a <b> and padded with spaces. That is where most selectors break.
12 tasks, both ways
| # | Task | CSS | XPath 1.0 | Matches |
|---|---|---|---|---|
| 1 | By id | #email | //*[@id='email'] | the input |
| 2 | By class (one of several) | .product | //*[contains(concat(' ', normalize-space(@class), ' '), ' product ')] | A1, B2, D4, E5 |
| 3 | Attribute contains | a[href*="example.com"] | //a[contains(@href,'example.com')] | Docs, Blog |
| 4 | Attribute starts with | a[href^="https"] | //a[starts-with(@href,'https')] | Docs |
| 5 | Second child, if it is an li | ul > li:nth-child(2) | //ul/*[2][self::li] | B2, E5 |
| 6 | Last li in each list | ul > li:last-of-type | //ul/li[last()] | C3, E5 |
| 7 | Text contains | none | //button[contains(., 'Subscribe')] | the button |
| 8 | Exact text, whitespace-proof | none | //button[normalize-space()='Subscribe now'] | the button |
| 9 | Parent of X | *:has(> .price) | //span[@class='price']/.. | A1, B2, D4 |
| 10 | Preceding sibling | label:has(+ #email) | //input[@id='email']/preceding-sibling::label[1] | the label |
| 11 | Ancestor with a class | .site-nav:has(a.external) | //a[contains(@class,'external')]/ancestor::*[contains(@class,'site-nav')] | the nav |
| 12 | Has a child | li:has(> span.price) | //li[span[@class='price']] | A1, B2, D4 |
A few rows need a sentence each.
Row 2 is long on purpose. The short version, //*[contains(@class,'product')], is a substring test, so on this markup it also matches both ul.products elements and the product-card item. That is seven nodes instead of four. The concat(' ', ..., ' ') padding turns it into a whole-word test, which is what CSS .product does for free.
Rows 5 and 6 look like they should be li:nth-child(2) and //li[2], but they don’t mean the same thing. In CSS, :nth-child(2) counts every element sibling. In XPath, li[2] counts only li siblings, which makes it the twin of :nth-of-type(2). The *[2][self::li] form counts all children first and then checks the name, which matches :nth-child exactly.
Rows 9 to 12 are the ones the “CSS can’t go up” advice is about. With :has(), CSS can now select an element based on what is inside it or after it. MDN lists :has() as Baseline widely available since December 2023. There’s a real difference in direction, though. The CSS version asks “which elements contain a match?”, while the XPath version starts at a specific node and walks up from it with .., ancestor:: or preceding-sibling::. Also, Selectors Level 4 says :has() cannot be nested inside another :has(). XPath predicates nest as deep as you like.
Rows 7 and 8 have no CSS column because the Selectors spec has no pseudo-class that matches text content. Playwright’s :has-text() and Puppeteer’s ::-p-text() are tool-specific extensions, not CSS.
CSS also has one trick XPath 1.0 lacks: a[href$="/blog"] (ends with). XPath 1.0 has no ends-with(), which arrived in 2.0. The 1.0 workaround is //a[substring(@href, string-length(@href) - string-length('/blog') + 1) = '/blog']. It works, and it’s a mess.
The //tag[1] trap
This is the bug behind most “my XPath returns too many results” questions. On the sample, //li[1] returns two nodes, A1 and D4.
The reason is in how the abbreviation expands. The spec defines // as short for /descendant-or-self::node()/, and a predicate binds to the step it follows, not to the whole path. So //li[1] is really:
/descendant-or-self::node()/child::li[1]
Read it left to right: for every node in the document, take its first li child. Each <ul> has a first li, so you get one per list. The spec spells this out: //para[1] “does not mean the same as” /descendant::para[1]. The first selects every para that is the first para child of its parent.
To get the first li on the whole page, wrap the path in parentheses so the predicate filters the finished node-set:
(//li)[1] → A1 only
(//li)[last()] → E5 only
//li[last()] → C3 and E5 (last li of each list)
/descendant::li[1] also returns only A1. One warning: not every engine gets this right. jsdom 26 returned two nodes for (//li)[1] when we checked these expressions, which goes against section 3.3 of the spec (“the Predicate filters the node-set with respect to the child axis”, so position comes from document order across the whole set). Browsers’ document.evaluate follows the spec. If a test passes in jsdom and fails in a real browser, this is worth checking.
Our XPath tester catches this: when a plain //tag[n] matches more than one node, it explains why and offers (//tag)[n] as a one-click fix.
text() vs . vs normalize-space()
Three ways to test text, three different results on the sample button:
| Expression | Result | Why |
|---|---|---|
//button[text()='Subscribe now'] | no match | text() is the button’s direct text nodes: " Sub" and " now ". Neither equals the string. |
//button[contains(text(), 'Subscribe')] | no match | A node-set passed to a string function becomes the string-value of its first node, so this only sees " Sub". |
//button[contains(., 'Subscribe')] | match | . is the element’s string-value: all descendant text joined, " Subscribe now ". |
//button[normalize-space()='Subscribe now'] | match | Trims the ends and collapses inner runs of whitespace to one space. |
Both rules (string-value is “the concatenation of the string-values of all text node descendants”, and a node-set becomes the string-value of its first node in document order) are in the 1.0 spec. For buttons and links, use normalize-space() with an exact match. Markup gets reformatted, and someone eventually wraps half a label in <b>.
Default namespaces: the empty result
If you query XML and //book returns nothing, check the root element for xmlns="...". Section 2.3 of the spec says the default namespace declared with xmlns “is not used” in name tests. An unprefixed name only matches elements in no namespace, so in a sitemap, feed or SOAP response it matches nothing.
Two fixes: bind a prefix to the namespace URI in your code (//s:loc with s mapped to the sitemap URI), or ignore namespaces with //*[local-name()='loc']. The XPath tester reads the xmlns declarations into a namespace table where you can add or override prefixes. If a node-set comes back empty in a document with a default namespace, it explains why and offers a rewrite with every bare name turned into *[local-name()='...']. HTML pages don’t hit this, because browsers special-case HTML documents so //div matches without a prefix.
When to use which
- Start with CSS for ids, classes, attributes and simple structure.
- Switch to XPath for text matching, walking up from a known node, selecting
@hrefortext()nodes themselves, numeric comparisons like//book[price > 10], the first match across the document, and namespaced XML. - In test code, consider neither. Playwright’s docs recommend “prioritizing user-visible locators like text or accessible role instead of using XPath” (they say the same about CSS), since both are “tied to the implementation”. They also note that XPath does not pierce shadow roots, while Playwright’s CSS engine pierces open shadow DOM.
- Old Puppeteer code:
page.$x()andwaitForXpathwere removed in Puppeteer 22.0.0 (released 2024-02-05). The replacement is the::-p-xpath(...)selector, as inpage.$$('::-p-xpath(//h2)'), which runs through the browser’s nativedocument.evaluate.
Whatever you run it in, it’s XPath 1.0. Browsers only ship 1.0, so matches(), ends-with() and lower-case() throw errors there. Our XPath tester shows a specific message when it sees one of those 2.0 functions.
Test it before you paste it
Paste your page into the XPath tester in HTML mode (it parses like a browser, so tag names are lowercase and missing tbody and body elements get added), or in XML mode for feeds and API responses. It runs your expression through the browser’s own document.evaluate as you type. Node-set results list each match’s type, a preview and an absolute path. When the expression is right, copy it as a document.evaluate loop, a Playwright xpath= locator, a Selenium Python By.XPATH call or a Puppeteer ::-p-xpath query. Nothing is uploaded.
Messy scraped markup is easier to read after a pass through the HTML formatter, and XML gets the same from the XML formatter. For JSON, the JSON Path finder does the same job, and JSONPath vs jq explains where that language’s rules differ.