

Paste HTML, type an XPath, see every match highlighted live. Browser-native XPath 1.0 — no server round-trip, nothing leaves your machine.
Looking for the CSS equivalent? Try the CSS Selector Tester.
Try a sample:
XPath is a query language for navigating XML and HTML documents by structure, not just by tag name. Where a CSS selector says "give me every .price", an XPath can say "give me every span whose class contains price, that lives inside a div with data-product-id, but only the second one." That precision matters when you're scraping pages where class names are obfuscated or repeat across unrelated sections.
Scrapers reach for XPath when they need axis navigation (parent::, following-sibling::), text matching (contains(text(), 'Sold out')), or attribute predicates that CSS can't express. Browsers, lxml, and parsel all ship XPath 1.0, so the same expression works in your browser dev tools and in your Python scraper.
| Pattern | What it matches | Example |
|---|---|---|
| //tag | All descendants with this tag | //a → every link |
| /html/body/div | Absolute path from the root | /html/body//h1 |
| * | Any element | //div/* |
| @attr | Attribute value | //img/@src |
| text() | Text node child | //span/text() |
| [@attr="val"] | Attribute equals predicate | //a[@rel="nofollow"] |
| contains() | Substring match | //div[contains(@class,'price')] |
| starts-with() | Prefix match | //a[starts-with(@href,'/p/')] |
| [n] | nth child of its kind | //ul/li[1] |
| (...)[n] | nth across the whole set | (//img)[1] |
| last() | Last in a node set | //li[last()] |
| .. | Parent | //span[@class='price']/.. |
| following-sibling:: | Sibling after this node | //h2/following-sibling::p[1] |
| ancestor:: | Any ancestor | //span[@class='price']/ancestor::article |
| not() | Negation | //a[not(@rel)] |
CSS selectors are shorter, faster to read, and supported by every browser API (querySelectorAll) plus every scraping library. Use them as your default.
Reach for XPath when you need: text content matching (CSS can't query text), axis traversal (parents, ancestors, following-sibling), positional predicates across the whole result set ((//div)[3] vs CSS's :nth-of-type which is per-parent), or attribute substring matching with conditions that compose more cleanly than CSS attribute selectors. Try the same query both ways in the CSS Selector Tester — for a given page you'll quickly feel which one fits.
lxml has native XPath support. BeautifulSoup doesn't, but it can hand off to lxml or parsel.
# pip install lxml requests beautifulsoup4
import requests
from lxml import html
resp = requests.get("https://example.com")
tree = html.fromstring(resp.content)
# Get every product title
titles = tree.xpath("//a[@class='product-title']/text()")
# Or, BeautifulSoup users can hand off to lxml:
from bs4 import BeautifulSoup
soup = BeautifulSoup(resp.content, "lxml")
# BeautifulSoup itself has no XPath; for that you can either
# use SoupSieve (CSS only) or pass soup.encode() back into lxml.Parsing XHTML/SVG/XML often sets a default namespace, and XPath 1.0 has no concept of a default namespace — unprefixed names won't match. Either parse as HTML (which ignores the namespace) or register a prefix and write //x:div instead of //div.
XPath 1.0 compares strings case-sensitively. //input[@type="Text"] won't match <input type="text">. Use translate(@type, 'ABCDEFGHIJKLMNOPQRSTUVWXYZ', 'abcdefghijklmnopqrstuvwxyz')="text" for case-insensitive matching, or normalize the input upstream.
text() returns text NODES, not a string — predicates like [text()='Sold out'] only match if the FIRST text node equals that. Use [normalize-space(.)='Sold out'] to compare the full concatenated text of the element.
[@class='price'] only matches if the WHOLE class attribute is exactly 'price'. Real HTML usually has multiple classes ('price price--current'), so use [contains(concat(' ',normalize-space(@class),' '),' price ')] for an exact word match, or simpler contains(@class,'price') if substring overlap is fine.
Try It For Free. No Subscription Required. No Credit Card Required. Instant Set-Up. 150 Free Requests Are Waiting For You!