XPath is a query language for pointing at elements inside an HTML or XML document. If CSS selectors are how you say "the element with class .price", XPath is how you say "the table cell to the right of the cell containing the word Price". It can do things CSS cannot, which is why it survives.
You meet it in three places. Web scraping and data extraction, where you are pulling values out of pages you did not build. Browser automation and testing, in Selenium, Playwright, and Cypress, where you need to find a button. And XML processing, which is where it came from originally.
In all three, one function does most of the work: contains(). And most people get it subtly wrong.
The short version: use contains(., 'text') and not contains(text(), 'text'). The dot sees all text inside an element; text() only sees the first direct text node and silently misses anything wrapped in a child tag. That one difference explains most broken XPath contains expressions.
The rest of this is the full reference: the syntax, the gotchas, the functions that do not exist, and a quick-reference table. Every expression was run against a real parser before publishing.
Jump to: the text() bug | attributes | whitespace | functions that do not exist | quick reference
01The contains XPath syntax
//tagname[contains(@attribute, 'value')]
//tagname[contains(text(), 'value')]
//tagname[contains(., 'value')]
The signature is contains(haystack, needle) and it returns a boolean. Reading the first one aloud: find any element of this tag whose attribute includes this substring.
That third form, contains(., 'value'), is the one most tutorials leave out, and it is usually the one you actually want.
02What contains() Does
//button[contains(@class, 'primary')] finds any button whose class attribute includes the substring primary.
The match is partial and that is the point. A button with class="btn primary large" matches. So does class="btn-primary". A button with class="prim" does not.
This solves the dynamic-value problem. When IDs are generated at runtime, when class names carry state, or when button text varies by context, an exact match breaks and a partial match survives.
03XPath contains text: the bug everyone hits
When people search for contains text XPath, this is usually the problem they are trying to solve. It is the single most common reason contains in XPath does not work, and almost every tutorial reproduces the mistake.
text() does not return an element's text. It returns the element's first direct text node. Anything inside a child element is invisible to it.
Given this markup:
html
<button class="btn btn-primary large">Submit <span>Order</span></button>
Two expressions, two different results:
| Expression | Matches |
|---|---|
| //button[contains(text(),'Submit')] | Yes, Submit is a direct text node |
| //button[contains(text(),'Order')] | No, Order is inside the span |
| //button[contains(.,'Order')] | Yes |
The dot means "the string value of this node", which is all descendant text concatenated. It sees Submit Order. text() only ever saw Submit .
The rule: use
contains(., 'value')for text matching unless you have a specific reason to target one text node.
Buttons with icons, links with nested spans, and headings with inline formatting all fail with text() and work with .. If you have ever written an XPath contains expression that looked right and matched nothing, this was probably why.
04Contains XPath with attributes
The most common use, and the least surprising. It works on any attribute:
//button[contains(@class, 'btn-primary')]
//input[contains(@name, 'email')]
//div[contains(@data-testid, 'user-card')]
//a[contains(@aria-label, 'Open menu')]
//img[contains(@src, 'avatar')]
Useful when part of the value is stable and part is generated. An ID like user_4821_profile can be matched with contains(@id, '_profile').
05The Partial Match Trap
contains() is a substring match, which means it matches more than you often intend.
Given two buttons, one class="btn btn-primary large" and one class="btn-danger":
//button[contains(@class,'btn')]
That matches both. btn is a substring of btn-danger.
For matching a whole class token rather than a substring, the standard fix is:
//button[contains(concat(' ', normalize-space(@class), ' '), ' btn ')]
Wrapping the class list in spaces and searching for the space-delimited token means btn matches only when it is a complete class name. Against the same markup, this returns one element instead of two.
Rule of thumb: if you are matching a class, reach for a CSS selector.
.btndoes natively what the concat trick does verbosely.
It is verbose. It is also the only correct way to do it in XPath 1.0.
06Whitespace Will Ruin Your Day
HTML is full of whitespace that is invisible when rendered and very much present in the DOM.
html
<p> Welcome, John! </p>
//p[text()='Welcome, John!'] -> no match
//p[normalize-space(text())='Welcome, John!'] -> match
normalize-space() strips leading and trailing whitespace and collapses internal runs of whitespace into single spaces. It fixes both problems at once.
Internal whitespace catches people out too. Text rendered as Saved successfully may be Saved successfully in the source, from indentation in a template. contains(., 'Saved successfully') returns nothing. contains(normalize-space(.), 'Saved successfully') matches.
Practical default: if you are matching text a human read off a screen, wrap it in
normalize-space().
One side effect worth knowing: normalize-space(.) on an ancestor also sees its descendants' text, so //div[contains(normalize-space(.),'Saved successfully')] can match both the target div and its parent. Add a more specific tag or class to narrow it.
07contains() Is Case Sensitive
There is no case-insensitive flag in XPath 1.0.
//a[contains(text(),'read more')] -> no match against "Read More"
The workaround is translate(), which maps characters one to one:
//a[contains(
translate(text(),
'ABCDEFGHIJKLMNOPQRSTUVWXYZ',
'abcdefghijklmnopqrstuvwxyz'),
'read more')]
Ugly, and it works. XPath 2.0 has lower-case(), but browsers, Selenium, and lxml all implement XPath 1.0, so you will not have it.
08Functions That Do Not Exist
Several widely-shared tutorials list functions that are not in XPath 1.0. Running them raises an error rather than returning nothing, which at least fails loudly.
| Function | Status |
|---|---|
| contains() | Exists |
| starts-with() | Exists |
| normalize-space() | Exists |
| translate() | Exists |
| ends-with() | XPath 2.0 only. Not available in browsers, Selenium, or lxml |
| trim() | Does not exist in any XPath version |
| lower-case() / upper-case() | XPath 2.0 only |
| matches() (regex) | XPath 2.0 only |
To match the end of a string in XPath 1.0, use substring() with string-length(), or reach for a different approach entirely:
//a[substring(@href, string-length(@href) - 3) = '.pdf']
09Using and / or in XPath with contains
Writing XPath with contains and multiple conditions means putting them inside the same predicate, joined with and or or:
//div[contains(@class,'alert') and contains(.,'successfully')]
//button[contains(.,'Submit') or contains(.,'Continue')]
//input[contains(@class,'form') and @type='text']
Using and in XPath is how you keep a partial match from being too broad. contains(@class,'btn') alone is vague; combined with a second condition it becomes precise.
Note that separate predicates also work as an AND:
//div[contains(@class,'alert')][contains(.,'successfully')]
Same result, and sometimes more readable when conditions are long.
10Finding XPath Expressions
An XPath finder is any tool that generates an expression for an element you point at. The built-in options:
Browser DevTools. Right-click an element in the inspector, Copy, Copy XPath or Copy full XPath. Fast, and the output is usually terrible: absolute paths like /html/body/div[3]/div[2]/main/div/button[1] break the moment anything above them changes.
Testing an expression before using it. In the DevTools console, $x("//button[contains(.,'Submit')]") returns matching elements. In Chrome and Firefox, Ctrl+F in the Elements panel accepts an XPath expression directly. This is the fastest feedback loop available and most people never learn it.
In Python, lxml evaluates expressions against parsed HTML, which is what was used to verify every example in this article.
The general advice: use an XPath finder to locate the element, then write the expression yourself. Generated absolute paths are the single largest source of brittle selectors.
11Contains XPath syntax: quick reference
| Goal | Expression |
|---|---|
| Attribute contains substring | //tag[contains(@attr,'value')] |
| Element text contains substring | //tag[contains(.,'value')] |
| Direct text node only | //tag[contains(text(),'value')] |
| Ignore surrounding whitespace | //tag[normalize-space(text())='value'] |
| Ignore internal whitespace too | //tag[contains(normalize-space(.),'value')] |
| Exact class token | //tag[contains(concat(' ',normalize-space(@class),' '),' name ')] |
| Case insensitive | //tag[contains(translate(.,'ABC...','abc...'),'value')] |
| Two conditions | //tag[contains(@class,'a') and contains(.,'b')] |
| Either condition | //tag[contains(.,'a') or contains(.,'b')] |
| Starts with | //tag[starts-with(@id,'user_')] |
Try the extraction endpoints free and skip the selector entirely for readable text, sitemaps, and page metadata. No card required.
12When Not to Use XPath
XPath is powerful and frequently the wrong tool.
CSS selectors are better for attributes and classes. .btn does natively what the concat trick does verbosely. [data-testid="x"] is clearer than the XPath equivalent. Use CSS unless you need something it cannot do.
XPath wins for text matching and traversal. CSS cannot select by visible text, and it cannot walk upward. //td[contains(.,'Price')]/following-sibling::td has no CSS equivalent.
And sometimes the selector is the problem. If you are maintaining XPath expressions against a site you do not control, every layout change breaks your parser, and you find out in production. That is the real cost, and it is not fixed by writing better XPath.
Where the data you want is a general capability rather than one site's specific structure, an endpoint avoids the selector entirely:
- Extract readable text from any URL instead of writing a content selector per site
- Extract a sitemap instead of crawling and parsing link structures
- Extract page metadata instead of maintaining XPath against head tags
- Extract structured data instead of parsing markup that changes without notice
No expression to maintain, because there is no markup to parse. Every one runs on the same key, and the free plan allows 5 calls a day with no credit card, which is enough to compare against what your current parser returns.
We wrote about where that line actually sits in more detail.
Browse the extraction catalog | Start free
13FAQ
What does contains() do in XPath?
contains(haystack, needle) returns true when the first string contains the second. Inside a predicate it filters elements by partial match, so //button[contains(@class,'primary')] finds buttons whose class attribute includes primary anywhere in it.
What is the contains XPath syntax?
//tagname[contains(@attribute, 'value')] for attributes, and //tagname[contains(., 'value')] for text. The dot form is preferred over contains(text(), 'value') because it sees text inside child elements too.
How do I use contains text XPath expressions?
For XPath contains text matching, use //tag[contains(., 'value')] rather than //tag[contains(text(), 'value')]. The dot represents the full string value of the element including all descendant text. text() only sees the first direct text node, so it misses anything wrapped in a child element.
Why does XPath text contains not work?
Because text() returns only direct text nodes, not text inside child elements. For <button>Submit <span>Order</span></button>, contains(text(),'Order') matches nothing, since Order lives in the span. contains(.,'Order') matches, because the dot includes all descendant text.
Is XPath contains case sensitive?
Yes. XPath 1.0 has no case-insensitive option. The workaround is translate() to fold the input to lowercase before comparing: contains(translate(text(),'ABC...','abc...'),'value'). XPath 2.0 has lower-case(), but browsers, Selenium, and lxml implement 1.0.
How do I write XPath with contains and multiple conditions?
Join them inside the predicate with and or or: //div[contains(@class,'alert') and contains(.,'saved')]. Stacked predicates work the same as and: //div[contains(@class,'alert')][contains(.,'saved')].
Does XPath have an ends-with function?
Not in XPath 1.0, which is what browsers, Selenium, and lxml use. ends-with() is XPath 2.0 and raises an error in 1.0 engines. The 1.0 equivalent uses substring() with string-length(), for example substring(@href, string-length(@href) - 3) = '.pdf'.
Why does my XPath contains match too many elements?
Because it is a substring match. contains(@class,'btn') matches btn-danger as well as btn, since btn is a substring of both. To match a whole class token, use contains(concat(' ', normalize-space(@class), ' '), ' btn '), or use a CSS selector, where .btn does this natively.
What is normalize-space in XPath?
normalize-space() strips leading and trailing whitespace from a string and collapses internal runs of whitespace into single spaces. It is essential when matching text, because HTML source often contains indentation whitespace that is invisible when rendered but present in the DOM.
What is an XPath finder?
Any tool that generates an XPath expression for an element you select, most commonly browser DevTools via right-click, Copy, Copy XPath. The generated output is usually an absolute path that breaks when the page structure changes, so it is better used to locate an element than to produce the final expression.
How do I test an XPath expression?
In browser DevTools, $x("//button[contains(.,'Submit')]") in the console returns matching elements, and Ctrl+F in the Elements panel accepts XPath directly. In Python, lxml evaluates expressions against parsed HTML. Testing before integrating catches most locator problems immediately.
Should I use XPath or CSS selectors?
Use CSS for attributes and classes, where it is shorter and clearer. Use XPath when you need to match on visible text, traverse upward to a parent, or use axes like following-sibling, none of which CSS can do.
Is there a way to extract page content without writing selectors?
Yes, when what you want is a general capability rather than one site's specific structure. Content extraction endpoints return readable text, sitemaps, and page metadata directly, so there is no expression to maintain when the site changes its markup. Selectors remain the right tool when the data is genuinely specific to one page's structure.
Browse the catalog | Get a free API key
14Verification
Every expression in this article was evaluated with lxml against the markup shown, on the date of publication. The ends-with() and trim() results reflect actual XPathEvalError: Unregistered function responses from an XPath 1.0 engine, not an assumption about the specification.
15About ApyHub
ApyHub is a curated API catalog for developers, teams, and AI agents, covering content and data extraction, file conversion, data validation, AI and OCR, and more across 20 categories. One subscription covers the whole catalog, billed in atoms, with headroom pooled across every API rather than locked to individual services.
Every service carries machine-readable certification covering data handling, retention, and standards alignment including GDPR, SOC 2, and ISO 27001. Every endpoint is MCP-ready by default.
ApyHub is headquartered in Amsterdam, with offices in the Netherlands, Greece, and India, and runs on EU infrastructure. The catalog holds 450+ services and 1,500+ endpoints, with new APIs and providers onboarded continuously. The free tier allows 5 calls a day with no credit card.
