Text Handling intermediate text gotchas

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>
ExpressionResult
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 wantUse
Match visible label, any nestingnormalize-space()='Label' or contains(., 'Label')
Match an element’s own text, not childrennormalize-space(text())='Label'
Any of several text nodes contains xtext()[contains(., 'x')]
Select the text node itself (scraping)//p/text()
Exact including whitespacetext()='...' (rare)

Try It Yourself

Open in Playground →

Swap . for text() and see whether the count changes on that sample.

Next Steps

  1. Whitespace and normalize-space() - What the browser does to the spaces in your markup
  2. Case-Insensitive Matching - translate() idioms
  3. Text Matching Functions - The full toolkit