Position and Indexing
position(), last(), numeric predicates, the difference between //li[1] and (//li)[1], and why ancestor::div[1] is the nearest ancestor rather than the outermost.
Position and Indexing
Indexes are the most fragile thing you can put in a locator, and also unavoidable in some places: “the third row”, “the last notification”, “the second Save button”. This lesson makes sure that when you do use one, it selects what you think it does.
Positions are 1-based
XPath counts from 1. [1] is the first node, [0] is nothing.
//li[1] first li child of each parent
//li[2] second li child of each parent
//li[last()] last li child of each parent
//li[last()-1] second-to-last
last() returns the size of the current context, so [last()] is always the final node, however many there are.
Per-parent versus global
A numeric predicate applies within the step it is attached to. For //li[1] the step is “li children of some parent”, so you get the first li of every parent.
<ul id="a"><li>A1</li><li>A2</li></ul>
<ul id="b"><li>B1</li><li>B2</li></ul>
//li[1] two results: A1 and B1
(//li)[1] one result: A1
(//li)[last()] one result: B2
//ul[2]/li[1] one result: B1
Parentheses turn the whole node-set into a single context, and then the index applies globally in document order. Reach for (...)[n] whenever you mean “the nth match on the page”.
position() with comparisons
[n] is shorthand for [position()=n]. The long form lets you use ranges:
//tr[position() > 1] all rows after the header row
//tr[position() <= 5] first five rows
//tr[position() >= 2 and position() <= 4] rows 2 to 4
//li[position() mod 2 = 0] even items
//li[position() != last()] everything except the last
position() is evaluated after the preceding predicates, so order matters:
//tr[@data-status='open'][1] first open row
//tr[1][@data-status='open'] first row, only if it is open
Reverse axes count backwards
This surprises almost everyone. On a reverse axis (ancestor, ancestor-or-self, preceding, preceding-sibling), position 1 is the node nearest the context node.
<div id="outer">
<div id="middle">
<div id="inner">
<button>Go</button>
</div>
</div>
</div>
//button/ancestor::div[1] #inner (nearest)
//button/ancestor::div[last()] #outer (furthest)
//button/preceding-sibling::*[1] the sibling immediately before the button
(//button/ancestor::div)[1] #outer, because parentheses reset to document order
So preceding-sibling::td[1] is “the cell just to the left”, which is usually what you want, and ancestor::form[1] is “the closest form”.
Anchoring an index safely
If you need an index, keep it inside a stable container so that unrelated page changes cannot shift it:
Fragile: (//button)[7] counts every button on the page. A new cookie banner breaks it.
Better: //div[@data-testid=‘toolbar’]//button[3] counts only inside the toolbar.
Best: //div[@data-testid=‘toolbar’]//button[@aria-label=‘Bold’] does not count at all.
Indexes in tables
Column indexes are the one place where a number is often the right choice, because the column position is part of the table’s meaning:
//tr[td[1][normalize-space()='Widget']]/td[3] price cell for the Widget row
//tr[normalize-space(td[1])='Widget']/td[last()] last cell in that row
Even so, computing the column from its header survives column reordering. See Working with Tables.
Selecting by count
You can filter a container by how many children it has:
//ul[count(li) = 0] empty lists
//ul[count(li) > 10] long lists
//tr[count(td) != count(//tr[1]/th)] rows whose cell count differs from the header
Try It Yourself
Open in Playground →
Then try //tbody/tr/td[2][1] and (//tbody/tr/td[2])[1] and note the difference in the match count.
Next Steps
- Logic: and, or, not - Replacing indexes with conditions
- XPath Axes Navigation - The reverse axes in context
- Lists and Repeated Components - Picking one card out of many without counting