Blog

XPath contains() in Selenium: Syntax, Examples & Tips

Adwitiya Pandey
Senior Test Evangelist
Published on
June 19, 2026
In this Article:

A complete guide to the XPath contains() function in Selenium, with syntax, use cases, advanced patterns, pitfalls, and working Java and Python code.

The XPath contains() function is one of the most useful tools a Selenium engineer has for handling dynamic web applications, and one of the most overused. Used carefully, it makes locators survive dynamic IDs, auto-generated class names, and minor DOM shifts. Used carelessly, it matches too many elements, runs slowly, and breaks in ways that are hard to debug.

This guide covers what contains() does, its syntax, the three primary use cases, the advanced patterns that combine it with normalize-space(), translate(), and XPath axes, the compound expressions for multiple conditions, the common pitfalls, and the performance and debugging techniques that keep locators stable.

Working Java and Python code runs through every section.

A Locator That Broke Three Times in Two Weeks

A QA engineer was once asked why a single Selenium test had broken three times in a fortnight. The cause was always the same expression,
//button[contains(@class, 'btn')].

Every time the team added a new button anywhere on the page, the test grabbed the wrong element, and every fix made the contains() a little more specific until the locator was almost unreadable.


The story repeats across Selenium codebases, because contains() sits right at the line between resilience and fragility.

The sections below show how to stay on the right side of that line, starting with what the function actually does before moving into the Selenium-specific patterns, pitfalls, and performance techniques that decide whether a contains() locator lasts a release or a year.

What XPath contains() Does

XPath stands for XML Path Language, and it is the language used to navigate elements and attributes in an XML or HTML document. Within it, contains() is a string function that returns true if a string contains a given substring.

The signature is contains(haystack, needle) and the return value is a boolean. Inside an XPath predicate, the part within square brackets, the function filters elements based on whether a chosen attribute or text node contains the substring being searched for.

//button[contains(@class, 'primary')]

The expression reads: find any button element whose class attribute contains the substring primary. The match is partial, which is what makes the function so useful.

A button with class="btn primary large" matches, a button with class="btn-primary" also matches, and a button with class="prim" does not.

The function is case-sensitive by default, and whitespace is preserved exactly as it appears, two characteristics that shape the patterns that follow.

Absolute vs Relative XPath

Before going further, one distinction matters for every locator you write.

An absolute path starts at the root and names every ancestor down to the element, such as html/body/div[1]/form/button. It breaks the moment anything in that chain changes, so it is rarely the right choice.

A relative path, written with a leading //, jumps straight to the element by its own characteristics, such as //button[contains(@class, 'primary')].

Relative paths are far more resilient to DOM change, and contains() is almost always used inside them.

CTA Banner

Why XPath contains() Matters in Modern Web Applications

Applications built on React, Angular, Vue, and similar frameworks generate dynamic attribute values at runtime. A button rendered today might carry id="btn-submit-47392", and the same button tomorrow might carry id="btn-submit-82910".

Static expressions that assert exact equality break on every render, while expressions using contains() survive.

<!-- Render 1 -->
<button id="btn-submit-47392">Submit</button>

<!-- Render 2 -->
<button id="btn-submit-82910">Submit</button>

A static locator like //button[@id='btn-submit-47392'] works once and breaks forever, whereas a contains() locator that targets the stable portion survives:

//button[contains(@id, 'btn-submit')]

The pattern extends to class names, auto-generated test IDs, hash-based selectors, framework-specific data attributes, and any other attribute whose value holds a stable substring inside a dynamic envelope. The contains() function is the workhorse that keeps Selenium viable on modern web stacks.

CTA Banner

The Three Primary Use Cases for contains() in Selenium

Three patterns cover most production use of contains(), and each is worth knowing in detail because the trade-offs differ.

Use Case 1: contains() With Attribute Values

The most common use is matching elements whose attribute values contain a known substring, typically class names, IDs, names, data attributes, and ARIA attributes.

//button[contains(@class, 'btn-primary')]
//input[contains(@name, 'email')]
//div[contains(@data-testid, 'user-card')]
//a[contains(@aria-label, 'Open menu')]

In Java:

WebElement submitButton = driver.findElement(
    By.xpath("//button[contains(@class, 'btn-primary')]")
);
submitButton.click();

In Python:

submit_button = driver.find_element(
    By.XPATH, "//button[contains(@class, 'btn-primary')]"
)
submit_button.click()

The pattern handles the common case where an attribute value wraps a stable identifier inside a dynamic portion such as an auto-generated suffix, a session identifier, or a build hash. Targeting the stable portion gives the locator durability.

Use Case 2: contains() With Text Content

The second pattern matches elements by the text they display. The text() function returns the text node of an element, and contains() matches against it.

//button[contains(text(), 'Submit')]
//a[contains(text(), 'Forgot password')]
//span[contains(text(), 'Welcome back')]
//h2[contains(text(), 'Dashboard')]

In Java:

WebElement loginButton = driver.findElement(
    By.xpath("//button[contains(text(), 'Login')]")
);

The pattern is useful when an element lacks stable attributes but displays known text, so a button reading "Submit" can be located by its text even when the framework regenerates its ID on every render.

The behaviour of text() matters here. It returns only the direct text node of the element, not concatenated text from descendants, so a button containing a <span> may return an empty string or only the parent's direct text. The fix is to use . instead, the current node's string value, which concatenates all descendant text:

//button[contains(., 'Submit')]

The . form is more forgiving when text is spread across nested elements, but also more inclusive, so it matches more elements. The choice depends on the specific structure.

Use Case 3: contains() With Multiple Conditions

The third pattern combines contains() with other predicates using logical operators (and, or, not) to produce more specific locators that reduce false matches.

//input[contains(@class, 'form-control') and contains(@name, 'email')]
//button[contains(@class, 'btn') and contains(text(), 'Save')]
//a[contains(@href, '/products') and not(contains(@class, 'disabled'))]

In Java:

WebElement emailField = driver.findElement(
    By.xpath("//input[contains(@class, 'form-control') and contains(@name, 'email')]")
);
emailField.sendKeys("user@example.com");

The pattern is the right approach when a single attribute is not specific enough. A button with the class btn could be any of fifty buttons on the page, whereas a button with the class btn and the text "Save" is almost always unique.

CTA Banner

contains() vs text() for Matching by Text

Because both come up constantly when locating by text, the difference deserves to be explicit. The text()= form demands an exact match, while contains(text(), ...) allows a partial one.

<!-- Matches only if the text is exactly "Login", a trailing space breaks it -->
//button[text()='Login']

<!-- Matches "Login", "Login now", "Please Login", and more -->
//button[contains(text(), 'Login')]

Use text()= for static, predictable content such as fixed navigation labels, where an exact match is safe and the strictness catches unexpected change. Use contains(text(), ...) for dynamic or partial text, such as a button that reads "Welcome, John!" where only "Welcome" is stable. Exact matching is more reliable in controlled environments but too rigid for dynamic interfaces, which is where contains() earns its place.

Advanced contains() Patterns

Three patterns extend the basic toolkit, and each solves a problem that comes up in nearly every production Selenium suite.

Whitespace-Resistant Matching With normalize-space()

Text in HTML often carries leading whitespace, trailing whitespace, or runs of internal whitespace introduced by source formatting, and plain text() returns it exactly as it appears. A locator looking for "Submit" can fail because the element's text is actually " Submit " or "Submit\n ".

The normalize-space() function strips leading and trailing whitespace and collapses internal runs to a single space, so combined with contains() it produces matching that survives source-formatting variation.

//button[contains(normalize-space(text()), 'Submit')]
//span[contains(normalize-space(.), 'Welcome User')]

WebElement submitButton = driver.findElement(
    By.xpath("//button[contains(normalize-space(text()), 'Submit')]")
);

The pattern is a near-default for any text-based locator on a real application, because source-formatted HTML almost never produces clean text nodes.

Case-Insensitive Matching With translate()

XPath 1.0, the version Selenium WebDriver supports, has no built-in case-insensitive matching, so the standard workaround uses translate() to lowercase both sides of the comparison.

//button[contains(translate(text(), 'ABCDEFGHIJKLMNOPQRSTUVWXYZ', 'abcdefghijklmnopqrstuvwxyz'), 'submit')]

The expression takes the button's text, translates each uppercase letter to its lowercase equivalent, and checks whether the result contains submit, so the locator now matches "Submit", "SUBMIT", "submit", and any other case combination.

String xpath = "//button[contains(translate(text(), 'ABCDEFGHIJKLMNOPQRSTUVWXYZ', 'abcdefghijklmnopqrstuvwxyz'), 'submit')]";
WebElement submitButton = driver.findElement(By.xpath(xpath));

The pattern is verbose but reliable. XPath 2.0 has a lower-case() function that simplifies it, but Selenium WebDriver does not support XPath 2.0 in most browsers, so the translate() workaround remains the norm.

contains() Combined With XPath Axes

XPath axes (parent, ancestor, following-sibling, preceding-sibling, descendant, child) navigate the relationships between elements, and combined with contains() they let locators target elements by structural relationship rather than their own attributes. A common pattern finds an input by its associated label text:

//label[contains(text(), 'Email')]/following-sibling::input

The locator finds a label whose text contains "Email", then moves to the next sibling input, and it is durable because labels rarely change their text even when surrounding HTML is restructured. Another common pattern finds a parent container by its child's text:

//span[contains(text(), 'Error')]/ancestor::div[contains(@class, 'form-group')]

WebElement emailInput = driver.findElement(
    By.xpath("//label[contains(normalize-space(text()), 'Email')]/following-sibling::input")
);
emailInput.sendKeys("user@example.com");

The axes are powerful and dangerous in equal measure. Complex axis-based locators are hard to read, slow to execute, and brittle in ways that are difficult to debug, so the pattern works best when the structural relationship is simple and stable.

Worked Examples on a Real DOM

Abstract patterns are easier to trust once you see them resolve a real, messy structure. Two examples show contains() combined with axes and logical operators to pin down a single element.

Finding a Button by Two Related Conditions

Consider an online bookstore where each book sits in its own div, and you need the "Add to Cart" button for the book by a specific author in a specific genre.

<div class="book">
    <h2>Book Title A</h2>
    <p>Author: Leom Ayodele</p>
    <ul class="genres"><li>Fiction</li><li>Adventure</li></ul>
    <button>Add to Cart</button>
</div>
<div class="book">
    <h2>Book Title B</h2>
    <p>Author: Leom Ayodele</p>
    <ul class="genres"><li>Non-Fiction</li><li>Science</li></ul>
    <button>Add to Cart</button>
</div>

The locator narrows by author and genre before selecting the button:

//div[contains(@class, 'book') and descendant::p[contains(text(), 'Leom Ayodele')] and descendant::li[contains(text(), 'Fiction')]]/button

It reads: find a div whose class contains "book" that has a descendant paragraph containing "Leom Ayodele" and a descendant list item containing "Fiction", then select that div's child button. Two books share the author, but only one is Fiction, so the compound condition resolves to a single element.

Finding Replies Beneath a Specific Comment

Consider a comment thread where you need every reply to a comment left by "John Doe".

<div class="comment" id="comment2">
    <p class="author">John Doe</p>
    <div class="body">I have a question.</div>
    <div class="reply"><p class="author">Alice</p><div class="body">Here's an answer.</div></div>
    <div class="reply"><p class="author">Bob</p><div class="body">Another perspective.</div></div>
</div>

//div[contains(@class, 'comment') and descendant::p[contains(@class, 'author') and contains(text(), 'John Doe')]]/div[contains(@class, 'reply')]

The expression finds the comment authored by John Doe, then selects its child reply divs, demonstrating how axes and contains() together reach elements that no single-attribute locator could isolate.

CTA Banner

contains() Compared to Other Xpath Functions

Three functions overlap with contains(), and knowing when to use which is part of writing maintainable XPath.

contains() vs starts-with()

starts-with() matches if the value begins with a substring, while contains() matches if it appears anywhere. When the stable portion sits at the start of a dynamic value, starts-with() is more specific and slightly faster.

<button id="submit-btn-47392">

<!-- Best match when the stable part is at the start -->
//button[starts-with(@id, 'submit-btn')]

<!-- Also works but less specific -->
//button[contains(@id, 'submit-btn')]

The two combine well for resilient locators on dynamic applications, for example //button[starts-with(text(), 'Save') and contains(@class, 'primary')].

contains() vs ends-with()

XPath 1.0 has no native ends-with(), so matching a known suffix such as a file extension uses a substring() workaround:

//a[substring(@href, string-length(@href) - string-length('.pdf') + 1) = '.pdf']

The pattern works but is awkward, and for most cases restructuring the locator to use contains() on a less specific portion is cleaner.

contains() vs CSS partial matching

CSS selectors offer partial matching through [class*="btn"], which is equivalent to XPath contains(@class, 'btn'). CSS is usually faster and more readable, but XPath keeps the edge for text matching and axis navigation that CSS cannot replicate.

button[class*="btn-primary"]

//button[contains(@class, 'btn-primary')]

The rule of thumb is to use CSS when the lookup is attribute-based and the structure is simple, and to use XPath when text matching or axis navigation is needed.

Common Pitfalls When Using contains()

Five mistakes recur most often in production Selenium code.

The first is over-matching. A locator like //div[contains(@class, 'card')] matches any div with the substring "card" in its class, which includes card, card-header, card-body, and cardboard-container. The fix is to use a more specific substring, combine with another condition, or match the whole class name by wrapping it in spaces:

//div[contains(concat(' ', normalize-space(@class), ' '), ' card ')]

The pattern uses concat() to put a space before and after the class value, then searches for card with surrounding spaces, so it matches only the whole class and not card-header or cardboard. The verbosity is the price of correctness.

The second pitfall is performance. Expressions with complex contains() chains, especially combined with axes, run slowly on large pages because the engine evaluates them against every candidate node. The fix is to scope as tightly as possible, preferring a specific tag over //* and avoiding // chains in the middle of the expression.

The third is the text() versus . mismatch covered above, where text() misses text spread across nested elements and . is the fix when nesting is possible.

The fourth is case sensitivity, where contains(@class, 'BTN') does not match class="btn", fixed with the translate() pattern applied to both sides if case is unpredictable.

The fifth is the assumption that a locator is stable across releases. A contains() locator depends on its target substring remaining in the HTML, and class names change, test IDs get renamed, and framework upgrades adjust auto-generated prefixes, so every contains() locator is one DOM change away from breaking.

CTA Banner

Performance Considerations

XPath performance varies widely by expression structure, and a few habits keep contains()-based locators fast.

  • Avoid leading //*, since //*[contains(@class, 'btn')] evaluates every element on the page, whereas anchoring to a tag with //button[contains(@class, 'btn')] cuts the candidate set dramatically.
  • Avoid chained // operators, since //div//span[contains(text(), 'Welcome')] evaluates a Cartesian product of all divs against all descendant spans.
  • Avoid contains() inside not() when a positive match on a different attribute is available, since negation is more expensive.
  • Prefer ID and class attributes for matching, since browser engines index them and evaluate them faster than ARIA, data, or computed attributes.
  • Cache the WebElement when reusing it within the same step rather than re-running the same XPath repeatedly.
// Cache once, reuse
WebElement panel = driver.findElement(By.xpath("//div[contains(@class, 'user-panel')]"));
WebElement name = panel.findElement(By.xpath(".//span[contains(@class, 'username')]"));
WebElement role = panel.findElement(By.xpath(".//span[contains(@class, 'role')]"));

The leading . in the inner XPath scopes the search to the cached panel rather than the whole document.

Debugging contains() Expressions

XPath expressions can be tested live in browser DevTools, which removes most of the guesswork. In Chrome, Firefox, or Edge, open the console and use $x():

$x("//button[contains(@class, 'btn-primary')]")

The function returns an array of matching elements. An empty array means the locator matches nothing, a long array means it is not specific enough, and a single expected match means it is correct.

Common causes of unexpected results are:

  • Whitespace in the text node, fixed with normalize-space().
  • Case mismatch, fixed with translate().
  • Text spread across descendants, fixed by using . instead of text().
  • An element rendered after the XPath was evaluated, fixed with WebDriverWait and ExpectedConditions.presenceOfElementLocated.
  • An element inside an iframe, fixed by switching context with driver.switchTo().frame(...).
CTA Banner

The Architectural Cost of contains() Based Locators

The contains() function is the best tool XPath gives Selenium engineers, and the discipline of using it well produces locators that outlast naive equality matches. The category stays expensive at enterprise scale for an architectural reason: every locator, however well written, depends on a single matching strategy, and when that strategy fails because the substring changes, the structure shifts, or a class is renamed, the locator breaks. Maintenance scales with the number of expressions in the suite multiplied by the rate of application change.

The cost is visible in any sufficiently large Selenium codebase. The burden grows as the application evolves, often crossing the line where adding new tests is slower than fixing existing ones. The contains() function delays the crossover. It does not prevent it.

AI-native test automation platforms approach the problem differently. Instead of relying on a single locator expression per element, they build a multi-signal model from the visual appearance of the rendered element, the DOM structure around it, the surrounding text, the role and ARIA semantics, and the position on the page.

When one signal changes, such as a renamed class, the others remain stable and the element is still identified. Self-healing resolves the everyday locator drift that consumes engineering time in code-first suites.

The trade-off is real: code-first XPath gives engineers low-level control, while AI-native abstraction removes the control in exchange for removing the maintenance, and which model fits depends on the team, the application, and the tolerance for maintenance debt.

Where Virtuoso QA Fits

Virtuoso QA's test creation uses a plain-English description of the element, which the platform combines with visual analysis, DOM structure, and contextual data to identify the right element, replacing the contains() expression with a description. A Selenium step such as:

driver.findElement(By.xpath("//button[contains(@class, 'btn-primary') and contains(text(), 'Submit')]")).click();

becomes a Virtuoso QA step that reads:

click on Submit button

The platform interprets the step against the current page, identifies the candidate button using multiple signals, and clicks it. When the application changes, a class is renamed, a wrapper is added, the button moves, the multi-signal identification adapts, and self-healing at around 95 percent user acceptance resolves the change without engineering intervention.

AI Root Cause Analysis pinpoints failures when they do occur, delivering the failing step, the relevant DOM context, and the suspected cause in the test report, so the locator-level maintenance that consumes Selenium time falls away as a category.

Future Outlook for XPath and Element Identification

Three shifts are visible in how Selenium engineers think about element identification.

The first is the move towards explicit testing attributes. Modern frameworks encourage data-testid attributes added specifically for test stability that rarely change between releases, so where the team controls both the application and the tests, adding stable test IDs to critical elements reduces the need for contains() workarounds.

<button data-testid="submit-order">Submit</button>

//button[@data-testid='submit-order']

The pattern produces locators that are exact, fast, and stable, at the cost of the application-side discipline of maintaining the attributes.

The second is the rise of AI-augmented locators inside code-first frameworks, where modern WebDriver implementations add self-healing layers that combine XPath with visual identification, reducing brittleness while keeping the code-first model.

The third is the move to natural-language testing for new suites. New automation initiatives increasingly start in AI-native platforms rather than Selenium, with Selenium reserved for low-level scripting where code control is essential. The contains() function remains the workhorse of existing Selenium codebases, but it is no longer the default starting point for new ones.

Conclusion

The XPath contains() function is what makes Selenium viable against modern, dynamic web applications, and using it well, with normalize-space() for whitespace, translate() for case, whole-word matching where needed, and tight scoping for performance, produces locators that last. The function delays the maintenance crossover that every large Selenium suite eventually hits, but it cannot remove it, because every locator still rests on a single matching strategy. Where that maintenance debt becomes the constraint, multi-signal AI-native identification is the architectural alternative. For everything short of that, contains() remains the most valuable tool in the XPath toolkit, and worth mastering.

CTA Banner

Selenium Related Reads

Frequently Asked Questions

What does contains() do in XPath?
contains() is a string function that returns true if a string contains a given substring. Used inside an XPath predicate, it filters elements based on whether a chosen attribute or text node contains the substring being searched for. It takes two arguments, the haystack (an attribute, text node, or string value) and the needle (the substring to look for).
What is the alternative to XPath contains() for class matching?
CSS selectors offer [class*="value"] for partial class matching, equivalent to XPath contains(@class, 'value'). CSS is usually faster and more readable for attribute-based partial matches, while XPath remains necessary for text-based matching and axis navigation that CSS cannot replicate.
Why does a contains() XPath work in DevTools but fail in Selenium?
DevTools evaluate the XPath against the current DOM, but Selenium may evaluate it before the element has rendered. The fix is to use WebDriverWait with ExpectedConditions.presenceOfElementLocated() or visibilityOfElementLocated() to wait for the element. Iframes and shadow DOM also cause this divergence, so switch context if the element is inside one.
Is XPath contains() slower than other locators?
contains() is slower than exact attribute equality and similar in performance to other substring functions. The biggest performance issues come from unanchored expressions such as //*, chained // operators, and complex axis traversal combined with contains(). Anchoring to a specific tag and avoiding unnecessary descendants keeps it performant.
How does Virtuoso QA handle element identification without XPath?
Virtuoso QA combines visual analysis, DOM structure, contextual data, and a plain-English description to identify elements without relying on a single locator strategy. Self-healing at around 95 percent user acceptance resolves the everyday locator drift that consumes engineering time in Selenium-based suites, removing the contains() workarounds and the maintenance burden that comes with them.

Subscribe to our Newsletter

Codeless Test Automation

Try Virtuoso QA in Action

See how Virtuoso QA transforms plain English into fully executable tests within seconds.

Try Interactive Demo
Schedule a Demo