Skip to content

XPath contains(): match text and attributes, with examples

We ran 20 contains() cases in lxml, parsel and Chrome. A class match found 4 elements instead of 2, contains(text()) found 0, and an empty value matched all 17.

HProxy TeamOctober 12, 2026Updated October 12, 20264 min read

XPath contains(): match text and attributes, with examples

The XPath function contains() matches part of a text or an attribute, which makes it a favourite for messy pages. It also fails quietly. Some uses match too much, some match nothing, and none of them raises an error.

On 12 October 2026 we ran 20 cases on a small test page of our own. We used three engines: lxml, parsel (the selector library inside Scrapy) and Chrome, and they agreed on every case.

The basics

from lxml import html

doc = html.document_fromstring(open("contains-page.html", encoding="utf-8").read())

print(len(doc.xpath("//a[contains(@title, 'Lamp')]")))
print(len(doc.xpath("//p[contains(., 'on sale')]")))
1
1

contains(a, b) is true when the string a contains b. The first argument can be an attribute, such as @title, or the element's own text, written as a dot.

Classes: contains(@class) matches too much

print([p.get("class") for p in doc.xpath("//p[contains(@class, 'price')]")])
print([p.get("class") for p in doc.xpath("//p[contains(concat(' ', normalize-space(@class), ' '), ' price ')]")])
['price', 'price_color', 'old-price', 'price sale']
['price', 'price sale']

The short form matched four elements, because contains() works on characters, not on words. Comparing with @class='price' went wrong the other way: it found only the element whose whole class is "price" and missed "price sale".

The longer form puts a space on each side of the class list and of the word, so only whole words match. It found the 2 right elements. In CSS, p.price does this for you; see our test of XPath against CSS selectors.

Text: use the dot, not text()

print(len(doc.xpath("//p[contains(text(), 'on sale')]")))
print(len(doc.xpath("//p[contains(., 'on sale')]")))
0
1

The test paragraph is <p>Price: <b>£10</b> on sale today</p>, and the first expression found nothing:

The paragraph's three nodes, and what each contains() expression compares
Diagram: HProxy, from our test page

The paragraph holds three nodes, and text() selects the two text nodes. When contains() gets a set of nodes, XPath 1.0 keeps only one. The standard turns a set of nodes into "the string-value of the node in the node-set that is first in document order". So contains(text(), 'on sale') only looked at "Price: ". The dot stands for the whole text of the element, child tags included.

Case: contains() is case sensitive

LOWER = "translate(@title, 'ABCDEFGHIJKLMNOPQRSTUVWXYZ', 'abcdefghijklmnopqrstuvwxyz')"
print(len(doc.xpath(f"//a[contains({LOWER}, 'lamp')]")))
2

contains(@title, 'Lamp') found only "Desk Lamp" and missed "desk lamp, black". XPath 2.0 has a lower-case() function, but lxml, parsel and Chrome speak XPath 1.0, and all three rejected it with an error. translate() swaps each capital letter for its small one, and then the search found both titles.

Spaces: normalize-space() and the non-breaking space

print(len(doc.xpath("//span[contains(normalize-space(.), 'Out of stock')]")))
print(len(doc.xpath("//span[contains(translate(., ' ', ' '), 'In stock')]")))
1
1

Pages often carry extra spaces, and normalize-space() folds each run into one, so "Out of stock" matched. It does not touch a non-breaking space, the &nbsp; of HTML. XPath counts as white space only what XML does: the space, the tab and the two line breaks. Our "In stock" with a non-breaking space matched only after translate() turned it into a plain space.

An empty value matches everything

sku = ""  # a value that came back empty
print(len(doc.xpath("//*[contains(@data-sku, $sku)]", sku=sku)), len(doc.xpath("//*")))
17 17

Say the value comes from a variable and is empty. Then contains() is true for every element of the page, including the 16 without a data-sku attribute. The XPath rules explain why. A missing attribute becomes an empty string, and every string contains the empty string. Check the value before you search; passing it as an XPath variable, as here, does not change the result.

starts-with(), and ends-with() in XPath 1.0

from lxml import etree

try:
    doc.xpath("//p[ends-with(@class, 'price')]")
except etree.XPathEvalError as e:
    print(e)
print(len(doc.xpath("//p[substring(@class, string-length(@class) - string-length('price') + 1) = 'price']")))
Unregistered function
2

starts-with() is part of XPath 1.0 and found the 3 classes that begin with "price". ends-with() arrived with XPath 2.0, and all three engines refused it. The substring() form compares the end of the string instead and found the 2 classes that end in "price".

Any attribute, and two texts at once

print(len(doc.xpath("//*[@*[contains(., 'lamp')]]")))
print(len(doc.xpath("//p[contains(., 'Price') and contains(., 'on sale')]")))
1
1

@* means every attribute of the element, so the first line finds elements with "lamp" in any attribute. To require two texts, join two contains() with and.

The same in Selenium and in the browser

Selenium sends your XPath to the browser. The WebDriver standard says the browser runs it with its own evaluate(), the function behind our Chrome column. So in Selenium with Chrome, the results on this page hold. All 20 cases gave the same answers in lxml, parsel and Chrome:

Twenty contains() cases and their matches in lxml, parsel and Chrome
Our own capture, 12 October 2026: contains_test.py on our server

These selectors usually run inside a scraper. Our Python scraping tutorial builds one. When a site limits your requests, see our guide to proxies for web scraping.

What this page could not check

  • One test page we made, in three engines: lxml, parsel and Chrome 143.
  • We did not run Firefox, Safari or an XPath 2.0 engine, and did not run Selenium itself.
  • We will rerun these tests by 12 April 2027.

Sources

  • W3C, XML Path Language (XPath) 1.0: w3.org.
  • W3C, XML 1.0, white space: w3.org.
  • W3C, XPath and XQuery Functions and Operators 3.1: w3.org.
  • lxml, XPath and XSLT with lxml: lxml.de.
  • W3C, WebDriver, the XPath locator strategy: w3.org.
  • Our own tests on our server, 12 October 2026: contains_test.py at 00:26 UTC (lxml 6.1.3, parsel 1.12.1, Chrome 143) and the code on this page at 00:25 UTC.

Frequently asked questions

How do I find an element that contains text with XPath?

Use contains(., 'text'), as in //p[contains(., 'on sale')]. The dot is the element's whole text, including text inside child tags. contains(text(), 'text') checks only the first text node, so it found nothing on our test paragraph, where a <b> tag split the text.

Is XPath contains() case sensitive?

Yes. contains(@title, 'Lamp') did not match 'desk lamp'. XPath 1.0, which lxml, Scrapy and browsers use, has no lower-case(), so lower the text with translate() first. That way the search found both titles.

How do I use contains() in a Selenium XPath?

Write the same XPath. Selenium passes it to the browser, which runs it with its own XPath 1.0 engine, as the WebDriver standard says. So the same rules apply: contains(.) for text and translate() for case. Chrome gave the same results as lxml on all 20 of our cases.

How do I match a class with XPath contains()?

contains(@class, 'price') also matches price_color and old-price. To match the class as a whole word, use contains(concat(' ', normalize-space(@class), ' '), ' price '). It found the 2 right elements, where the short form found 4.

How do I check whether an attribute contains a value?

For one attribute, //a[contains(@title, 'lamp')]. For any attribute, //*[@*[contains(., 'lamp')]]. Never search for an empty string: contains(@x, '') is true for every element, even those without the attribute.

Get proxies that are alive right now

Our free proxy list re-checks every exit every few minutes across 100+ countries, with a live last-checked time, so you copy IPs that worked moments ago, not a stale text dump. When the location has to survive a real check, the paid network holds up.

129M+ proxy checks run · 100+ countries · HTTP / HTTPS / SOCKS · re-checked every few minutes · no signup

HProxy.

Honest guides and comparisons on proxies, scraping and staying unblocked, from the team that runs the network.

RSS feed