Whitespace and normalize-space()
Why pretty-printed HTML breaks exact text matches, what normalize-space() does and does not fix, and how non-breaking spaces sneak into your locators.
Whitespace and normalize-space()
Developers format HTML for humans. Browsers keep every newline and indent as part of the text nodes. XPath sees what the browser keeps, not what you see rendered. That gap is the source of most “the text is right there, why doesn’t it match?” bugs.
What is actually in the text node
<button>
Sign In
</button>
The text node is "\n Sign In\n". Rendered, it looks like Sign In. To XPath, text()='Sign In' is false.
//button[text()='Sign In'] no match
//button[normalize-space(text())='Sign In'] match
//button[normalize-space()='Sign In'] match
//button[contains(text(), 'Sign In')] match, but see below
What normalize-space() does
- Strips leading whitespace.
- Strips trailing whitespace.
- Collapses every internal run of whitespace (spaces, tabs, newlines) to a single space.
" Sign In \n" becomes "Sign In"
With no argument it normalises the string value of the context node (all descendant text). With an argument it normalises that value only:
normalize-space() whole element
normalize-space(text()) first text node child only
normalize-space(@title) an attribute value
The default for label matching
Use normalize-space()='...' as your default exact-text locator. It matches what the user sees, tolerates formatting changes, and still rejects partial matches, so Save does not match Save and close.
//button[normalize-space()='Save']
//a[normalize-space()='Sign out']
//h2[normalize-space()='Order summary']
//td[normalize-space()='Shipped']
Compare with contains(), which is looser than you might want:
//button[contains(., 'Save')] also matches "Save and close", "Autosave", "Saved"
Whitespace that normalize-space() does not touch
XPath 1.0 defines whitespace as space, tab, carriage return and newline. Non-breaking spaces ( , U+00A0) are not whitespace to XPath.
<span>Total: $120</span>
normalize-space()='Total: $120' fails because the character between the colon and the dollar is U+00A0, not U+0020. Two fixes:
//span[normalize-space()='Total: $120'] if your language lets you write the character
//span[normalize-space(translate(., ' ', ' '))='Total: $120'] map nbsp to a space first
In Java or JavaScript source, write the character as inside the string. In Python, the same. Or use contains(., '$120') and sidestep the problem.
Other characters that look like spaces and are not: zero-width space (U+200B), thin space (U+2009), and the em space. If a locator that looks right fails, copy the text from DevTools into a hex viewer or run [...text].map(c => c.charCodeAt(0)) in the console.
Whitespace inside attributes
Attribute values keep their whitespace too, though it is rarer:
<button class="btn primary ">Go</button>
@class='btn primary' fails. contains(@class, 'primary') works, and the bulletproof class test is:
//button[contains(concat(' ', normalize-space(@class), ' '), ' primary ')]
This matches the whole class token and cannot accidentally match primary-outline.
Whitespace between elements
Whitespace between tags becomes text nodes as well. That matters for node() counts and for following-sibling::node()[1], which is often a whitespace text node rather than the element you expected. Use following-sibling::*[1] when you want the next element.
Try It Yourself
Open in Playground →
Remove the normalize-space() and use text()='Settings' to see whether the sample’s formatting breaks the match.
Next Steps
- Case-Insensitive Matching - Handling
Sign inversusSIGN IN - text() versus . (string value) - Nested elements and the first-node rule
- Common Errors and Fixes - Diagnosing “it’s right there but doesn’t match”