Our stake, stated plainly: we are Geonode and we sell proxies to people who scrape, so XPath is adjacent to our business. It is worth saying that a broken selector is never a proxy problem. If your extraction stopped working, the site changed its markup, and no amount of bandwidth or address rotation addresses that. The two get confused because both manifest as "my scraper stopped returning data" — but a proxy failure produces blocked responses and error pages, while a selector failure produces successful responses that yield nothing. Check the raw HTML before checking anything else. If the page is there and your XPath returns an empty node set, this article is relevant and your proxy is fine.
What preceding-sibling Actually Selects
The XPath 1.0 specification defines it in one line: the preceding-sibling axis "contains all the preceding siblings of the context node; if the context node is an attribute node or namespace node, the preceding-sibling axis is empty".
Two words carry the meaning. Siblings means nodes sharing the same parent — not cousins, not ancestors, nothing at a different depth. Preceding means earlier in document order.
<div>
<p>First</p>
<p>Second</p>
<span id="here">Context</span>
<p>Third</p>
</div>
With the span as context node:
preceding-sibling::p → First, Second
following-sibling::p → Third
preceding-sibling::* → both p elements
The attribute-node caveat is worth remembering because it explains a class of empty results. If you have navigated to an attribute — //@class — then preceding-sibling from there is empty by definition, regardless of what surrounds the element the attribute belongs to. Attributes are not siblings of anything.
The Reverse Axis Trap: [1] Does Not Mean First
This is the single most common source of wrong results, and the specification explains exactly why.
XPath classifies ancestor, ancestor-or-self, preceding and preceding-sibling as reverse axes. For reverse axes, proximity position is determined by ordering nodes in reverse document order.
So on preceding-sibling, position 1 is the nearest preceding sibling — the one immediately before the context node — not the first one in the document.
<div>
<p>Alpha</p>
<p>Beta</p>
<p>Gamma</p>
<span id="here">Context</span>
</div>
preceding-sibling::p[1] → Gamma (nearest)
preceding-sibling::p[2] → Beta
preceding-sibling::p[3] → Alpha (furthest)
(preceding-sibling::p)[1] → Alpha (first in document order)
The parentheses change everything. Without them, [1] is a predicate applied along the reverse axis and means "nearest". With them, the axis result is first collected into a node set in document order and [1] indexes into that, meaning "first".
Compare against following-sibling, which is a forward axis where the two coincide:
following-sibling::p[1] → the next one
(following-sibling::p)[1] → also the next one
That asymmetry is why people who learned on following-sibling get caught by preceding-sibling. The habit transfers and the semantics do not.
In practice, unparenthesised is almost always what you want. "The label immediately before this value" is the usual requirement, and that is preceding-sibling::label[1]. Reach for parentheses only when you genuinely mean "the first one in the document", which is a rarer need than it sounds.
preceding-sibling Versus preceding
Two different axes with confusingly similar names, and the difference matters.
The spec defines preceding as containing "all nodes in the same document as the context node that are before the context node in document order, excluding any ancestors and excluding attribute nodes and namespace nodes".
So preceding is everything earlier in the document at any depth, minus ancestors. preceding-sibling is only the ones sharing your parent.
<body>
<header><h1>Title</h1></header>
<div>
<p>One</p>
<span id="here">Context</span>
</div>
</body>
From the span:
preceding-sibling::* → the p only
preceding::* → the p, the h1, and the header
The ancestor exclusion is the part that surprises people. The div containing the span is not in preceding, even though its opening tag appears earlier in the source. Ancestors are excluded by definition, because the axis is about what came before you, not what contains you.
When to use which. preceding-sibling for structured relationships — a label and its value, a heading and the paragraph below, cells in a row. preceding for genuinely loose relationships, such as "the nearest heading anywhere above this element regardless of nesting". preceding is broader, considerably slower, and much more likely to match something you did not intend.
The Label-Value Pattern
This is the reason preceding-sibling exists in scraping work, and it is worth building up properly because most real markup is some variant of it.
The problem: you want a value that is only identifiable by the label sitting next to it. The value itself has no useful class, no id, nothing distinguishing.
Definition lists:
<dl>
<dt>Price</dt>
<dd>£42.00</dd>
<dt>Stock</dt>
<dd>In stock</dd>
</dl>
Selecting the price means finding the dd whose nearest preceding dt says "Price":
//dd[preceding-sibling::dt[1] = 'Price']
Read that inside out: for each dd, take its nearest preceding dt; keep the dd if that text is "Price". Note that the [1] is essential. Without it, preceding-sibling::dt = 'Price' is true if any preceding dt matches, so the second dd would also qualify. That is a real and frequent bug.
Table cells:
<tr>
<td>SKU</td>
<td>ABC-123</td>
</tr>
//td[preceding-sibling::td[1] = 'SKU']
Or from the header column across a row of a two-column specification table:
//th[normalize-space() = 'Weight']/following-sibling::td[1]
That second form is usually preferable when the label is a th, because it reads forwards and matches how the table is structured.
Robustness improvements that make these survive real markup:
//dd[preceding-sibling::dt[1][normalize-space() = 'Price']]
normalize-space() collapses internal whitespace and trims the ends, which handles the pretty-printed HTML that would otherwise break an exact string comparison. It is the single highest-value function in scraping XPath.
For partial matches, contains() is more forgiving and correspondingly less precise:
//dd[preceding-sibling::dt[1][contains(., 'Price')]]
Be careful: contains(., 'Price') also matches "Price excluding VAT" and "Historic Price". If the page has several such labels, you will get several results and your code will take the first one silently.
Headings and following content, which is the same pattern in the other direction:
//h2[normalize-space() = 'Specifications']/following-sibling::table[1]
The first table after that heading. This is a genuinely common requirement on documentation and product pages, and there is no CSS equivalent that expresses "the first table after this specific heading".
Combining With Predicates and Conditions
preceding-sibling composes with the rest of XPath, and a few combinations are worth knowing.
Counting siblings — useful for finding the first or last item, or checking position:
//li[count(preceding-sibling::li) = 0] first li
//li[count(preceding-sibling::li) < 3] first three
Testing existence — a node set in a boolean context is true when non-empty:
//p[preceding-sibling::h2] paragraphs with an h2 somewhere before
//p[not(preceding-sibling::p)] first paragraph among its siblings
Chaining axes:
//span[@class='value']/preceding-sibling::*[1]/text()
The immediately preceding element regardless of tag, and its text.
Multiple conditions:
//td[preceding-sibling::td[1] = 'Status'][normalize-space() != '']
Two predicates applied in sequence: the cell after a "Status" label, and non-empty.
A note on the .. shortcut, which is often cleaner than an axis:
//dt[.='Price']/following-sibling::dd[1]
is usually more readable than the preceding-sibling form for the same result, and reads in the direction the document is written. Prefer it where the anchor is the label.
Where CSS Selectors Can and Cannot Replace It
The received wisdom that "CSS cannot look backwards" needs updating.
CSS can now do sibling conditions with :has(). Widely supported in modern browsers, it lets a selector be conditional on a sibling relationship:
dt:has(+ dd) a dt immediately followed by a dd
li:has(~ li.active) an li with a later sibling that is active
What CSS still cannot do:
Select by text content. There is no CSS equivalent of [text() = 'Price']. This alone is why the label-value pattern remains XPath territory, because label matching is text matching.
Navigate to an arbitrary ancestor. :has() provides a form of parent selection, but there is no general ancestor axis.
Index along a reverse axis. No CSS construct means "the nearest preceding element of this type".
And what CSS does better: everything simple. Class and id selection, descendant relationships, attribute matching. CSS selectors are more readable, better supported by tooling, and faster in most engines.
The sensible policy is to use CSS by default and reach for XPath at the specific point where you need text matching or backwards navigation. Mixing them in one codebase is fine, and a scraper that uses CSS for 90% of its selectors and XPath for the hard 10% is easier to maintain than one committed to either.
Performance and Brittleness
Two practical constraints.
Performance. preceding-sibling is bounded by the number of siblings, which is usually small — that is fine. preceding scans everything before the context node in the document, which on a large page is expensive, and inside a loop it is quadratic. If a selector using preceding is slow, that is almost certainly why. Rewriting it in terms of preceding-sibling from a closer context node is the usual fix.
Brittleness. Sibling-based selectors depend on document structure, which is exactly what a redesign changes. preceding-sibling::td[1] breaks silently the day someone inserts a column. There is no error; the selector matches a different cell and your data is quietly wrong.
Three mitigations that actually help:
Anchor on text rather than position where you can. //dt[.='Price']/following-sibling::dd[1] survives a reordering of the list. (//dd)[3] does not.
Assert what you extract. If a price should match a currency pattern, check it. A selector that starts returning the stock status instead of the price is invisible unless something validates the shape.
Prefer embedded structured data when it exists. If the page carries JSON-LD in a <script type="application/ld+json"> block, parse that instead. It is designed to be machine-read, it is far more stable across redesigns, and it removes the entire category of selector fragility. Checking for it before writing any XPath is worth thirty seconds.
Silent structural failures are the same class of problem we described in why testing proxies matters — the request succeeds, the parse succeeds, and the data is wrong.
When Not to Use It
When the element has a usable identifier. If there is an id, a class, or a data attribute, use it. A selector that depends on structure is strictly more fragile than one that depends on a name the developer chose deliberately.
When structured data is available. JSON-LD, microdata, a JSON payload behind an XHR. Any of these beats parsing rendered HTML.
When the relationship is genuinely loose. If you find yourself writing preceding::*[5], the structure is not really telling you anything and the selector will break on the next deploy. Reconsider the approach.
When CSS suffices. For simple selection, CSS is more readable and better supported. Reserve XPath for text matching and backwards navigation.
When you are relying on XPath 2.0 features in a browser. Browsers implement XPath 1.0 via document.evaluate, and Selenium follows the browser. So no matches(), no regular expressions, no upper-case(), no sequence types. Server-side libraries such as lxml are also XPath 1.0 for the common API. If a snippet you found online does not work, check whether it uses a function that only exists in a later version.
People Also Ask
What does preceding-sibling do in XPath?
It selects all nodes that share the same parent as the context node and appear before it in document order. It does not include ancestors, descendants or nodes at other levels of the tree — only siblings. It is empty if the context node is an attribute or namespace node.
Why does preceding-sibling[1] give the wrong element?
Because preceding-sibling is a reverse axis, and position numbering on reverse axes runs in reverse document order. [1] therefore means the nearest preceding sibling, not the first one in the document. For the first in document order, wrap the axis in parentheses: (preceding-sibling::p)[1].
What is the difference between preceding and preceding-sibling?
preceding-sibling covers only nodes with the same parent. preceding covers every node earlier in the document at any depth, excluding ancestors and attribute nodes. preceding is far broader, considerably slower, and much more likely to match something unintended.
How do I select a value based on its label in XPath?
Match the label by text and then take the adjacent element: //dt[normalize-space()='Price']/following-sibling::dd[1], or from the value side, //dd[preceding-sibling::dt[1]='Price']. The [1] matters — without it the predicate is true if any preceding label matches.
Can CSS selectors do what preceding-sibling does?
Partly. :has() provides sibling-conditional selection in modern browsers. What CSS still cannot do is select by text content or index along a reverse axis, and text matching is precisely what the label-value pattern requires. Use CSS for simple selection and XPath where you need text or backwards navigation.
Does preceding-sibling work in Selenium and browsers?
Yes. Browsers implement XPath 1.0 through document.evaluate, and Selenium uses the browser's engine. The axis is XPath 1.0 and universally available. What is not available is anything from XPath 2.0 or later — no regular expressions, no matches(), no upper-case().
Is preceding-sibling slow?
Not usually. It is bounded by the number of siblings, which is typically small. The preceding axis is the slow one, because it scans everything earlier in the document, and inside a loop that becomes quadratic. If a sibling-based selector is slow, check whether you actually used preceding.
How do I make XPath selectors less fragile?
Anchor on text rather than position where possible, use normalize-space() to survive whitespace differences, prefer identifiers and data attributes over structure, check for embedded JSON-LD before parsing HTML at all, and validate the shape of what you extract so that a selector matching the wrong element fails loudly rather than quietly.
Wrapping Up
preceding-sibling is a narrow tool with one job: reaching backwards among nodes at the same level. The thing to hold onto is that it is a reverse axis, so [1] means nearest rather than first, and adding parentheses flips that meaning entirely. That single distinction accounts for most of the wrong results people get from it.
Its real value is the label-value pattern — extracting a field that is only identifiable by the text sitting next to it. CSS has closed some of the gap with :has(), but it still cannot select by text content, and text is exactly what a label is. That is the case where XPath remains the right tool rather than the habitual one.
The caution worth carrying is that structural selectors break silently. A new column, a reordered list, a wrapper div, and your selector matches something else while everything continues to look fine. Anchor on text where you can, check for embedded structured data before writing any selector at all, and validate what comes out — because the failure you can see is never the expensive one.
