← All posts

XPath vs CSS Selectors: Which Should You Use for Web Scraping?

Quick answer: use CSS selectors by default and switch to XPath when you hit something CSS can't express: walking up to a parent element, matching elements by their text content, or grabbing "the cell after the cell that says Email." Performance differences between the two are negligible in modern tooling, so the decision comes down to capability and readability, not speed.

That's the whole guide in one paragraph. The rest of this post is the evidence, because the internet is full of confident claims in both directions ("XPath is slow", "CSS can't do anything real") and most of them are either outdated or wrong.

The Head-to-Head Table

CSS Selectors XPath
Select by tag, class, id Yes Yes
Select by attribute Yes Yes
Partial attribute match Yes ([href^="/item"]) Yes (starts-with())
Match by text content No Yes (text(), contains())
Walk up to a parent Mostly no Yes (.., ancestor::)
Following/preceding siblings Following only (~, +) Both directions
Position logic Limited (:nth-child) Full (position(), last())
Works in querySelector Yes, natively No (needs document.evaluate)
Readability High Medium, degrades fast
Works on XML Awkward Native

Two rows in that table decide most real-world debates: text matching and upward traversal. Everything else is a wash.

Where CSS Selectors Win

Readability. div.product-card a.title reads like a sentence. The XPath equivalent, //div[contains(@class, "product-card")]//a[contains(@class, "title")], is 50 percent punctuation. On a scraper with 30 selectors that you'll revisit in six months, this compounds.

You already know them. Anyone who has written CSS or used jQuery can write CSS selectors. XPath is a separate language with its own axes, functions, and 1-based indexing that trips up every programmer the first time.

Native browser support. document.querySelectorAll('.price') just works, in DevTools and in every headless browser. XPath in the browser means document.evaluate() with its five-argument signature that nobody has ever typed from memory, or the $x() helper that only exists in the DevTools console.

Class matching semantics. This one is underrated. div.card matches an element whose class list contains card, handling multiple classes correctly. The naive XPath //div[@class="card"] silently fails on class="card featured", and the correct version is the verbose contains(concat(" ", normalize-space(@class), " "), " card "). Most people write the broken contains(@class, "card") instead, which also matches card-header. CSS gets this right for free.

Where XPath Wins

Matching by text. You want the button that says "Next page", not the fourth button in the toolbar. CSS has no way to say that. XPath does:

//button[contains(text(), "Next")]

Scrapers target meaning ("the row labeled Price"), and text is often the only stable hook on a page full of generated class names like css-1x8v2ab.

Walking up the tree. You can find the price span easily, but you actually want the whole product card that contains it. CSS selectors only move down and sideways. XPath goes up:

//span[contains(text(), "$")]/ancestor::div[contains(@class, "card")]

CSS technically gained :has() for this ("select the card that has a price inside"), and it now works in browsers. But support in scraping libraries is spotty: lxml and BeautifulSoup's default engines don't fully support it, so you can't count on it outside a real browser.

Key-value tables. Spec sheets, contact details, and metadata tables are everywhere, and they're the canonical XPath use case:

//td[normalize-space()="Email"]/following-sibling::td[1]

Doing this with CSS requires selecting all rows and filtering in your own code. XPath does it in one expression. If you scrape product spec pages for a living, this single pattern justifies learning XPath.

XML and weird documents. Sitemaps, RSS feeds, SOAP responses. XPath was built for XML; CSS selectors were bolted on. For anything that isn't HTML, use XPath and don't look back.

The Performance Question, Honestly

The claim "XPath is slower" gets repeated constantly, and it's mostly a fossil from the Selenium 1 era, when XPath on Internet Explorer ran through a slow JavaScript polyfill. That was fifteen years ago.

Here's what actually matters today:

  • In browsers, querySelectorAll is heavily optimized native code, and document.evaluate is also native. Both resolve in microseconds on normal pages. Your network request takes 300 to 2,000 milliseconds. The selector is a rounding error.
  • In lxml (Python), there is no contest at all, because CSS selectors are translated into XPath before they run. The cssselect library literally compiles div.card > a into descendant-or-self::div[contains(concat(' ', normalize-space(@class), ' '), ' card ')]/a. When people benchmark "CSS vs XPath in lxml", they're benchmarking XPath vs XPath plus a translation step.
  • In Selenium and Playwright, the selector engine cost is dwarfed by the round-trip to the browser process. Selector choice changes nothing you can measure.

In my experience the only place selector performance ever showed up in a profile was running tens of thousands of very complex XPath expressions with nested predicates against large documents in a tight loop. If that's you, benchmark your actual workload. For everyone else, choose on capability and pick the one you can read.

A Decision Rule You Can Actually Remember

  1. Start with a CSS selector. If it expresses what you want, you're done.
  2. Need to match on visible text? XPath.
  3. Need the parent or an earlier sibling of what you can select? XPath.
  4. Scraping XML? XPath.
  5. Everything else, including "class contains", attribute prefixes, and nth-child logic? Stay with CSS.

Mixing both in one project is fine. Scrapy, Selenium, Playwright, and lxml all accept both, often on the same page object. Consistency within a single selector matters; ideological purity across a codebase does not.

One warning that applies to both languages: never use absolute paths. /html/body/div[2]/div/div[3]/span and its CSS cousin body > div:nth-child(2) > ... break the moment the site ships a redesign, or even an A/B test. Anchor to semantics: ids, stable class fragments, text labels, data- attributes.

Sometimes the Answer Is Neither

Here's the thing nobody in the XPath vs CSS debate mentions: if you're feeding pages to an LLM or extracting article-style content, you may not need element selectors at all. Selectors exist to slice structure out of HTML. If what you actually want is "the readable content of this page as clean text," a fetch layer that converts HTML to Markdown, like the link.sc fetch API, skips the selector-maintenance treadmill entirely. We wrote about that tradeoff in HTML to Markdown for LLMs.

Selectors still win when you need precise fields: the price, the SKU, the third column of a table. For those jobs, keep the decision rule above, and keep the XPath cheat sheet bookmarked for the day CSS runs out of road. And if the page renders its content with JavaScript, no selector will save you until the DOM actually exists; see scraping JavaScript-rendered websites for that fight.

The debate has a boring resolution: CSS for the 80 percent, XPath for the 20 percent CSS can't touch, and no selector at all when Markdown extraction covers the job.


Skip the selector wars for content extraction: sign up for link.sc and get any page back as clean, LLM-ready Markdown.