XPath for Selenium — Axes, Functions and When XPath Beats CSS

CSS selectors handle most locator needs, but XPath fills the gaps CSS cannot reach. Need to find an element by its visible text? XPath. Need to navigate from a child element up to its parent? XPath. Need to combine text content with attribute conditions in a single expression? XPath. Understanding when XPath is the right tool — and when it is overkill — keeps your test suite both powerful and maintainable.

XPath Syntax — Axes, Functions and Practical Patterns

XPath navigates the DOM as a tree, using path expressions to select nodes. While its syntax is more verbose than CSS, it offers capabilities that CSS simply does not have.

from selenium.webdriver.common.by import By

# ── XPath patterns — when CSS cannot do the job ──

XPATH_PATTERNS = [
    # --- Text-based selection (CSS cannot do this) ---
    {
        "name": "Exact text match",
        "xpath": "//button[text()='Submit Order']",
        "matches": "<button>Submit Order</button>",
        "css_possible": False,
        "use": "Finding buttons/links by their visible label",
    },
    {
        "name": "Partial text (contains)",
        "xpath": "//button[contains(text(),'Submit')]",
        "matches": "Any button whose text includes 'Submit'",
        "css_possible": False,
        "use": "When text has dynamic suffixes (e.g. 'Submit (3 items)')",
    },
    {
        "name": "Normalise whitespace",
        "xpath": "//button[normalize-space()='Submit Order']",
        "matches": "Button with text 'Submit Order' ignoring extra spaces/newlines",
        "css_possible": False,
        "use": "Text matching when HTML has irregular whitespace",
    },

    # --- Parent / ancestor traversal (CSS cannot go upward) ---
    {
        "name": "Parent axis",
        "xpath": "//input[@id='email']/parent::div",
        "matches": "The <div> that is the direct parent of the email input",
        "css_possible": False,
        "use": "Navigating up to a container from a known child",
    },
    {
        "name": "Ancestor axis",
        "xpath": "//span[text()='Error']/ancestor::form",
        "matches": "The <form> that contains a <span> with text 'Error'",
        "css_possible": False,
        "use": "Finding a container based on content of a descendant",
    },
    {
        "name": "Following sibling",
        "xpath": "//label[text()='Email']/following-sibling::input",
        "matches": "The <input> that follows a <label> with text 'Email'",
        "css_possible": False,
        "use": "Targeting an input by the text of its label",
    },

    # --- Combining conditions ---
    {
        "name": "AND condition",
        "xpath": "//input[@type='text' and @name='search']",
        "matches": "Text input with name 'search'",
        "css_possible": True,
        "use": "Multiple attribute conditions (CSS can also do this)",
    },
    {
        "name": "OR condition",
        "xpath": "//input[@type='text' or @type='email']",
        "matches": "Input that is either text or email type",
        "css_possible": False,
        "use": "Matching elements that could be one of several types",
    },
    {
        "name": "Position (index)",
        "xpath": "(//div[@class='product-card'])[3]",
        "matches": "Third product card on the page",
        "css_possible": True,
        "use": "Selecting a specific item by index (1-based in XPath)",
    },

    # --- Anti-patterns to AVOID ---
    {
        "name": "AVOID: Absolute path",
        "xpath": "/html/body/div[1]/div[2]/form/div/input",
        "matches": "Fragile — breaks if ANY ancestor changes",
        "css_possible": True,
        "use": "NEVER — always use relative paths (//)",
    },
]

print("XPath Patterns — When CSS Cannot Do the Job")
print("=" * 70)

css_count = sum(1 for p in XPATH_PATTERNS if not p["css_possible"])
print(f"\n  {css_count} of {len(XPATH_PATTERNS)} patterns are XPath-exclusive (CSS cannot replicate)\n")

for p in XPATH_PATTERNS:
    exclusive = " [XPath-exclusive]" if not p["css_possible"] else ""
    print(f"  {p['name']}{exclusive}")
    print(f"    XPath: {p['xpath']}")
    print(f"    Use:   {p['use']}")
    print()
Note: XPath is 1-indexed, not 0-indexed. (//div[@class='item'])[1] selects the first item, not the second. This is the opposite of most programming languages and CSS’s :nth-child(1) (which is also 1-indexed, but developers often confuse the two). Getting the index wrong is a common source of “element not found” errors that are difficult to debug because the selector looks correct at a glance.
Tip: Use XPath’s contains() and normalize-space() together for robust text matching: //button[contains(normalize-space(), 'Submit')]. This handles elements where the text has leading/trailing whitespace, line breaks, or extra spaces between words — all common in real HTML. Without normalize-space(), an exact text match fails on <button> Submit </button> because the text includes invisible whitespace.
Warning: Absolute XPaths like /html/body/div[1]/div[2]/form/input are the most fragile locators in all of Selenium. They encode the exact position of every ancestor element. Adding a single wrapper div, reordering sections, or inserting a banner at the top of the page breaks every absolute XPath in your suite. Always start with // (descendant search) and target elements by attributes or text, not by their exact position in the DOM tree.

Common Mistakes

Mistake 1 — Using XPath when CSS can do the job

❌ Wrong: //input[@type='email'] — this works but is slower and more verbose than the CSS equivalent.

✅ Correct: input[type='email'] (CSS) — use XPath only when you need text matching, parent traversal, or OR conditions that CSS cannot express.

Mistake 2 — Copying XPath from Chrome DevTools without review

❌ Wrong: Right-clicking an element → “Copy XPath” → pasting /html/body/div[3]/div/div/ul/li[2]/a directly into your test.

✅ Correct: Using the copied XPath as a starting point, then rewriting it as a relative, attribute-based expression: //ul[@class='nav-menu']//a[text()='Products'].

🧠 Test Yourself

You need to find an input field that immediately follows a label with the text “Email Address”. CSS selectors cannot match by text content. Which XPath expression is correct?