---
title: "XPath contains(): Syntax, Text Matching, and the Bug Everyone Hits"
url: https://apyhub.com/blog/xpath-contains-syntax-text-matching-and-the-bug-everyone-hits
author: ApyHub
published: 2026-09-06T22:42:01Z
---

# XPath contains(): Syntax, Text Matching, and the Bug Everyone Hits

**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](#xpath-contains-text-the-bug-everyone-hits) | [attributes](#contains-xpath-with-attributes) | [whitespace](#whitespace-will-ruin-your-day) | [functions that do not exist](#functions-that-do-not-exist) | [quick reference](#contains-xpath-syntax-quick-reference)

***

## The 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.

## What 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.

## XPath 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.

## Contains 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')`.

## The 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. `.btn` does natively what the concat trick does verbosely.

It is verbose. It is also the only correct way to do it in XPath 1.0.

## Whitespace 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.

## contains() 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.

## Functions 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']
```

## Using 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.

## Finding 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.

## Contains 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**](https://apyhub.com/auth/signup) and skip the selector entirely for readable text, sitemaps, and page metadata. No card required.

## When 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](https://apyhub.com/catalog/data-extraction) instead of writing a content selector per site
* [Extract a sitemap](https://apyhub.com/catalog/data-extraction) instead of crawling and parsing link structures
* [Extract page metadata](https://apyhub.com/catalog/data-extraction) instead of maintaining XPath against head tags
* [Extract structured data](https://apyhub.com/catalog/data-extraction) 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](https://apyhub.com/auth/signup) 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](https://apyhub.com/blog/web-scraping-in-2026-what-changed-what-s-legal-and-what-to-use-instead) in more detail.

[**Browse the extraction catalog**](https://apyhub.com/catalog/data-extraction) | [**Start free**](https://apyhub.com/auth/signup)

## FAQ

### 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](https://apyhub.com/catalog/data-extraction) 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**](https://apyhub.com/catalog) | [**Get a free API key**](https://apyhub.com/auth/signup)

## Verification

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.

***

## About ApyHub

[ApyHub](https://apyhub.com/) is a curated API catalog for developers, teams, and AI agents, covering [content and data extraction](https://apyhub.com/catalog/data-extraction), [file conversion](https://apyhub.com/catalog/file-conversion), [data validation](https://apyhub.com/catalog/data-validation), [AI and OCR](https://apyhub.com/catalog/artificial-intelligence), 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](https://apyhub.com/auth/signup) allows 5 calls a day with no credit card.
