Use an XPath predicate that compares an element’s text: //*[normalize-space(.) = 'Save'] for an exact, whitespace-tolerant match, //*[contains(., 'Save')] for a substring, or //button[text()='Save'] when the literal text must be a direct text node. The right expression depends on whether text is nested, how strict the match should be, and which XPath engine runs it.
What “text” means in XPath
XPath is a language for addressing nodes in an XML or HTML document with paths and predicates. The W3C XPath 1.0 specification defines how expressions select nodes and convert them to strings (XPath 1.0). In a predicate, text() is a node test for text nodes; it does not mean “all text visibly rendered by this element.” A period (.) refers to the context node’s string value, which includes the text of descendant nodes according to XPath’s node/string model (see XPath 2.0).
That distinction matters when markup splits a label across child elements. For example:
<button>Save <strong>changes</strong></button>
//button[text()='Save changes'] may not match because no single direct text node contains the complete label. //button[normalize-space(.)='Save changes'] evaluates the button’s combined string value and is usually the safer exact locator.
Free tools Windows power users keep installed
One-click scans. No signup required.
#1 Best Overall
Core XPath patterns for selecting by text
Exact direct text node
//button[text()='Save']
This selects a button whose direct text node is exactly Save. It is strict: extra spaces, line breaks, or nested elements can prevent a match.
Exact text with normalized whitespace
//button[normalize-space(.)='Save changes']
normalize-space() trims leading and trailing whitespace and collapses runs of whitespace before comparing. Use it when the label is known but formatting varies.
Substring matching
//button[contains(., 'Save')]
contains() matches a substring anywhere in the element’s string value, including descendant text. Scope it with a tag, ancestor, or another predicate when several controls contain the same word:
//form[@id='profile']//button[contains(., 'Save')]
Exact matching on a specific element type
//a[normalize-space(.)='Documentation']
Limiting the element type avoids accidentally selecting a heading, menu item, or hidden container with the same words.
The Tool Desk
Outbyte PC Repair FREERepair Windows errors before they cause bigger problemsFix Now →Outbyte Driver Updater FREEFix the driver behind crashes, sound loss and screen glitchesFind Drivers →Text followed by another condition
//button[normalize-space(.)='Delete' and @data-confirm='true']
Combining text with a stable attribute can distinguish otherwise identical controls.
text() versus .: choose the correct scope
| Expression style | What it evaluates | Best use | Main risk |
|---|---|---|---|
text()='Save' |
A direct text-node child | Simple markup where the label is one direct node | Fails when text is split or nested |
normalize-space(.)='Save' |
The element’s combined string value, with whitespace normalized | Exact labels with formatting or descendant elements | Can match text contributed by children you did not intend to include |
contains(., 'Save') |
A substring of the combined string value | Stable fragments such as “Save changes” and “Save draft” | May match unintended labels; scope it carefully |
normalize-space(text())='Save' |
The first direct text node converted to a string in common XPath usage | Direct-node text with inconsistent whitespace | Not suitable for labels distributed across multiple child nodes |
When exactness matters, prefer equality over contains(). When only a portion of the label is stable, use contains() and add structural constraints.
Rank #2
- Used Book in Good Condition
Whitespace, punctuation, and case
Whitespace
Plain equality compares the resulting string literally. A newline or multiple spaces can cause a miss. Use:
//div[normalize-space(.)='Account settings']
This does not remove internal punctuation or change letter case.
Do these 3 things before closing this tab:
1Fix the driver behind crashes, sound loss and screen glitches2Clear out junk files and repair common Windows errors3Scan for outdated or missing drivers - takes under a minuteCase sensitivity
XPath string comparisons are normally case-sensitive. If the page can render “Save” or “SAVE,” use a case-folding pattern supported by your engine, commonly with translate() in XPath 1.0:
//button[translate(normalize-space(.), 'ABCDEFGHIJKLMNOPQRSTUVWXYZ', 'abcdefghijklmnopqrstuvwxyz')='save']
Case-folding adds complexity and can be locale-sensitive. If you control the markup, a stable attribute such as data-testid is generally less fragile than text.
Quotes inside the target text
XPath string literals use single or double quotes. If the label itself contains both kinds, construct a concat() expression, or pass the value through your automation library’s locator-building utilities rather than concatenating untrusted input into XPath.
Use text XPath in Selenium
Selenium’s Python API accepts XPath through By.XPATH; its documentation also provides exact and partial link-text strategies (Selenium 4.49.0 API documentation).
Python: exact and partial matches
from selenium import webdriver
from selenium.webdriver.common.by import By
from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver.support import expected_conditions as EC
URL = "https://example.com/settings"
driver = webdriver.Chrome()
try:
driver.get(URL)
wait = WebDriverWait(driver, 15)
# Exact label, allowing layout whitespace and nested elements.
save = wait.until(EC.element_to_be_clickable(
(By.XPATH, "//button[normalize-space(.)='Save changes']")
))
save.click()
# Partial label, scoped to the settings form.
draft = wait.until(EC.element_to_be_clickable(
(By.XPATH, "//form[@id='settings']//button[contains(., 'Save')]")
))
print(draft.text)
finally:
driver.quit()
Replace the example URL and selectors with your page’s actual structure. Waiting for a condition is preferable to locating immediately when the page renders controls asynchronously.
Python: a reusable text locator
from selenium.webdriver.common.by import By
def exact_text(tag, label):
# For fixed, trusted labels. Escape quotes if labels are dynamic.
return (By.XPATH, f"//{tag}[normalize-space(.)={label!r}]")
locator = exact_text("button", "Save")
element = driver.find_element(*locator)
For dynamic or user-supplied labels, do not insert raw values into an XPath string. Implement a quote-escaping function that emits a valid XPath literal, or use a locator strategy that keeps the value separate from the expression.
Links: Selenium’s dedicated strategies
For anchors, Selenium supports By.LINK_TEXT for an exact visible link label and By.PARTIAL_LINK_TEXT for a substring. XPath is more expressive when you also need an ancestor, attribute, or combined predicate:
driver.find_element(By.LINK_TEXT, "Documentation")
driver.find_element(By.PARTIAL_LINK_TEXT, "Doc")
driver.find_element(By.XPATH, "//nav//a[normalize-space(.)='Documentation']")
How to test an XPath before automating it
- Inspect the target element in browser developer tools.
- Check whether the visible label is a direct text node or is split among child elements.
- Start with a specific tag, such as
//buttonor//a, rather than//*. - Try an exact expression first:
//button[normalize-space(.)='Save']. - Count matches in the browser’s XPath console with
$x("//button[normalize-space(.)='Save']"), where supported. - If there are several results, add an ancestor, class, state attribute, or position only when that structure is intentional.
A selector that returns one element today can become ambiguous after a redesign. Treat uniqueness as a property to verify, not an assumption.
Common failures and fixes
No match because text is nested
Symptom: text()='Save changes' returns nothing. Fix: use normalize-space(.) so descendant text participates.
No match because of whitespace
Symptom: the label appears correct but equality fails. Fix: normalize whitespace, and inspect the DOM for line breaks or indentation.
Too many matches
Symptom: Selenium reports multiple candidates or clicks the wrong control. Fix: replace broad //* with a tag and scope it to a meaningful container. Add an attribute predicate when available.
Partial match selects the wrong element
Symptom: “Save” finds “Save as draft” instead of “Save.” Fix: use equality for the complete label, or add a state/attribute predicate.
Quick wins for a faster PC:
Fix the driver behind crashes, sound loss and screen glitchesFind Drivers →Repair Windows errors before they cause bigger problemsFix Now →Scan for outdated or missing drivers - takes under a minuteDriver Scan →The element exists but is not clickable
Symptom: the XPath finds an element, but the click fails. Fix: wait for visibility or clickability, check for overlays, and confirm that the matched node is the actionable control rather than a wrapping div.
Text is rendered outside the queried DOM
Symptom: the words are visible but absent from the inspected subtree. Fix: check shadow DOM boundaries, frames, and client-side rendering. Switch into the correct frame before locating, and use the component’s supported access method when content is inside a shadow root.
Different results in another tool
Symptom: an expression works in one browser or automation framework but not another. Fix: verify the XPath version and implementation supported by the executing engine. The W3C specifications describe language behavior, but they do not guarantee identical integration details in every tool.
Independent reader supportYour contribution helps us test, update, and keep practical guides available for everyone.Reliability and performance guidelines
- Prefer stable, semantic scopes such as
//form[@id='checkout']over a document-wide text search. - Use exact equality when the complete label is contractual; reserve
contains()for intentionally variable labels. - Prefer a stable test attribute when you own the application. Text changes are often a product decision, not a DOM contract.
- Wait for the state you need instead of adding arbitrary sleeps.
- Keep XPath readable. A shorter, scoped expression is easier to diagnose than a long chain of positional indexes.
- Re-check uniqueness after UI changes and include selector failures in test diagnostics.
XPath evaluation cost depends on the document, expression, and engine. In practice, reducing the search scope and avoiding unnecessary descendant-wide searches improves both clarity and the chance of selecting the intended node.
Best Value
Or skip the browser setup
If your goal is to inspect or archive a page rather than drive an interactive test, ScreenshotNeo returns a screenshot or PDF from one request. It can accept cookie and consent banners before capture, remove more than 60 known consent platforms plus newsletter popups and chat widgets, and lets you turn each cleanup step off. Bot checks, CAPTCHAs, blank pages, timeouts, failed loads, and cache hits are not billed; each response identifies the result with X-Page-Verdict and X-Billed headers. Its MCP server exposes take_screenshot, get_page_info, and capture_pdf to Claude, Cursor, and other MCP clients.
For a direct image request, see the ScreenshotNeo documentation:
curl -G "https://api.screenshotneo.com/v1/shot" -d access_key=YOUR_API_KEY --data-urlencode url=https://stripe.com -o shot.webp
The same call in Python:
import requests
r = requests.get(
"https://api.screenshotneo.com/v1/shot",
params={"access_key": "YOUR_API_KEY", "url": "https://stripe.com"},
timeout=90,
)
r.raise_for_status()
open("shot.webp", "wb").write(r.content)
And Node.js:
const q = new URLSearchParams({ access_key: 'YOUR_API_KEY', url: 'https://stripe.com' });
const res = await fetch(`https://api.screenshotneo.com/v1/shot?${q}`);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const body = Buffer.from(await res.arrayBuffer());
require('fs').writeFileSync('shot.webp', body);
ScreenshotNeo supports full-page and element captures, device presets, custom viewports, dark mode, retina scale, PDF settings, custom CSS and JavaScript, clicks, waits, request blocking, headers, cookies, user agents, authorization, timezone, geolocation, transparent backgrounds, resizing, configurable caching, signed links, asynchronous webhooks, bulk capture of up to 100 URLs per call, usage reporting, and an OpenAPI specification. Parameter names used by other screenshot APIs also work, which can simplify migration.
The Free plan includes 1,000 screenshots per month without a card; paid plans start at $5 for 3,000 shots. Sign up free to try it.
What’s actually slowing this PC down?
Pick the symptom - the matching free tool is one click away.
FAQ
Should I use text() or .?
Use text() when the expected value is one direct text node. Use . when child elements may contribute to the visible label.
When is contains() appropriate?
Use it when only a stable substring is known, then constrain the tag or ancestor so unrelated controls cannot match.
Is XPath guaranteed to behave identically everywhere?
No. Confirm the XPath version and integration supported by the browser, automation library, or other engine executing the expression.
Frequently Asked Questions
Can XPath select an element whose text is split across several child elements?
Yes. Match the element’s string value with normalize-space(.) or contains(., ...) instead of requiring one direct text() node.
Outdated Drivers Are Slowing You Down
One free scan finds every outdated or missing driver and matches the right update for your exact hardware.Free scan · exact hardware matchPC Slower Than It Used to Be?
A free scan shows the junk files, broken settings and background clutter dragging Windows down - then fixes them in one click.Free scan · Windows 10 & 11Why does an exact text XPath stop working after a redesign?
The redesign may change whitespace, nesting, capitalization, or the label itself. Inspect the new DOM, then choose normalized text, a narrower scope, or a stable test attribute.
Quick Recap
Product prices and availability are accurate as of the date/time indicated and are subject to change. Any price and availability information displayed on Amazon at the time of purchase will apply.

