Our stake here is small and worth declaring anyway: we are Geonode and we sell proxies to people doing extraction work, so selectors come up constantly in support conversations. The thing worth saying before the comparison is that neither choice affects whether you get blocked. A selector question and a network question look identical from a distance — both produce "my scraper stopped returning data" — and they have nothing in common. If the page arrived intact and your expression matched nothing, this is a selector problem and no infrastructure decision will touch it.
The Short Version
| CSS | XPath | |
|---|---|---|
| Select by class, id, attribute | Yes, cleanly | Yes, awkwardly |
| Descendant and child relationships | Yes | Yes |
| Select by text content | No | Yes |
| Navigate to parent or ancestor | Partly, via :has() | Yes, directly |
| Navigate to previous sibling | Partly, via :has() | Yes, directly |
| Positional selection | :nth-child() family | position(), last(), predicates |
| String functions | No | Yes |
| Query XML with namespaces | No | Yes |
| Readability | Better | Worse |
| Tooling and browser support | Universal | Universal for 1.0 |
Two rows carry most of the weight. CSS cannot select by text content at all, which rules it out for the single most common extraction pattern — finding a value by the label next to it. And XPath is harder to read, which matters more than people admit when a colleague has to maintain your code a year later.
What CSS Does Better
Class and attribute selection, and it is not close.
div.product-card
a[href^="https://"]
input[type="checkbox"]:checked
ul > li:first-child
section.content p:not(.footnote)
The XPath equivalents are longer and, for classes, genuinely unpleasant:
//div[contains(concat(' ', normalize-space(@class), ' '), ' product-card ')]
//a[starts-with(@href, 'https://')]
//ul/li[1]
That first one is the class-matching idiom, and it exists because @class is a single space-separated string and XPath has no notion of tokens within it. A naive contains(@class, 'product-card') also matches product-card-large and old-product-card, so the padded version is the correct one. It is also four times the length of div.product-card and considerably harder to scan.
If your selection criteria are classes, ids, attributes and structural relationships, use CSS. This covers a large majority of real selection work, and choosing XPath for it means paying a readability cost for capability you are not using.
CSS also has the better tooling story. Every browser's element inspector produces CSS selectors natively, most testing frameworks default to them, and document.querySelectorAll is available everywhere without a helper.
What XPath Does Better
Text matching, which CSS cannot do at all:
//button[normalize-space()='Continue']
//a[contains(., 'Download')]
//dt[normalize-space()='Price']/following-sibling::dd[1]
That last pattern — find the label, take the adjacent value — is the workhorse of structured-page extraction, and there is no CSS expression for it because CSS has no access to text content. This single capability is why XPath persists in scraping codebases that otherwise use CSS throughout.
Ancestor navigation:
//span[@class='price']/ancestor::div[contains(@class,'card')][1]
Walk up from a value to the container that holds it. :has() gives CSS a form of this, with limits covered below.
Positional logic relative to content:
//h2[normalize-space()='Specifications']/following-sibling::table[1]
The first table after a specific heading. :nth-child() counts positions among siblings; it cannot express "after the element whose text is X".
String functions. normalize-space(), substring-before(), translate() and the rest let you do work inside the expression. normalize-space() in particular is close to essential, since real HTML is pretty-printed and an exact text comparison against "\n In stock\n" will fail.
XML with namespaces. If you are querying XML rather than HTML — a sitemap, an RSS feed, a SOAP response — CSS is not the tool. XPath is designed for it, and namespace handling is part of the design.
How :has() Changed Things
The most significant development in this comparison, and many articles predate it.
The MDN documentation describes :has() as representing "an element if any of the relative selectors that are passed as an argument match at least one element when anchored against this element", providing "a way of selecting a parent element or a previous sibling element with respect to a reference element". Its baseline status is "widely available", supported across browsers since December 2023.
So CSS can now express things it previously could not:
div.card:has(span.sold-out) /* a card containing a sold-out marker */
h1:has(+ p) /* an h1 immediately followed by a p */
li:has(~ li.active) /* an li with a later active sibling */
tr:has(td.error) /* a row containing an error cell */
That covers a real share of what people previously needed XPath for — parent selection, and conditioning on a sibling.
Three limits are documented and worth knowing. :has() "cannot be nested within another :has()". Pseudo-elements "are not valid selectors within :has()" and are not valid anchors for it. And its specificity is "the specificity of the most specific selector in its arguments", matching how :is() and :not() behave.
The limit that matters most for extraction is not on that list: :has() still cannot match on text. div:has(span) works; div:has(span:contains('Price')) does not exist, because :contains() is not a standard CSS selector. It was proposed and dropped, and browsers do not implement it. So the label-value pattern remains XPath territory regardless of :has().
Side-by-Side Translations
The fastest way to develop a feel for the trade-off is to see the same intent expressed both ways.
| Intent | CSS | XPath |
|---|---|---|
| Element with a class | div.card | //div[contains(concat(' ',normalize-space(@class),' '),' card ')] |
| Element with an id | #main | //*[@id='main'] |
| Attribute exists | a[href] | //a[@href] |
| Attribute starts with | a[href^="/docs"] | //a[starts-with(@href,'/docs')] |
| Attribute contains | a[href*="pdf"] | //a[contains(@href,'pdf')] |
| Direct child | ul > li | //ul/li |
| Any descendant | div p | //div//p |
| First child | li:first-child | //li[1] |
| Last child | li:last-child | //li[last()] |
| Nth child | li:nth-child(3) | //li[3] |
| Next sibling | h2 + p | //h2/following-sibling::p[1] |
| Any later sibling | h2 ~ p | //h2/following-sibling::p |
| Negation | p:not(.footnote) | //p[not(contains(@class,'footnote'))] |
| Parent of a match | div:has(> span.price) | //span[contains(@class,'price')]/.. |
| Contains text | not possible | //p[contains(., 'Price')] |
| Exact text | not possible | //p[normalize-space()='Price'] |
| Value next to a label | not possible | //dt[normalize-space()='Price']/following-sibling::dd[1] |
Reading down that table, the pattern is clear. For everything above the parent row, CSS is shorter and clearer, and choosing XPath is choosing verbosity for no gain. For the three rows at the bottom, there is no CSS column at all.
Two translations deserve a second look. The class row is the starkest contrast in the whole comparison — nine characters against seventy-odd — and the XPath version is not padding for its own sake, because the short form contains(@class,'card') genuinely over-matches card-large and discard. And the "first child" row hides a trap: li:first-child and //li[1] agree here, but //li[1] is a predicate applied per parent, so it selects the first li under every list in the document. The CSS is unambiguous; the XPath needs (//li)[1] if you meant only one.
A Realistic Mixed Example
What this looks like in practice, extracting a product page.
# CSS for the structural work — clear and adequate
cards = tree.cssselect('div.product-grid > article.product-card')
for card in cards:
name = card.cssselect('h3.product-name')[0].text_content().strip()
image = card.cssselect('img.product-image')[0].get('src')
# XPath for the label-value pairs, which CSS cannot express
price = card.xpath(
".//dt[normalize-space()='Price']/following-sibling::dd[1]"
)[0].text_content().strip()
stock = card.xpath(
".//span[contains(., 'in stock') or contains(., 'out of stock')]"
)
Three things in there are worth copying.
The leading . on each XPath expression. .//dt searches within the current card; //dt would search the entire document from the root, returning the first matching label on the page for every card. This is one of the most common bugs in mixed extraction code, and it produces the same value for every record — plausible, uniform, and wrong.
CSS where CSS suffices. The grid and card selection, the heading, the image. Writing those in XPath would add length and subtract clarity.
XPath only at the point of need. The price is identified by its label, and the stock status by its text. Neither is expressible in CSS, and both are the reason XPath is in the file at all.
The result is a scraper where each expression is as short as it can be, and a reader can see at a glance which parts depend on structure and which depend on content — which is also a map of what will break when the site changes.
Performance
Usually irrelevant, occasionally decisive.
CSS is generally faster in browsers, because engines optimise selector matching heavily — it is on the critical path for rendering. XPath goes through a more general evaluation model.
The difference rarely matters at the scale most people work at. Selecting from a single page a few hundred times is unmeasurable either way; the network request dominates by orders of magnitude.
Where it does matter:
The preceding and following axes are genuinely expensive. They scan the whole document before or after the context node. Inside a loop over many nodes, that becomes quadratic. If an XPath expression is noticeably slow, check whether it uses one of those — preceding-sibling and following-sibling are bounded by sibling count and are fine.
// at the start of a nested expression re-searches from the root. //div//span is more expensive than it looks, and .//span inside a loop over divs is what you usually meant.
:has() can be costly in large documents, since the engine must evaluate the inner selector for each candidate. Fine for extraction; something to watch in a stylesheet applied to a large page.
For server-side parsing with lxml or similar, the difference is small enough that readability should decide.
Availability and Version Traps
The gotcha that produces "it works in the online tester and not in my code".
Browsers implement XPath 1.0 through document.evaluate, and Selenium uses the browser's engine. XPath 2.0 and 3.1 features — matches(), replace(), lower-case(), ends-with(), sequences — are not available there. A snippet using any of them will fail.
lxml implements XPath 1.0 for its common API, plus EXSLT extensions including re:test() for regular expressions. So a regex-based expression that works in Python will not work in Selenium.
CSS support in parsing libraries is usually via a translation layer that converts CSS to XPath internally. That works well for the common selectors and less well for newer ones — :has() support in server-side parsers varies considerably, and a selector that works in a browser may not work in your scraper.
Practical rule: test your selectors in the environment that will run them, not in a browser console or an online tool. The two most common surprises are XPath 2.0 functions failing in Selenium, and :has() failing in a Python parser.
Choosing in Practice
A decision procedure that resolves nearly every case.
Start with CSS. It is more readable, better tooled, and adequate for class, id, attribute and structural selection — which is most selection.
Switch to XPath when you need to match on text. This is the main reason and it is decisive: no CSS construct reads text content.
Switch to XPath for ancestor navigation beyond what :has() covers, particularly when you need a specific ancestor several levels up rather than a conditional match on a known parent.
Switch to XPath for XML. Namespaces and document-order operations are what it was built for.
Use both in one codebase. This is normal and sensible rather than a compromise. A scraper using CSS for the straightforward 90% and XPath for the hard 10% is easier to maintain than one committed to either.
And a habit worth more than either choice: check for embedded structured data before writing a selector at all. Many pages carry JSON-LD in a <script type="application/ld+json"> block because it drives search features, and parsing that is dramatically more stable than parsing rendered markup. It survives redesigns that break every selector on the page.
What Neither Solves
Both are fragile in the same way, and this is the part that costs real time.
Structural selectors break silently. Someone inserts a column, and td[3] matches a different cell. No error is raised; your data is quietly wrong. Anchor on text or identifiers where you can, and assert the shape of what you extract — if a price should look like a price, check it.
Neither handles client-rendered content. If the markup is assembled by JavaScript after load, both find nothing in the initial HTML. That is a fetching problem requiring a browser or the underlying API, not a selector problem.
Neither survives a redesign on its own. The mitigation is to keep selectors in one place, so updating after a change is an hour rather than a day, and to store the HTML you parsed so you can compare old against new when something breaks.
And neither is affected by how the page was fetched. Which is where we came in: if the document is intact and your expression matches nothing, no amount of infrastructure changes the answer.
People Also Ask
Is XPath better than CSS selectors?
Neither is better in general. XPath is more capable — it can match text, navigate to ancestors and use string functions. CSS is more readable and better supported by tooling. Use CSS by default and XPath for the specific things CSS cannot express.
Can CSS selectors select by text?
No. There is no standard CSS selector for text content — :contains() was proposed and never adopted, and browsers do not implement it. This is the single largest capability gap and the main reason XPath persists in extraction work.
Does :has() replace XPath?
Partly. It gives CSS parent selection and sibling-conditional matching, which were previously XPath-only. It does not add text matching, so the label-value pattern still needs XPath. It also cannot be nested, and support in server-side parsing libraries varies.
Which is faster, XPath or CSS?
CSS is generally faster in browsers because selector matching is optimised for rendering. The difference is usually irrelevant next to network time. Where XPath genuinely gets slow is the preceding and following axes, which scan the whole document.
Can I use XPath 2.0 in Selenium?
No. Browsers implement XPath 1.0 through document.evaluate, and Selenium uses the browser's engine. Functions such as matches(), lower-case() and ends-with() are not available. This is the usual reason a snippet works in an online tester and fails in a test suite.
How do I select a parent element?
In XPath, parent:: or ... In CSS, :has() provides a conditional form — div:has(> span.price) selects the div rather than the span. For a specific ancestor several levels up, XPath's ancestor:: axis is more direct.
Should I use CSS or XPath for web scraping?
Both, in the same codebase. CSS for class, id and attribute selection, which is most of it. XPath where you need to match text — finding a value by its label is the archetypal case and has no CSS equivalent.
Which is more stable when a site changes?
Neither, inherently — stability comes from what you anchor on rather than which language you use. Anchoring on text or a data-* attribute survives a redesign; anchoring on position survives nothing. Both languages let you do either.
Wrapping Up
The comparison reduces to two asymmetries. CSS cannot read text, and XPath is harder to read. Everything else is detail.
That makes the working rule straightforward: default to CSS, because most selection is by class, id or attribute and CSS expresses those cleanly. Reach for XPath at the specific point where you need something it uniquely offers — text matching above all, then ancestor navigation and string functions. Mixing them is normal, and a codebase that uses each for what it is good at is easier to maintain than one that picked a side.
:has() has genuinely narrowed the gap and is worth adopting where it fits: parent selection and sibling conditions in plain CSS, widely available since late 2023. Just note that it does not address text, and that server-side parser support for it is less uniform than browser support.
Then check your environment before trusting any expression. XPath 1.0 in browsers and Selenium, EXSLT extras in lxml, variable :has() support in parsing libraries — the most common selector surprise is not a syntax error, it is a feature that exists somewhere other than where you are running it.
