text() versus . (string value)
The single most misunderstood part of XPath text matching: why contains(text(), 'x') misses matches, when to use . instead, and how nested elements change the answer.
text() versus . (string value)
Two expressions look interchangeable and are not:
//button[contains(text(), 'Save')]
//button[contains(., 'Save')]
They agree on simple markup and disagree as soon as the text is split across elements. Knowing which one you want fixes a huge share of flaky text locators.
What text() selects
text() selects the direct text node children of the context element. Not grandchildren. Not the rendered text.
<button>
<svg>...</svg>
Save
</button>
The button has one text node: "\n Save\n" (with the whitespace). text()='Save' fails because of the whitespace. contains(text(), 'Save') works.
<button><span>Save</span></button>
Now the button has no text node children at all. The text belongs to the span. //button[contains(text(), 'Save')] matches nothing. //button[.//text()[contains(., 'Save')]] or simply //button[contains(., 'Save')] matches.
What . means
. is the context node. When a function expects a string, XPath takes the node’s string value, which for an element is all descendant text concatenated in document order.
<button>Save <b>all</b> changes</button>
| Expression | Result |
|---|---|
string(//button) | Save all changes |
//button/text() | two nodes: Save and changes |
//button[.='Save all changes'] | match |
//button[text()='Save all changes'] | no match |
//button[contains(., 'all')] | match |
//button[contains(text(), 'all')] | no match, “all” is inside the b |
The first-node rule
When text() returns several nodes and you pass it to a string function such as contains(), XPath 1.0 uses only the first node in document order. The rest are ignored.
<p>Status: <b>Failed</b> at 10:42</p>
//p[contains(text(), '10:42')] does not match. The first text node is Status: , and the function never looks at at 10:42. Either of these works:
//p[contains(., '10:42')]
//p[text()[contains(., '10:42')]]
The second form applies the predicate to each text node separately, so any of them can satisfy it.
The equality operator behaves differently from functions: text()='x' compares against every text node and is true if any equals x. That inconsistency is why [text()='Save'] sometimes works where [contains(text(), 'Save')] fails.
normalize-space() without an argument
normalize-space() with no argument uses the context node’s string value, so it behaves like . with trimming:
//button[normalize-space()='Save all changes']
//button[normalize-space(.)='Save all changes'] identical
//button[normalize-space(text())='Save'] first text node only, trimmed
For most “match the visible label” cases, normalize-space()='Label' is the right default. It ignores wrapping whitespace and collapses internal runs of spaces, which is exactly how the browser renders it.
When you really want text()
text() is the better choice when you need to distinguish an element’s own text from its children’s:
<li>Fruit<ul><li>Apple</li></ul></li>
//li[normalize-space()='Fruit'] fails because the outer li’s string value is FruitApple. //li[normalize-space(text())='Fruit'] matches only the outer one.
Decision table
| You want | Use |
|---|---|
| Match visible label, any nesting | normalize-space()='Label' or contains(., 'Label') |
| Match an element’s own text, not children | normalize-space(text())='Label' |
| Any of several text nodes contains x | text()[contains(., 'x')] |
| Select the text node itself (scraping) | //p/text() |
| Exact including whitespace | text()='...' (rare) |
Try It Yourself
Open in Playground →
Swap . for text() and see whether the count changes on that sample.
Next Steps
- Whitespace and normalize-space() - What the browser does to the spaces in your markup
- Case-Insensitive Matching -
translate()idioms - Text Matching Functions - The full toolkit