The XPath data model: nodes, order and values

XPath does not see your file, it sees a tree of seven node types. Knowing what each node contains explains most surprising query results.

The seven node types

An XPath expression is evaluated against a tree, not against text. The tree is built by the parser and normalised: whitespace between tags becomes text nodes, attribute order is not significant, and every value you can select is one of exactly seven node kinds.

Node typeSelected byString-value
Root/Concatenated text of the whole document
Element//bookAll descendant text, tags stripped
Attribute//book/@isbnThe attribute value
Text//title/text()The characters themselves
Comment//comment()Comment body without the markers
Processing instruction//processing-instruction()Everything after the target name
Namespace//namespace::*The namespace URI

The consequence that trips people up: an element's string-value is the concatenation of all its descendant text, so string(//div) on a container returns everything inside, not the container's own words. Element nodes do not have a separate "own text" value.

Document order and the context node

# Every expression is evaluated with respect to a context node.
# Inside a predicate, position() counts siblings from that context.

xmllint --xpath '//ul/li[position() = 1]' page.html   # first li of each ul
xmllint --xpath '(//ul/li)[1]' page.html              # first li in the whole doc
xmllint --xpath '//ul/li[last()]' page.html           # last li of each ul
xmllint --xpath '//ul/li[position() < 3]' page.html   # first two of each ul
  • Node-sets are always in document order and are never duplicated, so //a | //a is just //a.
  • A leading / means the root node; a path with no leading slash is relative to the context node.
  • //x is shorthand for /descendant-or-self::node()/child::x, which is why it is cheap to write and expensive to run.
  • ancestor, preceding and following axes are in reverse document order as axes, but a node-set returned from them is still sorted into document order.

Values, conversions and comparison

from lxml import etree

doc = etree.fromstring("<r><n>10</n><n>9</n><s>abc</s></r>")

# string() collapses an element to text; number() converts it
print(doc.xpath("number(//n[1])"))      # 10.0
print(doc.xpath("//n[text() > '9']"))   # text comparison, not numeric

# Compare with a number to force numeric comparison: only the 10 survives
print(doc.xpath("//n[. > 9]"))          # [<Element n>]
print(doc.xpath("count(//n)"))          # 2.0
print(doc.xpath("boolean(//missing)"))  # False

XPath 1.0 has four data types: node-set, string, number and boolean. There is no separate integer, no date and no null. Predicate expressions convert their result to a boolean using these rules: an empty node-set is false, a non-empty one is true, a number is false only when it is zero or NaN, an empty string is false, and NaN compares false against everything including itself.

⚠️
In XPath 1.0, < and > compare as numbers when at least one side is a number, but as strings when both sides are node-sets. //n[. > 9] and //n[. > '9'] can therefore disagree, and '2' > '10' is true because it is a string comparison.

FAQ

Why does count(//p) return a decimal?
XPath 1.0 has only one numeric type, a double-precision float, so counts and indexes print with a decimal point in XPath 2.0 and later engines. XPath 1.0 engines usually print them as integers.
Does whitespace-only text count as a text node?
Yes, unless the parser is told to strip it. That is why indented markup can make text() predicates match blank strings and why strip-space matters in XSLT.

Location paths and axes Selecting attributes, text and special nodes

Last refreshed 2026-09-18.