Resource library

QA How-To

Playwright locator nth: Examples and Best Practices

Master playwright locator nth examples for lists, tables, frames, dynamic content, strictness, debugging, and stable zero-based element selection in UI tests.

23 min read | 2,724 words

TL;DR

`locator.nth(index)` returns a zero-based Locator for one match, with `nth(0)` selecting the first. Use it for real positional requirements, scope the collection first, assert its ready state, and verify identity before a consequential action.

Key Takeaways

  • Playwright locator indexes are zero-based, so nth(0) selects the first current match.
  • Use nth only when position is part of the requirement or no stronger identity exists.
  • Scope the collection to a meaningful component before selecting an index.
  • Assert collection readiness and selected-item identity before important actions.
  • Account for headers, hidden rows, sorting, pagination, and virtualization when indexing.
  • Prefer filters, roles, labels, or test IDs when the target is a business entity.
  • Treat strictness errors as useful evidence that a locator may be underspecified.

These playwright locator nth examples show how to select one element from an ordered collection without turning a stable Playwright test into a position-dependent gamble. locator.nth(index) is zero-based, returns another Locator, and keeps Playwright's auto-waiting and retry behavior for the selected match.

The syntax is small, but the design decision is important. An index is reliable only when order is part of the product contract or when every stronger identity has been exhausted. This guide covers runnable TypeScript examples, dynamic lists, tables, frames, strictness, debugging, and the review questions senior SDETs use before approving nth().

TL;DR

const rows = page.getByRole('row');
await expect(rows).toHaveCount(4);

const secondDataRow = rows.nth(2); // Header is index 0, data rows start at 1.
await expect(secondDataRow).toContainText('INV-1002');
Need Best starting point Use nth()?
Item has unique text or accessible name filter({ hasText }) or getByRole(..., { name }) Usually no
Item has a stable test contract getByTestId() Usually no
Product explicitly defines position Collection locator plus nth(index) Yes
Need first or last member first() or last() Prefer the readable helper
Need every stable member count() loop, all(), or collection assertion Use nth(i) in a loop when appropriate

The safest pattern is: define the collection semantically, wait for its expected shape, select the required position, and assert identity before performing a destructive action.

1. Understand zero-based Locator indexing

locator.nth(index) returns a Locator that resolves to the match at the supplied zero-based position. nth(0) selects the first match, nth(1) the second, and nth(2) the third. Creating the Locator does not query and freeze an element immediately. It stores a query that Playwright resolves when an action, assertion, or value operation runs.

import { test, expect } from '@playwright/test';

test('reads the third result', async ({ page }) => {
  await page.setContent(`
    <ol aria-label="Search results">
      <li>API testing</li>
      <li>UI automation</li>
      <li>Performance testing</li>
    </ol>
  `);

  const results = page
    .getByRole('list', { name: 'Search results' })
    .getByRole('listitem');

  await expect(results).toHaveCount(3);
  await expect(results.nth(2)).toHaveText('Performance testing');
});

The collection locator is scoped to a named list, so unrelated list items cannot affect the index. toHaveCount(3) establishes the state in which position 2 is meaningful. The final assertion verifies identity rather than trusting position alone.

Do not write await page.getByRole('listitem').nth(2) just because examples sometimes include await while assigning a Locator. nth() is synchronous and returns a Locator. The necessary await belongs on expect(...) or an action such as click().

An out-of-range Locator is still created successfully. It fails only when an operation needs a matching element, normally by timing out. If the list may legally be shorter, assert or branch on the collection count before using that index.

2. Playwright locator nth examples that actually run

The following complete spec uses page.setContent(), so it can run without an application server. It demonstrates selection, action, and an observable result.

import { test, expect } from '@playwright/test';

test('selects the second shipping method', async ({ page }) => {
  await page.setContent(`
    <form>
      <fieldset>
        <legend>Shipping method</legend>
        <label><input type="radio" name="shipping" value="standard"> Standard</label>
        <label><input type="radio" name="shipping" value="express"> Express</label>
        <label><input type="radio" name="shipping" value="pickup"> Store pickup</label>
      </fieldset>
      <output id="choice">Nothing selected</output>
    </form>
    <script>
      document.querySelectorAll('input').forEach(input => {
        input.addEventListener('change', () => {
          document.querySelector('#choice').textContent = input.value;
        });
      });
    </script>
  `);

  const methods = page.getByRole('radio');
  await expect(methods).toHaveCount(3);

  const express = methods.nth(1);
  await expect(express).toHaveAttribute('value', 'express');
  await express.check();

  await expect(express).toBeChecked();
  await expect(page.locator('output')).toHaveText('express');
});

In a real product, getByRole('radio', { name: 'Express' }) is clearer and more resilient. This example intentionally uses nth() to demonstrate the API, then protects the action with an identity assertion. That assertion matters if the business later inserts a free shipping option above Express.

For installation and runner configuration, see the Playwright automation guide. Keep examples runnable, but adapt locators to the accessible names and test contracts in your application.

3. Know when nth is appropriate and when it is a smell

Position is sometimes the requirement. A carousel may define the first visible slide, a ranked leaderboard may define the third place entry, and a spreadsheet-like grid may define row and column coordinates. In those cases, using an index communicates the behavior under test.

Position is a smell when it substitutes for identity. Clicking the second Delete button across an entire page says nothing about which record should be deleted. The test can pass against the wrong row after sorting, filtering, personalization, or insertion changes. A stable test locates the intended record first, then locates its Delete button.

// Fragile: global position stands in for record identity.
await page.getByRole('button', { name: 'Delete' }).nth(1).click();

// Stronger: record identity is explicit.
const project = page.getByRole('row').filter({ hasText: 'Mobile Checkout' });
await project.getByRole('button', { name: 'Delete' }).click();

Ask three questions before using nth(): Does the requirement mention order? Is the collection scoped to one meaningful component? Would inserting a valid peer before this item change the intended target? If the third answer is no, position is probably not the correct identity.

The official locator model treats first(), last(), and nth() as explicit ways to choose among multiple matches, but strictness is valuable feedback. A strictness error often reveals that the locator is underspecified. Do not automatically silence it with nth(0). Improve the locator unless choosing one by position is genuinely the requirement.

4. Scope nth inside cards, lists, and repeated controls

A robust nth locator begins with the smallest component that owns the order. Scope by a landmark, named list, dialog, table, or test ID before selecting a member. This prevents unrelated matches elsewhere in the page from shifting the index.

test('opens the middle recommendation', async ({ page }) => {
  await page.setContent(`
    <nav><a href="#help">Help</a></nav>
    <section aria-label="Recommended courses">
      <article><h2>API Foundations</h2><a href="#api">View course</a></article>
      <article><h2>Playwright Deep Dive</h2><a href="#pw">View course</a></article>
      <article><h2>Performance Basics</h2><a href="#perf">View course</a></article>
    </section>
  `);

  const recommendations = page
    .getByRole('region', { name: 'Recommended courses' })
    .getByRole('article');

  const middleCard = recommendations.nth(1);
  await expect(middleCard.getByRole('heading'))
    .toHaveText('Playwright Deep Dive');
  await middleCard.getByRole('link', { name: 'View course' }).click();
  await expect(page).toHaveURL(/#pw$/);
});

Notice that the index chooses the card, not a generic link. All later queries are relative to that card. This preserves the component boundary and makes a failure report easier to interpret.

If the heading text is the actual identity, eliminate nth() and filter the article by its heading. If the middle position itself is the requirement, retain nth(1) and assert the expected content. The same code shape can serve different test intents, so comments and assertions should reveal why the index exists.

Use Playwright getByTestId patterns when the application exposes a stable automation contract for a repeated component that has no useful accessible identity.

5. Select table rows and cells without off-by-one errors

Tables are the most common source of misleading indexes because header rows, hidden rows, expandable detail rows, and pagination can all affect the matched collection. Decide whether the index is relative to every row, only body rows, or visible business rows.

import { test, expect } from '@playwright/test';

test('checks amount in the second invoice row', async ({ page }) => {
  await page.setContent(`
    <table>
      <caption>Open invoices</caption>
      <thead><tr><th>Invoice</th><th>Amount</th><th>Status</th></tr></thead>
      <tbody>
        <tr><td>INV-1001</td><td>$40.00</td><td>Due</td></tr>
        <tr><td>INV-1002</td><td>$75.00</td><td>Review</td></tr>
        <tr><td>INV-1003</td><td>$20.00</td><td>Due</td></tr>
      </tbody>
    </table>
  `);

  const table = page.getByRole('table', { name: 'Open invoices' });
  const bodyRows = table.locator('tbody').getByRole('row');
  await expect(bodyRows).toHaveCount(3);

  const secondRow = bodyRows.nth(1);
  await expect(secondRow.getByRole('cell').nth(0)).toHaveText('INV-1002');
  await expect(secondRow.getByRole('cell').nth(1)).toHaveText('$75.00');
});

Scoping to tbody avoids counting the header. The row identity assertion catches unexpected sorting. Cell indexes are defensible when column order is explicitly fixed, but accessible column association or a stable cell contract may be clearer in complex grids.

Never copy a visual row number without inspecting the DOM. A virtualized grid may render only a window of rows, and an expanded record may insert another row element. If the requirement is invoice INV-1002, filter by that value. If the requirement is the second displayed result after sorting, assert the active sort and the full ordered identifiers before inspecting its cells.

6. Handle dynamic, sorted, filtered, and virtualized collections

Because a Locator resolves when used, items.nth(2) can point to a different DOM element after a re-render. This is a feature for stable queries, but a risk when index meaning changes during loading. Establish the final collection state before selecting or acting.

await page.getByRole('combobox', { name: 'Status' }).selectOption('open');

const results = page
  .getByRole('list', { name: 'Filtered tickets' })
  .getByRole('listitem');

await expect(results).toHaveCount(3);
await expect(results).toContainText(['Open', 'Open', 'Open']);

const thirdTicket = results.nth(2);
await expect(thirdTicket).toContainText('TKT-309');
await thirdTicket.getByRole('link', { name: 'Open ticket' }).click();

Avoid locator.all() while a collection is still changing. It returns the matches present at that moment and does not wait for the list to finish loading. A count assertion or an application-specific loading-state assertion should establish readiness first.

Virtualized lists need special treatment. The fifth rendered node is not necessarily the fifth record in the dataset, and scrolling can recycle the same node for another record. Prefer record identity, an accessible grid coordinate, or application-supported search. Use nth() only for the rendered order you can prove, and scroll through the user interface when that behavior is the test.

Sorting adds another precondition. Assert the sort control state and, where risk justifies it, assert the ordered list of visible labels with toHaveText([...]). Then a positional action has an evidence trail. Fixed sleeps do not prove readiness and should not replace a web-first assertion.

7. Compare nth, first, last, filter, and collection assertions

These APIs solve related but different problems. Choosing the most expressive one makes maintenance easier.

API or pattern Meaning Strong use case Main risk
locator.nth(2) Third current match Product-defined rank or coordinate Index shifts silently
locator.first() First current match First chronological or visual item Can hide ambiguous identity
locator.last() Last current match Latest item when order is guaranteed Sorting changes meaning
locator.filter({ hasText }) Match containing identity text Record, card, or row selection Text may be duplicated or localized
locator.filter({ has }) Match containing a child locator Semantic component selection Inner locator must be relative
expect(locator).toHaveText([...]) Entire ordered collection Proving count, content, and order Expected list can be too rigid

Use first() and last() when those words express the requirement better than numeric indexes. They are not inherently safer than nth(), but they communicate intent.

Filters usually outperform positions for business entities. hasText is concise, while has lets you identify a card by a descendant role or test ID. Collection assertions are ideal before an indexed action because they prove the shape and order the action relies on.

Do not chain position selectors merely to force uniqueness, such as page.locator('div').nth(4).locator('button').nth(1). That is a disguised DOM path. Build the component from roles, labels, text, or test contracts, and reserve one positional choice for the point where order carries meaning.

8. Iterate safely with count and nth

Using nth(i) inside a loop is appropriate when the test must inspect each member of a stable collection. Read the count after the page reaches its expected state, then operate on locators one at a time.

test('all skill tags are nonempty', async ({ page }) => {
  await page.setContent(`
    <ul aria-label="Required skills">
      <li>Playwright</li><li>TypeScript</li><li>API testing</li>
    </ul>
  `);

  const tags = page
    .getByRole('list', { name: 'Required skills' })
    .getByRole('listitem');

  await expect(tags).toHaveCount(3);
  const count = await tags.count();

  for (let index = 0; index < count; index += 1) {
    await expect(tags.nth(index), `tag at index ${index}`)
      .not.toHaveText(/^\s*$/);
  }
});

For exact expected values, one collection assertion is usually better:

await expect(tags).toHaveText(['Playwright', 'TypeScript', 'API testing']);

It verifies member count, content, and order with one retrying assertion. The loop is useful when each item needs the same structural rule, a nested assertion, or a separate action.

Be cautious when actions mutate the collection. Deleting rows.nth(i) while incrementing i skips elements because indexes shift after every deletion. If the requirement is to delete all rows, repeatedly delete rows.first() until the expected empty state, or capture stable record identities and delete by identity. Also avoid concurrent actions against the same page with Promise.all(). Browser interactions generally need deliberate sequencing even when the test code can create promises.

9. Use nth with frames, shadow DOM, and nested locators

The same zero-based idea applies after narrowing inside a frame or component. Enter the correct frame with frameLocator(), then define a semantic collection and select the position.

const widget = page.frameLocator('iframe[title="Plan selector"]');
const plans = widget.getByRole('radio');

await expect(plans).toHaveCount(3);
const teamPlan = plans.nth(1);
await expect(teamPlan).toHaveAccessibleName('Team');
await teamPlan.check();

FrameLocator also supports positional methods when multiple frames match, but frame order is rarely a good identity. Prefer a unique title, test ID, or surrounding component. A positional frame locator should come with an assertion that proves which embedded application was selected.

Playwright locators pierce open shadow roots by default for normal CSS and user-facing locator strategies. You can scope to the custom element and select repeated descendants as usual. Closed shadow roots remain inaccessible, and XPath does not pierce shadow roots.

Nested nth() calls can be legitimate for a true matrix, such as the second row and third cell. They become fragile when they reconstruct markup structure. Name each level so the code reads in domain terms:

const pricingGrid = page.getByRole('table', { name: 'Pricing comparison' });
const professionalRow = pricingGrid.getByRole('row').filter({ hasText: 'Professional' });
const annualPriceCell = professionalRow.getByRole('cell').nth(2);
await expect(annualPriceCell).toHaveText('$240');

Only the column remains positional, while the plan is selected by identity.

10. Debug strictness and out-of-range failures

When an nth test fails, first classify the failure. A strictness error before nth() means another single-element operation was attempted on an ambiguous Locator. A timeout on items.nth(4) often means fewer than five matches existed, the item never became actionable, or the collection was scoped incorrectly.

Use targeted evidence during diagnosis:

const items = page.getByRole('listitem');
console.log('item count:', await items.count());
console.log('item texts:', await items.allTextContents());
await expect(items.nth(2)).toBeVisible();

Remove broad logging after the issue is fixed, especially when lists can contain personal or confidential data. Trace viewer is usually better evidence because it connects the action, DOM snapshot, locator, network, and console state. Run a focused test with trace enabled and inspect the matched collection at the failure step.

Do not respond to an out-of-range failure by raising the timeout. If the list has the wrong count, waiting longer may only delay the same result. Assert the expected count or the business completion state earlier. If the list is legitimately variable, branch on a well-defined condition or choose by identity.

The Playwright timeout troubleshooting guide helps separate assertion, action, navigation, and whole-test timeouts. For visibility and actionability failures, review waiting for visible, enabled, and stable elements.

11. Review playwright locator nth examples before merging

A senior review should begin with intent. Ask the author to state why position is the product contract. If the answer names a record, label, or ID rather than a rank or coordinate, replace the index with identity-based scoping.

Next, inspect the collection boundary. It should start from a named table, list, region, dialog, card set, or explicit test contract, not the entire document. Check whether hidden templates, headers, skeletons, mobile duplicates, or detail rows can join the match set. Require a web-first assertion that establishes the collection state before the indexed action.

Then inspect consequences. A read-only assertion against the wrong item is bad, but a positional Delete, Approve, Pay, or Send action is riskier. Assert identity immediately before destructive actions and verify the record-specific outcome afterward. Ensure sorting, filtering, pagination, and virtualization assumptions are visible in the test.

Finally, run the test across relevant browser projects and with realistic data order. A reliable test should fail for a meaningful reason if a valid item is inserted ahead of the target. If insertion changes the business meaning, the index is appropriate. If insertion merely redirects the test to the wrong entity, the locator needs redesign.

Interview Questions and Answers

Q: Is Playwright locator.nth() zero-based or one-based?

It is zero-based. nth(0) is the first match and nth(2) is the third. I name indexes or use first() and last() when that makes intent clearer, and I establish the collection state before relying on a position.

Q: Does nth() return an ElementHandle?

No. It returns another Locator. The selected match is resolved when an action or assertion runs, which preserves locator retry behavior and allows the current DOM to be queried after re-renders.

Q: Why can adding nth(0) to fix strictness be dangerous?

It suppresses ambiguity without proving that the first match is the intended entity. A new valid element can appear earlier and redirect the test. I first improve scoping or use record identity, then use a position only when order is the requirement.

Q: How do you avoid off-by-one errors in a table?

I scope rows to tbody when headers should not count, assert the expected row count, and verify row identity before using cell indexes. I also inspect whether hidden, expanded, or virtualized rows share the same role.

Q: What happens if the nth index does not exist?

The Locator can still be created, but an assertion or action that needs the element waits and eventually fails. I assert the collection's expected count or readiness state so the failure explains the missing precondition.

Q: When would you use nth() in a strong test?

I use it for product-defined positions such as rank, ordered steps, fixed grid coordinates, or a test that explicitly verifies displayed order. I keep the collection narrowly scoped and normally assert the selected member's identity before acting.

Q: How do all() and an nth loop differ?

all() immediately returns locators for the current matches and does not wait for a dynamic list to finish loading. A count assertion followed by count() and nth(i) can establish readiness first and resolve each locator during the loop. For exact ordered values, a single collection assertion is often simpler.

Common Mistakes

  • Treating nth(1) as the first element instead of the second.
  • Adding first() or nth(0) only to silence a strictness error.
  • Indexing every matching element on the page instead of scoping to a component.
  • Clicking a destructive control before asserting the selected record's identity.
  • Counting table headers, hidden templates, skeleton rows, or expanded detail rows.
  • Using a fixed wait instead of asserting the collection's ready state.
  • Assuming the rendered index in a virtualized list equals the dataset index.
  • Deleting items in an increasing index loop while the collection shrinks.
  • Using nested position chains as a substitute for semantic locators.
  • Raising a timeout when the requested index is impossible.
  • Awaiting nth() itself and misunderstanding when locator resolution happens.
  • Keeping an index after the requirement changes from order to entity identity.

Conclusion

The best playwright locator nth examples make position explicit, scoped, and verifiable. Build a semantic collection, establish its final state, select the zero-based index, and assert identity before an important action. That approach keeps the convenience of nth() without surrendering test intent.

Use filters, roles, labels, and test IDs when an entity has a stable identity. Use nth(), first(), or last() when order itself is what the user sees and what the requirement promises. Review one existing positional locator in your suite with those rules and strengthen its preconditions today.

Interview Questions and Answers

Explain how Playwright locator nth works.

`locator.nth(index)` creates a zero-based Locator for one match in a collection. It does not eagerly capture a DOM node. The target resolves when an action or assertion runs, so the query reflects the current DOM and retains locator auto-waiting behavior.

When is nth a better choice than filter?

It is better when position is the behavior, such as third place, the first visible slide, or a fixed grid coordinate. A filter is better when the target has business identity, such as a user name or invoice number. I make that intent explicit in the assertion.

Why is nth(0) not a universal strictness fix?

It chooses the first ambiguous match without proving that it is correct. DOM insertion, responsive duplicates, or a new valid record can silently redirect the action. I treat strictness as a prompt to improve scoping unless the first position is the requirement.

How would you make a positional Delete action safer?

I scope the collection to its owning component, establish the expected order, select the position, and assert the selected record's identity immediately before clicking Delete. I then verify a record-specific outcome, not only a generic success message.

What locator risks exist in virtualized lists?

Rendered nodes may represent only a window of the dataset and may be recycled while scrolling. A DOM index can differ from the business index. I prefer record identity or supported grid semantics and use position only for the rendered order I can prove.

How do you diagnose an out-of-range nth locator?

I inspect the collection count, matched text, scope, hidden elements, filtering, sorting, and trace snapshot. I determine which readiness condition was missing. Increasing timeout is useful only when the state is valid but legitimately slow.

How should nth be used in collection iteration?

I first establish a stable collection state and read the count. I iterate sequentially with `nth(i)` for structural checks or nested behavior. If an action mutates the collection, I use stable identities or deliberately repeat `first()` instead of incrementing through shifting indexes.

Frequently Asked Questions

Is Playwright locator nth zero-based?

Yes. `nth(0)` selects the first match, `nth(1)` the second, and so on. An out-of-range Locator is created, but an operation on it eventually fails because no element resolves.

What does locator.nth return in Playwright?

It returns another Locator, not an ElementHandle and not a Promise. The match is resolved when you perform an action or assertion, so normal locator retry and auto-waiting behavior remains available.

Should I use nth to fix a Playwright strict mode violation?

Only when the position is genuinely part of the requirement. Otherwise, scope the Locator or identify the intended item by role, name, text, child content, or a stable test ID.

How do I select the last matching element in Playwright?

Use `locator.last()` when the last position is the intended behavior. It communicates intent more clearly than calculating an index, but it still depends on stable collection ordering.

How do I use nth with a Playwright table?

Scope to the named table and usually to `tbody` before selecting a row. Assert the row count and identity, then use a cell index only if column order is a stable product contract.

Why does an nth locator time out?

The collection may have fewer matches than expected, the selected element may never become actionable, or the collection may be scoped incorrectly. Inspect count, text, trace evidence, and the application readiness state before changing timeouts.

Can I iterate a locator with nth?

Yes. Wait for the collection to stabilize, read its count, and use `locator.nth(i)` in a sequential loop. If exact content and order are known, a single array-based locator assertion is often clearer.

Related Guides