Layerbit
XML & Data Extraction

XPath Cheat Sheet: Selecting Nodes, Predicates and the Namespace Trap

XPath is the query language you already have — in every browser, every XML library, Selenium, Scrapy and most ETL tools — and most developers know just enough of it to be surprised when //entry returns nothing. This is the reference we wished existed: the syntax that covers 95% of real queries, the semantics that cause the other 5% of bugs, and the namespace problem explained once and for all.

The short version

  • /a/b is a child step; //b is "anywhere below". @x is an attribute. […] filters.
  • //item[2] is the second item within each parent. (//item)[2] is the second item in the document. They differ constantly.
  • In XPath 1.0 an unprefixed name means no namespace. If the document has a default xmlns, every unprefixed query returns nothing. Bind a prefix, or use local-name().
  • Browsers, Python's lxml default, Java's javax.xml and most scrapers implement XPath 1.0. Functions from 2.0 and 3.1 will not work there.
  • An XPath expression returns one of four things: a node set, a string, a number or a boolean. Know which one you asked for.

Location paths

ExpressionSelects
/catalogThe root element, only if it is named catalog
/catalog/bookEvery book that is a direct child of the root catalog
//bookEvery book anywhere in the document
//book/titleThe title children of every book
//book//titleAny title at any depth below a book
//book/@idThe id attribute nodes of every book
//book/*All element children of every book
//book/text()Text nodes that are direct children of a book (usually whitespace)
//book/node()All child nodes: elements, text, comments
//title/..The parent of every title
//@*Every attribute in the document

Predicates: filtering a step

Square brackets after a step keep only the nodes for which the expression inside is true. The expression can be a comparison, a function call, a nested path (true if it selects at least one node), or a number, which is shorthand for a position test.

ExpressionSelects
//book[@lang='en']Books with a lang attribute equal to en
//book[@lang]Books that have a lang attribute at all
//book[price > 30]Books whose price child, as a number, exceeds 30
//book[title]Books that have at least one title child
//book[not(@lang)]Books with no lang attribute
//book[@lang='en' and price < 20]Both conditions
//book[1]The first book child of each parent
//book[last()]The last book child of each parent
(//book)[1]The first book in document order, overall
//book[position() <= 3]The first three books under each parent
//book[contains(title, 'XML')]Books whose title contains the text
//book[starts-with(@id, 'bk1')]Attribute prefix match
//book[normalize-space(title) = 'XPath']Exact match ignoring surrounding whitespace

The //book[2] versus (//book)[2] distinction is the one that produces "it returns three results when I asked for one". A predicate binds to the step it follows, so //book[2] means "for every node, its second book child". If three author elements each contain two books, you get three results. Wrapping the path in parentheses first collects all books into one set, and then the predicate picks the second.

Functions worth memorising

FunctionReturnsExample
count(ns)Number of nodescount(//book)
sum(ns)Sum of numeric valuessum(//book/price)
string(x)String value (text content, concatenated)string(//book[1]/title)
contains(a, b), starts-with(a, b)BooleanSubstring tests; case-sensitive
substring(s, start, len)String; positions start at 1substring('2026-09-27', 1, 4) → 2026
substring-before(s, sep), substring-after(s, sep)StringSplit on a delimiter once
normalize-space(s)Trimmed, inner whitespace collapsedUse before comparing text
translate(s, from, to)Character mappingtranslate(@x, 'ABC', 'abc') — the 1.0 way to lower-case
string-length(s)Number
local-name(n), name(n), namespace-uri(n)StringThe namespace escape hatch, below
position(), last()Number, within the current step
not(b), boolean(x), number(x)Conversions

Note what is not in the list: no matches() for regular expressions, no lower-case(), no string-join(), no for … return. Those arrived in XPath 2.0, and the engines most people actually use — browsers via document.evaluate, Python's lxml by default, Java's built-in javax.xml.xpath, Selenium, Scrapy — implement 1.0. Saxon and BaseX support 3.1; if you need it, check your engine before writing the query.

Try queries against your own XML

Layerbit's XML Formatter & XPath Tester runs XPath 1.0 through the browser's own engine, binds prefixes from the root element automatically, labels the result type, and shows each matched node with its path — and tells you outright when an unprefixed query has hit a default namespace.

Open XPath Tester

The namespace trap, explained once

Take an Atom feed:

<feed xmlns="http://www.w3.org/2005/Atom">
  <entry><title>Hello</title></entry>
</feed>

The xmlns on the root declares a default namespace: every unprefixed element in the document — feed, entry, title — belongs to http://www.w3.org/2005/Atom. Now query //entry. In XPath 1.0 an unprefixed name test matches elements with that local name and no namespace. There are none. The query returns an empty set, with no error, and nothing in the syntax hints at why.

Three ways out, in order of preference:

  1. Bind a prefix and use it. Tell the engine that a means http://www.w3.org/2005/Atom, then write //a:entry/a:title. The prefix you choose does not have to match anything in the document; only the URI matters. Every engine has a way to do this — a namespace resolver function in the browser, a namespaces= dict in lxml, a NamespaceContext in Java.
  2. Match on local name. //*[local-name()='entry'] ignores namespaces entirely. It is verbose, and it will also match an entry element from some other vocabulary, but it works when you cannot register a prefix.
  3. Match on both. //*[local-name()='entry' and namespace-uri()='http://www.w3.org/2005/Atom'] is precise without needing a binding.

The same rule applies to attributes, with one twist: unprefixed attributes are never in the default namespace. So //a:link/@href is correct even though a:link needs the prefix — an unprefixed @href is what the document actually contains.

Axes for when the path is not downward

AxisMeaningExample
parent::The parent (.. is shorthand)//title/parent::book
ancestor::All ancestors//title/ancestor::catalog
following-sibling::Later siblings//h2[@id='faq']/following-sibling::p[1]
preceding-sibling::Earlier siblings//price/preceding-sibling::title
descendant::What // abbreviates/catalog/descendant::title
self::The node itself; useful for type tests//*[self::h1 or self::h2]

Sibling axes are the workhorse for scraping HTML that has structure but no useful ids: "the paragraph after the heading that says Price" is //h3[normalize-space()='Price']/following-sibling::p[1].

XPath on HTML

Browsers expose document.evaluate() on HTML documents too, and Selenium and Scrapy use XPath on HTML constantly. Two differences from XML. HTML element names are case-insensitive but the DOM stores them in a fixed case, so match what the parser produces — lower-case in an HTML document, which browsers normalise for you. And class is a space-separated list, so [@class='card'] misses class="card featured"; the idiom is [contains(concat(' ', normalize-space(@class), ' '), ' card ')], which is exactly the kind of thing CSS selectors do better. Use CSS for class and id matching and XPath when you need text content, sibling navigation or a predicate CSS cannot express.

Four result types, one mental model

An XPath expression evaluates to a node set, a string, a number or a boolean, and confusion about which you have causes the last class of bug. //book/price is a node set; //book/price > 30 is a boolean that is true if any price exceeds 30; sum(//book/price) is a number; string(//book/price) is the first price as text. Ask yourself which you want before writing the expression, prefix your names when the document has a default namespace, and remember that a predicate binds to the step beside it. That is most of XPath, and all of the parts that go wrong.

Frequently asked questions

Almost always a default namespace. When the root element declares xmlns="…", every unprefixed element is in that namespace, and in XPath 1.0 an unprefixed name in a query matches only elements with no namespace. Bind a prefix to the namespace URI and use it in the query (//a:entry), or use //*[local-name()='entry'] to ignore namespaces.

A predicate applies to the step immediately before it. //item[1] selects the first item child of every parent, so it can return many nodes. (//item)[1] first gathers every item in the document into one set and then takes the first in document order, returning exactly one node.

Only in XPath 2.0 and later, which added matches(), replace() and tokenize(). Browsers, Python's lxml default engine, Java's built-in XPath and most scraping frameworks implement XPath 1.0, where the closest tools are contains(), starts-with(), substring() and translate(). For real pattern matching, extract the text with XPath and apply a regular expression in your language.

Yes. Browsers support document.evaluate() on HTML documents, and Selenium, Scrapy and similar tools use XPath on parsed HTML routinely. Element names are matched in the case the parser produced, which is lower-case in browsers, and the class attribute is a space-separated list, so test it with contains() on a padded value rather than equality.

XPath 1.0, through document.evaluate(). That includes all axes, predicates and the core function library, but not the sequences, regular-expression functions, or for-expressions introduced in XPath 2.0 and 3.1. A query using those fails with a syntax error rather than silently returning nothing.