A short note on who wrote this. We are Geonode and we sell proxies to people who extract data, so XPath is adjacent to our business rather than part of it. The relevant disclaimer is that a selector problem and a proxy problem look nothing alike, and confusing them wastes hours — a blocked request returns a challenge page or an error status, while a broken expression returns a perfectly good page and an empty result. If the HTML is there and your XPath finds nothing, the network is fine and this page is the right place.
The Basic Syntax
| Expression | Selects |
|---|---|
/html/body/div | Absolute path from the root |
//div | Any div anywhere in the document |
//div/p | p elements that are direct children of a div |
//div//p | p elements at any depth inside a div |
. | The current context node |
.. | The parent of the context node |
* | Any element |
@href | The href attribute |
//@href | Every href attribute in the document |
text() | Text node children of the context node |
node() | Any node, including text and comments |
//a | //link | Union — everything matched by either expression |
The distinction between / and // is the one to internalise. A single slash means "direct child"; a double slash means "descendant at any depth". //div/p misses a paragraph wrapped in a section; //div//p finds it.
Absolute paths — /html/body/div[2]/div[1]/span — are what browser "copy XPath" produces and what breaks on the next redesign. Prefer starting from something identifiable and navigating relatively.
Predicates
Square brackets filter a node set. They are where most of the useful work happens.
| Expression | Selects |
|---|---|
//div[1] | The first div among its siblings, per parent |
(//div)[1] | The first div in the entire document |
//div[last()] | The last div among its siblings |
//div[position() < 4] | The first three |
//a[@href] | Links that have an href attribute |
//a[@href='/about'] | Links with that exact href |
//div[@class and @id] | Elements with both attributes |
//p[text()] | Paragraphs with at least one text-node child |
//div[p] | Divs containing at least one p child |
//div[not(@hidden)] | Divs without a hidden attribute |
//td[.='42'][@class='qty'] | Two predicates, applied in sequence |
//div[1] versus (//div)[1] is the single most common confusion in XPath. The first is a predicate applied per parent, so it selects the first div under every parent that has one — potentially many nodes. The second collects all divs into a node set in document order and takes the first, which is exactly one node. Both are useful; they are not interchangeable.
Predicates chain, and each applies to the result of the previous one. //td[@class='price'][1] means "the first among the cells with class price", reading left to right.
Axes
Axes navigate relative to the context node. Most people use three and occasionally need the others.
| Axis | Selects |
|---|---|
child:: | Direct children — the default, usually omitted |
descendant:: | All descendants at any depth |
parent:: | The parent |
ancestor:: | All ancestors up to the root |
ancestor-or-self:: | Ancestors plus the node itself |
following-sibling:: | Later siblings |
preceding-sibling:: | Earlier siblings |
following:: | Everything after in document order, excluding descendants |
preceding:: | Everything before, excluding ancestors |
attribute:: | Attributes — abbreviated as @ |
self:: | The node itself |
Four are reverse axes — ancestor, ancestor-or-self, preceding and preceding-sibling — and on those, position numbering runs backwards. preceding-sibling::p[1] is the nearest preceding paragraph, not the first in the document. Wrap in parentheses to get document order instead. We covered this in detail in XPath preceding-sibling.
following and preceding are much broader and much slower than their sibling counterparts, and they exclude ancestors and descendants respectively. Reach for them only when the relationship is genuinely loose.
String Functions
The workhorses.
| Function | Does |
|---|---|
contains(a, b) | True if a contains b as a substring |
starts-with(a, b) | True if a begins with b |
normalize-space(s) | Trims and collapses internal whitespace |
string-length(s) | Character count |
substring(s, start, len) | Substring, 1-indexed |
substring-before(a, b) | Everything before the first occurrence of b |
substring-after(a, b) | Everything after the first occurrence of b |
translate(s, from, to) | Character-by-character replacement |
concat(a, b, ...) | Joins strings |
string(node-set) | String value of the first node only |
Three notes that prevent real bugs.
substring() is 1-indexed. substring('hello', 1, 3) returns hel. Everyone gets this wrong once.
normalize-space() should be your default wrapper on any text comparison. Real HTML is pretty-printed, so the string value of a cell is frequently "\n In stock\n" rather than "In stock". With no argument it operates on the context node.
There is no ends-with(), no lower-case() and no regular expressions in XPath 1.0. For case-insensitivity, translate() with explicit alphabets is the standard workaround. Server-side libraries such as lxml support EXSLT extensions including re:test(); browsers and Selenium do not.
Numeric and Boolean Functions
| Function | Does |
|---|---|
count(node-set) | Number of nodes |
position() | Position of the context node |
last() | Size of the context node set |
number(s) | Converts to a number |
sum(node-set) | Sums the numeric values |
round(), floor(), ceiling() | As named |
not(expr) | Boolean negation |
boolean(expr) | Converts to boolean |
true(), false() | Literal booleans |
Comparison operators are =, !=, <, >, <=, >=, with and and or for combination. Note that in XML contexts < must be escaped as <, which is why you sometimes see expressions written with position() < 4.
A subtlety worth knowing: comparing a node set to a value is an existential test. //p = 'Price' is true if any paragraph equals "Price". That is often what you want inside a predicate and almost never what you want at the top level.
Patterns That Come Up Constantly
The expressions that do real work.
Match a class properly — plain contains(@class, 'btn') also matches btn-primary and unbtn:
//div[contains(concat(' ', normalize-space(@class), ' '), ' btn ')]
Find a value by its label — the most common extraction requirement there is:
//dt[normalize-space()='Price']/following-sibling::dd[1]
//th[normalize-space()='Weight']/following-sibling::td[1]
//td[preceding-sibling::td[1]='SKU']
Find a row by a cell, then take a different column:
//tr[td[normalize-space()='SKU-1234']]/td[3]
Find a container by something inside it:
//div[contains(@class,'card')][.//span[contains(., 'Sold out')]]
Find the first element after a heading:
//h2[normalize-space()='Specifications']/following-sibling::table[1]
Case-insensitive text match:
//p[contains(translate(., 'ABCDEFGHIJKLMNOPQRSTUVWXYZ',
'abcdefghijklmnopqrstuvwxyz'), 'price')]
Exclude rather than include — often clearer:
//tr[not(contains(@class,'header'))]
//li[not(contains(normalize-space(),'Advertisement'))]
Match either of two element types:
//*[self::button or self::a][normalize-space()='Continue']
Skip empty values:
//td[string-length(normalize-space()) > 0]
Extract an attribute from a matched element:
//a[normalize-space()='Download']/@href
The Traps
Ranked by how often they cost people time.
Node-set to string conversion takes only the first node. contains(//p, 'Price') converts the whole //p node set to a string by taking the first paragraph and ignoring the rest. Apply the predicate per node instead: //p[contains(., 'Price')]. This is the single most common XPath bug.
. and text() are different. The string value of an element is the concatenation of all its descendant text nodes; text() returns only direct text-node children, and string conversion takes the first of those. Use . unless you specifically want to exclude nested content.
Reverse-axis numbering. preceding-sibling::td[1] is the nearest, not the first.
Whitespace. //td[.='In stock'] fails against pretty-printed HTML. normalize-space() fixes it.
Case sensitivity. Everything in XPath 1.0 is case-sensitive, including element names in XML documents.
Default namespaces. In XML with a default namespace, //item matches nothing — you must register a prefix and use it. HTML parsers usually spare you this; XML parsers do not.
Absolute paths from browser dev tools. They encode the exact structure at one moment in time and break on any change.
Over-matching with contains(). Matching "Price" also matches "Historic Price". Use normalize-space() = 'Price' when you mean equality.
Injecting untrusted strings. Interpolating user input into an expression is XPath injection. Use variable binding where your library offers it. XPath 1.0 has no escape for a quote inside a string literal, so a value containing both quote characters requires concat().
XPath 1.0 Versus Later Versions
Worth knowing because a snippet you find online may not work where you need it.
XPath 1.0 is what browsers implement through document.evaluate, what Selenium uses, and what lxml's common API provides. It has the functions listed above and no more.
XPath 2.0 and 3.1 add regular expressions (matches(), replace()), case functions (upper-case(), lower-case()), ends-with(), sequence types, for expressions and much else. They are available in XSLT 2.0+ processors and some XML tooling, and are not available in browsers.
The practical rule: if an expression uses a function not in the tables above, check whether your environment supports it before debugging why it fails. "Works in an online XPath tester and not in Selenium" is almost always this.
For the gap XPath 1.0 leaves, the answer is usually your host language. Extract with XPath, then apply a regular expression in Python or JavaScript where you can also inspect what matched.
Writing Selectors That Survive a Redesign
A cheat sheet tells you what is possible; this section is about which of those options to choose, because the difference between a selector that lasts a year and one that breaks next Tuesday is entirely a matter of what you anchor on.
Anchor on meaning, not on position. (//table)[3]/tr[2]/td[4] encodes the exact shape of the page at one moment. Someone adds a table above, and every number is wrong — silently, because the expression still matches something. //th[normalize-space()='Weight']/following-sibling::td[1] encodes a relationship that survives reordering, because the label moves with the value.
Prefer stable attributes over generated ones. Ids and data-* attributes chosen by a developer are far more durable than class names, which change whenever someone touches the styling. Many modern front-end frameworks generate hashed class names — css-1x9dj2k — that change on every build; anchoring on those guarantees breakage.
Check for embedded structured data before writing any selector. A great many pages carry JSON-LD in a <script type="application/ld+json"> block, because it drives search features. Parsing that is dramatically more stable than parsing rendered HTML, since it is designed to be machine-read and survives visual redesigns entirely. Thirty seconds checking for it can replace an afternoon of selector maintenance.
Assert the shape of what you extract. This is the habit that separates a pipeline that fails loudly from one that fails silently. If a price should match a currency pattern, check it. If a category page has never had fewer than twenty items, make fewer than twenty an error rather than a result. A selector that starts matching the wrong element produces plausible, well-formed, wrong data — and no exception is raised at any point.
Keep selectors in one place. Scattered through a codebase, forty XPath strings are forty separate maintenance liabilities. Collected in a single module with names, they are a map of what you depend on, and updating after a redesign is an hour rather than a day.
And test against saved HTML. Storing a copy of each page you parse means that when a selector breaks you can compare the old markup against the new and see exactly what changed. Re-fetching to debug is slower, costs bandwidth, and may give you a different page than the one that failed.
When to Use CSS Instead
XPath is more powerful and less readable. CSS is the right default for a large share of selection work.
Use CSS when: you are selecting by class, id, attribute or descendant relationship. div.card > p.price is clearer than the XPath equivalent, better supported by tooling, and generally faster.
Use XPath when: you need to match on text content, which CSS cannot do at all; you need to navigate to a parent or ancestor; you need positional logic relative to siblings in a way :nth-child cannot express; or you are querying XML rather than HTML.
Note that CSS has closed part of the gap. :has() provides sibling- and descendant-conditional selection in modern browsers, so dt:has(+ dd) is now expressible. What CSS still cannot do is select by text, and text matching is precisely what the label-value pattern requires.
Mixing both in one codebase is fine and sensible: CSS for the straightforward 90%, XPath for the hard 10%.
People Also Ask
What is XPath used for?
Navigating and selecting nodes in XML and HTML documents. In practice it is used for web scraping, browser test automation, and querying XML configuration and data files. It expresses relationships — parents, siblings, text content — that CSS selectors cannot.
What is the difference between / and // in XPath?
A single slash selects direct children; a double slash selects descendants at any depth. //div/p matches paragraphs whose immediate parent is a div, while //div//p matches paragraphs anywhere inside a div.
Why does my XPath contains() return nothing?
Most often because you passed it a node set. XPath converts a node set to a string by taking the first node in document order and discarding the rest, so contains(//p, 'x') only ever inspects the first paragraph. Write //p[contains(., 'x')] instead.
How do I select by class in XPath?
Use //div[contains(concat(' ', normalize-space(@class), ' '), ' name ')], which pads the attribute so only whole tokens match. A plain contains(@class, 'btn') also matches btn-primary. If you are selecting only on classes, a CSS selector is clearer and correct by default.
Does XPath support regular expressions?
Not in XPath 1.0, which is what browsers and Selenium implement. XPath 2.0 and later add matches() and replace(), and server-side libraries such as lxml support EXSLT's re:test(). For browser automation, extract with XPath and apply the regex in your host language.
What is the difference between //div[1] and (//div)[1]?
//div[1] applies the predicate per parent, selecting the first div under every parent that has one — possibly many nodes. (//div)[1] collects all divs in document order and takes the first, which is exactly one node.
Is XPath case-sensitive?
Yes, throughout — element names, attribute names, and string comparisons. XPath 1.0 has no lower-case() function, so case-insensitive matching requires translate() with explicit uppercase and lowercase alphabets.
Should I use XPath or CSS selectors?
CSS for classes, ids, attributes and descendant relationships — it is clearer and better supported. XPath when you need to match on text content, navigate to ancestors, or express positional logic CSS cannot. Using both in one codebase is normal.
Wrapping Up
The useful core of XPath is small: // to search anywhere, predicates in square brackets to filter, @ for attributes, a handful of string functions, and the sibling axes for navigating relative to something you can identify.
Three habits prevent most of the pain. Wrap text comparisons in normalize-space(), because real HTML is pretty-printed and an exact match will fail. Apply contains() inside a predicate so it evaluates per node, since string conversion of a node set silently takes only the first. And prefer anchoring on text or identifiers over anchoring on position, because structure changes and text usually does not.
Then remember what version you are writing for. Browsers and Selenium give you XPath 1.0 — no regular expressions, no lower-case(), no ends-with() — and an expression that works in an online tester may use none of those and still fail for a reason further down the list. When you need what 1.0 lacks, extract with XPath and do the rest in your host language.
