Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

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.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

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.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

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
XPath 2.0 Programmer's Reference
  • 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.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

Case 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).

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

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

  1. Inspect the target element in browser developer tools.
  2. Check whether the visible label is a direct text node or is split among child elements.
  3. Start with a specific tag, such as //button or //a, rather than //*.
  4. Try an exact expression first: //button[normalize-space(.)='Save'].
  5. Count matches in the browser’s XPath console with $x("//button[normalize-space(.)='Save']"), where supported.
  6. 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.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

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.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

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.Support on Ko-Fi

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.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

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.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

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.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

Why 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.

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.