In Selenium XPath, why does //tr[1] match several rows while (//tr)[1] matches only one?
answer
- Where does the bracket attach?
- Position is relative to something
- The counting restarts for each parent
- Parentheses change what gets indexed
- [1] is shorthand for position() = 1
basics
~10 sA predicate attaches to the step in front of it and is evaluated once per context node, so //tr[1] keeps the first row under every parent. Parentheses index the finished node-set instead.
solid answer
~40 s`//tr[1]` abbreviates `/descendant-or-self::node()/child::tr[1]`, and the `[1]` belongs to the `child::tr` step. That step runs once per context node, and the numbering restarts each time — so on a mooring page with three tables it keeps the first row of each `tbody` and returns three elements. Wrapping the path in parentheses first evaluates it to a single flat node-set; `(//tr)[1]` then means the first row in document order across the page. Positions are 1-based and `[1]` is shorthand for `[position() = 1]`, with `last()` giving the size of the same node-list. When you want exactly one element, either parenthesise or anchor the path — `//table[@id='mooring-chart']//tr[1]` — so only one context node can produce a match.
code
java · 24 linesimport java.util.List;
import org.openqa.selenium.By;
import org.openqa.selenium.WebDriver;
import org.openqa.selenium.WebElement;
import org.openqa.selenium.chrome.ChromeDriver;
public class MooringRowIndexes {
public static void main(String[] args) {
WebDriver driver = new ChromeDriver();
try {
driver.get("https://harbour.example/moorings");
List<WebElement> firstCellPerRow =
driver.findElements(By.xpath("//table[@id='mooring-chart']//tr/td[1]"));
WebElement topRow =
driver.findElement(By.xpath("(//table[@id='mooring-chart']//tr)[1]"));
WebElement lastRow =
driver.findElement(By.xpath("(//table[@id='mooring-chart']//tr)[last()]"));
System.out.println(firstCellPerRow.size());
System.out.println(topRow.getText() + " / " + lastRow.getText());
} finally {
driver.quit();
}
}
}go deeper
Remember that XPath counts from 1 and that square brackets filter a step. Knowing that //tr[1] is not automatically a single element is the part that catches people out first.
Explain that a predicate is bound to the step before it and re-evaluated per context node, and show the parenthesised form as the fix. Be able to say what last() and position() return.
Show the diagnosis habit: count the matches with findElements before concluding the predicate is wrong, then decide whether you meant one per parent or one on the page and change the expression accordingly.
Be ready to discuss why positional indexing into rendered lists is a maintenance liability across a large suite, and what you expect from the application so tests can address rows by identity instead.
## A predicate attaches to a step, not to the whole expression `//tr[1]` looks like "take the whole list of rows, keep the first". It is not. The expression abbreviates `/descendant-or-self::node()/child::tr[1]`, and the `[1]` belongs to the **`child::tr` step**. A step is evaluated once per **context node**, and each evaluation gets its own numbering that restarts at 1. On a mooring chart with three tables — reserved slips, transient slips, dry storage — every `tr` has a `tbody` for a parent. The step runs once per `tbody`, and each run keeps its own first `tr`. `//tr[1]` therefore matches **three** rows, one per table, and `findElement` quietly takes whichever comes first in document order. ## One-based, and relative to what XPath positions start at **1**, not 0. `[1]` is shorthand for `[position() = 1]`, and `position()` returns the node's index within the node-list the step is currently producing — not its index in the page: - `[1]` — first node produced by this step, for this context node. - `[last()]` — last one; `last()` returns the size of that same node-list. - `[last() - 1]` — the one before the last. - `[position() > 2]` — every node from the third onward. The rule that ties them together: in XPath 1.0, if a predicate expression evaluates to a **number**, it is compared against `position()`; anything else is converted to a **boolean**. That is why `[1]` filters by position while `[@class='slip']` filters by truth, using identical syntax. ## `//tr[1]` versus `(//tr)[1]` Parentheses force the path to be evaluated to completion first, producing one flat node-set; the predicate then indexes **that** set. | Expression | Evaluated as | Matches on a three-table page | |---|---|---| | `//tr[1]` | predicate on the `child::tr` step | first row of each table — three elements | | `(//tr)[1]` | predicate on the finished node-set | the single first row in document order | | `//tr[last()]` | predicate on the step | last row of each table — three elements | | `(//tr)[last()]` | predicate on the finished node-set | the very last row on the page | | `//table[@id='mooring-chart']//tr[1]` | anchored, then per-step | first row of that chart's `tbody` | If you want exactly one element, parenthesise, or anchor the path so only one context node can produce a match. ## Predicate order changes the result Two predicates in a row are applied left to right, each filtering the output of the last. Swapping them is not a no-op: - `//td[@class='boat'][1]` — keep the boat cells, then take the first of those per parent. You get one boat cell per row. - `//td[1][@class='boat']` — take the first cell of each row, then keep it only if it is a boat cell. On a chart whose first column is the slip number, this matches **nothing**. ## Combining conditions inside one predicate `and` and `or` join boolean tests in a single predicate, with `and` binding tighter than `or`: 1. `//td[@class='status' and normalize-space()='Reserved']` — a status cell whose trimmed text is exactly `Reserved`. 2. `//tr[td[@class='boat'] and td[@class='status']]` — a nested path inside a predicate is itself a boolean test: true when it selects at least one node. 3. `//td[@class='slip' or @class='boat']` — either column. A nested path used as a boolean is worth dwelling on, because it is how a predicate reaches sideways without leaving the row: `//tr[td[normalize-space()='Sea Wren']]` selects mooring **rows**, not cells, and uses the boat cell purely as a test. The step inside the bracket is evaluated with the `tr` as its context node, and the predicate is true whenever that inner path selects at least one node. One trap worth memorising: `//td[@class != 'boat']` and `//td[not(@class='boat')]` are **not** equivalent. A comparison against a missing attribute compares against an empty node-set and is always false, so the first form silently drops every cell with no `class` attribute at all; the second keeps them. ## Reading a failure When a chart locator returns the wrong mooring, the diagnosis order that pays is: 1. Count what the expression really matches — run it with `findElements` and print the size before assuming `findElement` picked badly. 2. Ask which **step** each predicate is bound to, then whether that step can run more than once. 3. If it can, decide whether you meant "one per parent" (leave it on the step) or "one on the page" (parenthesise). 4. Only then reach for a more specific anchor. `By.xpath` passes the string straight through to the remote end, so the numbering behaviour above is XPath's, identical in every language binding.
- How do //td[@class='boat'][1] and //td[1][@class='boat'] differ?Predicates apply left to right. The first keeps the boat cells and then takes the first of those under each parent, giving one boat cell per row. The second takes the first cell of each row and then keeps it only if it carries that class — on a chart whose first column is the slip number, it matches nothing.
- What is the predicate [2] shorthand for in XPath 1.0?`[position() = 2]`. XPath 1.0 says a predicate whose value is a number is compared against `position()`, and anything else is converted to a boolean. That single rule is why `[2]` filters by position while `[@class='slip']` filters by truth using identical syntax.
- How do you require two conditions of the same node in one predicate?Join them with `and`, as in `//td[@class='status' and normalize-space()='Reserved']`. `or` is available too and binds more loosely than `and`. Chaining two separate brackets works as well, but the order then matters, because each bracket filters the output of the one before it.
saying these in an interview costs you the question
- Believes //tr[1] always returns exactly one element
- Thinks XPath positional predicates count from zero
- Says parentheses around a path change nothing about the result
- Assumes two chained predicates can be swapped without changing matches
- Uses @class != 'boat' expecting it to keep cells lacking the attribute