The selector language that came back from 1999
The scraping code you inherited has one line that does all the real work: //div[@class='result']/table/tr/td[3]. Nobody left a comment. The API you're integrating against returns XML, not JSON, and the integration guide says "the value is at /response/shipment[status='delayed']/eta" with no further explanation. A vendor's product feed arrives as 40 megabytes of nested elements and you need exactly three of them. All of these are XPath moments, and all of them share the same problem: you cannot write an XPath expression down confidently without running it against real data.
XPath is older than most of the people using it — the W3C published version 1.0 in November 1999, back when XML was going to be the format for everything. The prediction was wrong in general and right in specific: JSON won the APIs, but XML never left the places it had already conquered. Enterprise config files, SOAP endpoints, office document formats, RSS and Atom feeds, SVG, sitemaps, DocBook, HL7 medical records, the publishing industry's entire backbone. If you touch any of those, you touch XPath.
This tester is the missing REPL. Paste or load your XML, type the expression, and see exactly which nodes match — highlighted, counted, and drillable — or see the scalar result if your expression computes one. It runs on the XPath engine already installed in your browser, so the behavior you verify here is the behavior your production code will get.
What the tester gives you
The page is two panels and an input bar, deliberately spare. The document panel takes XML three ways: pasted text, a loaded file, or one of the built-in samples (an orders file, an XHTML fragment with namespaces, an Atom feed). The expression bar evaluates as you type — every keystroke, no run button needed — and the results panel reacts in three shapes depending on what your expression returns.
A node-set result highlights the matched nodes inside the document view and lists them underneath with full paths, so //order[@status='shipped'] doesn't just say "2 nodes" but shows you which two. A scalar result — count(//order), sum(//total), a string pulled by substring() — displays as a value. A boolean, typically the output of a comparison used inside a larger predicate but perfectly testable alone, shows as true or false. Getting all three shapes back matters more than it sounds: half of debugging an XPath is discovering that what you thought was a node-set is actually a number.
When an expression fails, the error message comes straight from the browser engine with its position, which is worth its weight: a malformed predicate like //lineqty > fails with a syntax complaint pointing at the spot, instead of the silent nothing that a naive regex-based tester would hand you. There's also a cheat sheet of axes and functions docked beside the editor, and a namespace-aware mode for documents whose elements carry prefixes. It's the same engine concept as our [JSON Path Tester, aimed at the XML side of the fence.
Evaluating an expression in four moves
Put a document in
Paste XML, drop a file, or click a sample. The document parses immediately — if it isn't well-formed XML, you hear about that first, before XPath enters the picture.
Type the expression
Evaluation is live. As you type, the match count updates; finish the expression and the highlighting settles on whatever it selects.
Inspect the matches
Click any result in the list to jump to it in the document view, where it stays highlighted with its full path shown. Matched nodes are numbered in document order.
Refine or export
Tighten the predicate, add a position filter, switch the context node if you're working relative to a section — then copy the verified expression into your scraper, stylesheet, or test.
A habit worth forming: start with the broadest expression that returns anything — //order — then narrow one predicate at a time until the match count is what your business logic says it should be. The tester's instant feedback makes that a thirty-second loop. Writing the final expression in one shot, by contrast, is how //div[2]/span[contains(text(),'Total')] ends up selecting a currency label on Tuesdays and nothing at all on weekends.
The grammar, in the order it actually composes
Every location path is a sequence of steps, and every step has three parts: an axis, a node test, and any number of predicates. Written out longhand it's child::order[@status='shipped'][2] — the child axis, the order node test, two predicates. Abbreviated, which is how everyone actually writes it, the child axis vanishes (order), attributes get @ (@id), parent gets .., self gets ., and descendant-or-self gets //. The abbreviations are why XPath looks like file paths with superpowers.
The node test is the coarse filter — a name like order, a wildcard like * for any element, or node() for any node at all including text and comments. Predicates are the fine filter, evaluated as boolean expressions per candidate node, left to right: [@status='shipped'] keeps matching elements, [2] keeps the second, [@status='shipped'][2] means "of the shipped ones, the second" — note that order, because [2][@status='shipped'] means "of the second one, keep it if shipped," a subtly different and usually accidental question. Stacking predicates is the feature and the trap in one syntax.
Predicates can use the full expression language, which is where XPath stops being a path language and starts being a small functional one. Arithmetic (+, -, *, div, mod — division is div because / is taken), comparisons (=, !=, <, <=, >, >=), boolean glue (and, or, not()), and the core function library: position() and last() for ordering, count() and sum() over node-sets, contains() and starts-with() and substring() and normalize-space() and translate() for text surgery, concat() for assembly, string-length() for validation. The node-test wildcards and this function set together are 90% of production XPath.
// Production XPath, annotated:
//line[@qty > 10]/@sku
→ descendant-or-self of root → any 'line' element
with qty attribute > 10 → its sku attribute
//order[customer = 'Meridian Foods' and total > 500]/@id
→ orders for one customer whose total clears 500 → their ids
//line[not(@qty)]
→ every line element missing a qty attribute entirely — data QAThe thirteen axes, ranked by how often you'll use them
Axes are the directions a step can travel. Child is the default and covers most walking; attribute covers @; the rest exist for the moments a path needs to move sideways or upward, which is exactly what CSS selectors historically couldn't do. There are thirteen in XPath 1.0, and nobody uses them uniformly — so here they are in rough order of real-world frequency.
| Axis | Selects | Example |
|---|---|---|
| child (default) | Element children of the context node | order/total |
| attribute (@) | Attributes of the context node | order/@id |
| descendant-or-self (//) | The node and everything below it | //total anywhere |
| self (.) | The context node itself | ./@status |
| parent (..) | The node one level up | total/.. — the owning order |
| descendant | Everything below, excluding self | orders/descendant::total |
| following-sibling | Siblings after the context node | customer/following-sibling::lines |
| preceding-sibling | Siblings before the context node | lines/preceding-sibling::customer |
| ancestor | All nodes above, up to the root | total/ancestor::order |
| ancestor-or-self | Same, including the context node | total/ancestor-or-self::* |
| following | Everything after in document order, excluding descendants | order[1]/following::order |
| preceding | Everything before in document order, excluding ancestors | order[3]/preceding::order |
| namespace | Namespace nodes of the context element | rare outside XSLT |
The parent and sibling axes deserve a small celebration, because they're the reason scrapers reach for XPath over CSS at all: from a price cell you can climb to its row, and from the row to the header that names the column. When the layout moves, the relationship survives, and your expression survives with it. The following and preceding axes are the ones to treat with care — they traverse the whole document outside your subtree, so on a large file they are the slowest steps available and occasionally a correctness hazard, since 'preceding' includes nodes the human eye groups with other sections.
Five expressions against one orders file
Here is the tester's built-in sample document, abbreviated — three orders, five line items, one shipped-status mix. Every result below was evaluated against this exact XML, and you can reproduce each one in the tester with one click.
<orders>
<order id="A-1001" status="shipped">
<customer>Meridian Foods</customer>
<lines>
<line sku="SKU-91" qty="12" unit="41.50"/>
<line sku="SKU-33" qty="4" unit="129.00"/>
</lines>
<total>1014.00</total>
</order>
<order id="A-1002" status="open">
<customer>Halcyon Print</customer>
<lines>
<line sku="SKU-91" qty="2" unit="41.50"/>
</lines>
<total>83.00</total>
</order>
<order id="A-1003" status="shipped">
<customer>Meridian Foods</customer>
<lines>
<line sku="SKU-17" qty="30" unit="8.25"/>
</lines>
<total>247.50</total>
</order>
</orders>Input
count(//order) //order[@status='shipped']/@id //line[@qty > 10]/@sku
Output
count(//order) → 3 (a number result) //order[@status='shipped']/@id → 2 nodes: A-1001, A-1003 //line[@qty > 10]/@sku → 2 nodes: SKU-91 (qty 12), SKU-17 (qty 30) Note that @qty > 10 is a numeric comparison on the attribute's value — the tester resolves attribute strings to numbers the same way the spec's number() conversion does.
Input
sum(//order[@status='shipped']/total) //order[customer = 'Meridian Foods' and total > 500]/@id //line[position() = 2]/@sku
Output
sum(//order[@status='shipped']/total) → 1261.50 (1014.00 + 247.50 — the two shipped orders; the open one is excluded before sum()) //order[customer = 'Meridian Foods' and total > 500]/@id → A-1001 only (A-1003 is Meridian Foods but totals 247.50) //line[position() = 2]/@sku → SKU-33 (only order A-1001 has a second line element; position() counts siblings, not document-wide)
Input
//customer[contains(., 'meridian')] //customer[contains(translate(., 'ABCDEFGHIJKLMNOPQRSTUVWXYZ', 'abcdefghijklmnopqrstuvwxyz'), 'meridian')]
Output
First expression → 0 nodes. contains() is case-sensitive in XPath 1.0, and the element's text is 'Meridian Foods' with a capital M. Second expression → 1 node: <customer>Meridian Foods</customer> The translate() call folds the element text to lowercase before the comparison. This is the canonical XPath 1.0 idiom, and it works on attribute values identically.
The last example is the one to memorize, because case-sensitive contains() bites everyone once. The fold-the-case-with-translate trick predates Google and will outlive us all, at least until browsers decide to implement an XPath version newer than 1.0 — a decision they have now declined for over two decades.
Namespace pain and other well-known traps
- Namespaces, the big one. If the root declares xmlns="http://www.w3.org/1999/xhtml" (or any default namespace), every unprefixed element name in your XPath matches nothing, because in XPath 1.0 an unprefixed name matches only elements in no namespace. The fix is a namespace resolver mapping a prefix of your choice to that URI, then x:html/x:body. The tester's namespace mode lists declared namespaces and wires the resolver for you.
- Text nodes versus string values. /order/customer returns the customer element; /order/customer/text() returns its text node. For comparisons it rarely matters — string() of an element includes descendant text — but node-identity and count() care a lot, and text() breaks silently when a value is split across multiple text nodes by markup.
- The double-slash performance tax. // scans the entire subtree from the document root every time it appears. On a 40 MB product feed, //sku inside a loop is the difference between seconds and a coffee break. Anchor your paths (orders/order/lines) whenever the structure is stable enough to describe.
- Position is per-axis, per-step. line[2] means the second line child among its siblings, not the second match document-wide. To get the second match overall, filter the outer step: (//line)[2] — parentheses first, then the position predicate. Those parentheses are load-bearing.
- Numbers are IEEE double precision. Attribute comparisons like @qty > 10 do exact floating-point math — quantities stored as 41.50 are fine, but penny arithmetic on decimal values can wobble in the usual 0.1-plus-0.2 way. Don't build a billing engine out of XPath predicates.
- Comments are nodes. The node() wildcard and the comment() node test can both select XML comments, and a naive text extraction can accidentally include a commented-out block that a human "deleted" fifteen years ago. Ask for element names explicitly when it matters.
- CDATA is just text. What the document author wrapped in <![CDATA[...]]> arrives as an ordinary text node as far as XPath is concerned — invisible in the syntax, occasionally surprising when a "different-looking" value compares equal to a plain-text one.
Who still lives in XPath
Integration engineers, mostly — people whose week is a parade of other people's formats. The freight system speaks XML. The publisher's CMS speaks XML. The government data portal absolutely speaks XML, frequently XML from 2003 with a schema PDF to match. When the requirement is "pull fields X, Y, and Z out of this payload and map them into our system," the expression work happens here first, against a real sample, until the match count is boring.
Scrapers form the second camp, split between XPath and CSS partisans in a way that generates entertaining team arguments. XPath earns its following for the reasons above — upward traversal, attribute selection, functions, and compound predicates that CSS only answers with the newer :has() and attribute operators. Test automation leans the same way: Selenium and Playwright both accept XPath locators, and a locator verified in a tester before it's committed is a locator that doesn't fail at 2 a.m. in CI.
The third camp is quieter: XSLT maintainers (stylesheets are XPath with a rendering engine attached), technical writers in DocBook territory, anyone doing batch transformations with tools like xsltproc, and data people sanity-checking sitemap XML before search-console work. Their expressions are fewer and stranger, and they appreciate a namespace resolver more than anyone.
XPath versus CSS selectors
For scraping HTML, the eternal question: XPath or CSS? Both work. They differ in ways that matter once you leave the happy path.
| Capability | XPath 1.0 | CSS selectors |
|---|---|---|
| Select by id/class/attribute | Yes — [@id='x'], [@class~='x'] | Yes — #x, .x, [attr] |
| Text content matching | Yes — contains(., 'x'), starts-with() | No (attribute values only) |
| Move upward (parent, ancestor) | Yes — .., ancestor:: | Only via :has() (recent addition) |
| Select attributes as results | Yes — /@sku returns the attribute itself | No — matching elements only |
| Arithmetic and aggregation | Yes — sum(), count(), comparisons | No |
| Position among all matches | Yes — (//line)[3] | Yes — :nth-of-type, with different semantics |
| Readability for simple descendance | Verbose | Clearly better |
| Engine availability | document.evaluate in all modern browsers | querySelector, generally faster |
The practical split we see: CSS when the path is short, downward, and structural — article > h2, #results .row — because it reads cleanly and engines optimize it hard. XPath the moment you need text matching, a climb to a parent, attribute values as the result, or a compound condition across two attributes. The tester here plus the browser console's $x() make it cheap to prototype in XPath and only commit what survives contact with the page. And when the data is JSON rather than markup, neither applies — that's JSON Path Tester territory.
It runs on the engine your browser already ships
There is no XPath implementation on this page to trust, because the browser brought one. The tester calls document.evaluate — the standard DOM API, present in every modern browser since the mid-2000s — hands it your expression and your document, and renders whatever XPathResult comes back. What you test is not a reimplementation that approximates the spec; it is the same engine your scraper's browser session, your XSLT processor's cousin, and your production code will exercise. When a result surprises you here, it would surprise you in production identically.
The document you paste never crosses the network. Parsing, evaluation, highlighting: all local, all in-tab, gone when the tab closes. That's not just a privacy posture — it's what makes an offline XPath REPL possible on a train or inside a locked-down environment. Load the page once and the network panel stays quiet thereafter; verify it yourself if you like.
XPath questions, fielded
Which XPath version does this tester use?
XPath 1.0 — the version web browsers implement through document.evaluate. That means full support for location paths, all thirteen axes, predicates, and the core function library, but none of the XPath 2.0/3.1 additions like ends-with(), lower-case(), for-expressions, or regular-expression matching. For scraping and config work, 1.0 remains the dialect that actually runs everywhere.
What is the difference between a single slash and a double slash?
A single slash steps through the hierarchy strictly: /orders/order/total walks the exact path from the root. A double slash is shorthand for descendant-or-self::node()/ — it selects at any depth below the context node, so //total finds every total element no matter how deeply nested. The single slash is precise and fast; the double slash is convenient and, on large documents, measurably slower.
How do I make contains() case-insensitive?
XPath 1.0 has no lower-case() function, so the standard workaround folds the case yourself with translate(): contains(translate(., 'ABCDEFGHIJKLMNOPQRSTUVWXYZ', 'abcdefghijklmnopqrstuvwxyz'), 'searchterm'). Ugly, reliable, and the answer you will find in every Stack Overflow thread on the subject. The tester's cheat sheet ships this pattern ready to paste.
Why does my expression match nothing on an XHTML page?
Namespaces. XHTML documents place every element in the http://www.w3.org/1999/xhtml namespace, and in XPath 1.0 an unprefixed name matches only elements in no namespace at all — so /html finds nothing. You need a namespace resolver that maps your chosen prefix to the XHTML URI and expressions like x:html/body. This tester has a namespace-aware mode and shows the document's declared namespaces so you can see what you are up against.
Is the first node in a predicate [1] or [0]?
One. XPath predicates are one-indexed, unlike nearly every programming language you use. //line[1] is the first line element among its siblings, //line[last()] is the final one, and //line[position() > 1] skips the first. Half of all beginner XPath bugs are an off-by-one from this single fact.
Can I test expressions against a live web page instead of pasted XML?
The tester works on the document you paste or load into it, so it is built for XML payloads, config files, sitemaps, and scraped fragments. The same expressions are usable in a live page context through the browser console — Chrome and Firefox both expose an $x() helper in devtools — and in Selenium, Playwright, and most scraping libraries, so what you verify here transfers directly.
How is XPath different from JSONPath?
Same idea, different data model. XPath navigates XML's tree of elements, attributes, and text nodes with axes and a function library; JSONPath navigates JSON's objects and arrays with a dot-and-bracket syntax. The mental model of 'describe the path, get the nodes' carries over, but the syntaxes share almost nothing. If your data is JSON, use the JSON Path Tester instead of forcing XPath onto it.
Is my XML sent to a server?
No. The expression runs against your document using the browser's own XPath engine, entirely inside the tab. Nothing is transmitted, stored, or logged — which is the point of building on document.evaluate rather than shipping the document off to a service.
Pair it with these
The usual workflow around here: format and validate the XML first with XML Formatter & Validator, prototype the expression here, then either convert the result set with XML to JSON for downstream tooling or hand the verified expression to your scraper. If your selector problem lives in JSON instead, JSON Path Tester is the mirror-image tool. And when the extraction job is really a reformatting job, Regex Tester covers the flat-text cases XPath was never meant to touch.
Specs and documentation
Sources and further reading
- MDN Web Docs, Document.evaluate()(developer.mozilla.org)
- MDN Web Docs, XPath — the guide to the abbreviated syntax and functions(developer.mozilla.org)
- W3C, XML Path Language (XPath) Version 1.0, 16 November 1999(w3.org)
- W3C, XML Path Language (XPath) 3.1 — for the version differences(w3.org)
- Wikipedia, XPath(en.wikipedia.org)
We cite primary sources (standards bodies, government agencies, official documentation) wherever they exist so you can verify every number on this page yourself.