Resource library

QA How-To

API pagination testing: A Practical Guide (2026)

Learn API pagination testing for offset, page, cursor, and keyset designs with runnable pytest examples, boundaries, mutable data, security, and performance.

24 min read | 3,304 words

TL;DR

API pagination testing verifies that clients can traverse an authorized filtered collection without missing or duplicating records, in a stable documented order, and eventually terminate. Test boundaries, metadata, limits, malformed cursors, sort ties, mutable data, and security for the specific page, offset, cursor, or keyset model.

Key Takeaways

  • Define pagination correctness through coverage, uniqueness, order, termination, filter consistency, and authorization invariants.
  • Use deterministic data with unique sort ties so tests can expose unstable ordering rather than accidentally pass.
  • Test page-number and offset boundaries separately from cursor or keyset opacity and traversal rules.
  • Traverse the complete result set and assert no missing or duplicate identities, not only the first and last page sizes.
  • Specify how inserts, updates, and deletes between requests affect snapshot consistency, duplicates, and gaps.
  • Treat cursors as untrusted opaque input and verify tampering, cross-user reuse, filter changes, and expiry fail safely.
  • Measure late-page behavior and payload limits in a production-like dataset without turning every functional pipeline into a load test.

API pagination testing proves that a client can traverse a collection in the documented order without missing, duplicating, leaking, or looping over records. A first page with the expected item count is not enough. Correctness spans boundaries, sort stability, filters, metadata, cursor lifecycle, concurrent mutations, authorization, termination, and performance on later pages.

Pagination bugs are often quiet. A user sees nineteen of twenty orders, an export duplicates an invoice, or a cursor created for one tenant reveals another tenant's data. The endpoint still returns 200, so shallow tests pass. This guide develops stronger invariants and runnable pytest and HTTPX examples for page, offset, cursor, and keyset designs.

TL;DR

Pagination model Request example Main strengths Main test risks
Page number page=3&size=25 Familiar UI navigation Index base, last page math, data drift
Offset and limit offset=50&limit=25 Simple arbitrary positioning Slow deep offsets, duplicates and gaps during writes
Cursor cursor=opaque&limit=25 Encapsulates continuation state Tampering, expiry, loops, filter or identity binding
Keyset afterCreatedAt=...&afterId=... Efficient stable forward traversal Composite ties, encoding, sort direction

For every model, assert authorized set coverage, identity uniqueness, documented ordering, correct boundaries, parameter validation, and termination. Then test the model's declared consistency when data changes between page requests.

1. Define API Pagination Testing Through Invariants

Begin with the complete authorized result set for a fixed query. A correct traversal should satisfy coverage, meaning every item that belongs under the contract appears as promised; uniqueness, meaning no identity appears twice unless duplicates are explicitly meaningful; ordering, meaning every adjacent item follows the documented total order; filter preservation, meaning every item still meets the original filter; and termination, meaning following continuation information eventually stops without a cursor loop.

The phrase as promised matters because a live collection can change between requests. An API may offer snapshot consistency, where all pages represent one logical snapshot, or live keyset behavior, where forward movement follows current data after the last seen key. Offset pagination often provides no strong cross-request snapshot. Tests need the documented model before deciding whether an inserted record is missing or correctly excluded.

Pagination also has a shape contract. Define default size, minimum and maximum size, whether indexes are zero-based or one-based, last-page behavior, empty collection behavior, parameter names, next and previous links, totals, and error semantics. A total may represent exact filtered count, estimated count, or no count. Do not assert an exact total if the API intentionally provides an estimate.

Identity and order must be explicit. Sorting only by createdAt is not total when two records share a timestamp. Add a unique tie-breaker such as id, and specify direction for every key. Without a total order, repeated requests can move equal-valued records between pages even when data is unchanged.

2. Compare Page, Offset, Cursor, and Keyset Pagination

Page-number pagination is usually translated into an offset. It is convenient for a user interface that jumps to page 7, but the meaning of page 7 drifts as earlier rows are inserted or removed. Test index semantics carefully. A mismatch between a one-based public API and zero-based storage calculation commonly skips the first group or repeats it.

Offset and limit expose the position directly. The expected slice for a static list is items[offset:offset+limit]. This makes deterministic oracle construction easy. Deep offsets can become expensive because a datastore may scan or discard many earlier rows. Under concurrent inserts before the current offset, the next request can repeat records; deletes can create gaps.

Cursor pagination returns an opaque continuation token. The token can encode the last sort key, snapshot identity, filter hash, expiry, and other state. Clients should store and return it unchanged rather than parse it. Servers must validate it as untrusted input, bind it to relevant context, and prevent tampering or cross-user replay.

Keyset pagination exposes or internally uses the last seen sort values, such as (created_at, id). A descending continuation predicate might be created_at < last_created_at OR (created_at = last_created_at AND id < last_id). The second term prevents ties from disappearing. Keyset is efficient for forward traversal but does not naturally jump to an arbitrary numbered page.

Requirement Page or offset Cursor or keyset
Jump to arbitrary position Natural Usually not offered
Stable under earlier inserts Weak without snapshot Stronger with correct continuation semantics
Deep-page database cost Can increase significantly Usually bounded by indexed seek
Client readability High Cursor should remain opaque
Security concern Large offset abuse Token tampering and context replay
Essential test Exact static slices Complete traversal and token lifecycle

Choose the model from product navigation and consistency needs, not because one style is fashionable.

3. Turn the Pagination Contract into a Test Inventory

Create an inventory for request, response, ordering, mutation, and security. Request cases include omitted size, minimum, typical, maximum, above maximum, zero, negative, nonnumeric, repeated parameter, and incompatible combinations. Decide whether above-maximum size is rejected or clamped. Silent clamping can be valid if documented, but the response should make the effective size understandable.

Response cases include empty collection, one item, exactly one full page, one more than a full page, partial last page, beyond-end page, and the last continuation. Verify item array type, effective limit, current position, next or previous presence, and link parameter preservation. If links are URLs, confirm proper encoding and an approved origin. If cursors are separate fields, confirm null, omission, or empty semantics consistently indicate termination.

Order cases include every supported sort field and direction, duplicate primary sort values, null values, mixed case or locale if strings are sortable, and invalid sort fields. Filters and search terms must persist through every continuation request. If the next link drops status=paid, the second page can silently mix unrelated data.

Security cases include filter values that select another tenant, cursor reuse by another user, cursor reuse after permission changes, modified token bytes, oversized tokens, expired tokens, and cursors applied to a different endpoint or sort. Error bodies should follow the same safe contract described in API error handling and negative testing.

Trace each case to a clear expected behavior. Avoid assert status in [400, 404, 422]; that test permits regressions in client semantics. Pick the documented code and stable error identifier.

4. Create Deterministic Data and Independent Oracles

A high-value fixture contains enough records to cross several pages and deliberately creates sort ties. For example, create eleven orders across two statuses, with several pairs sharing the same createdAt but unique IDs. Include records belonging to another tenant that must never appear. Record the expected authorized IDs independently in setup order, then sort them in test code according to the public contract.

Do not derive the oracle by calling the same endpoint without pagination. That can repeat the server's sorting or authorization bug and make both results agree. Prefer a supported fixture API that returns created identities, a trusted repository query in a component test, or a known seeded dataset. Keep data isolated per run with a tenant or unique marker.

For ordering, compare adjacent tuples rather than only the first and last element. A descending order with tie-breaker can be expressed as a list of (createdAt, id) tuples equal to sorted(tuples, reverse=True) if both fields share direction. Mixed directions need an explicit comparator or normalized keys. Check uniqueness through IDs, not complete object equality. Two valid rows can have identical visible values.

Totals deserve a separate oracle. Exact total should match authorized filtered records and remain consistent with the promised snapshot. If total is expensive and intentionally absent, do not reconstruct it in the API merely to satisfy a test. A hasNext flag should agree with continuation presence, but testing both is useful because contradictory metadata breaks clients.

Cleanup should delete the fixture collection through an approved test interface. It must run even when traversal fails. Never rely on random ordering from a shared dataset, and never let a test pass merely because an expected ID happened to fall outside the first page.

5. Test Offset Pagination with pytest and HTTPX

The example below targets a test Orders API and assumes fixtures create seven authorized orders with a stable ascending (createdAt, id) order. Install pytest and httpx, set API_BASE_URL and a short-lived API_TEST_TOKEN, and adapt the allowed hosts. The HTTPX client uses a bounded timeout and does not follow redirects automatically.

# conftest.py
import os
import httpx
import pytest

_ALLOWED_HOSTS = {"127.0.0.1", "localhost", "orders.test.example"}

@pytest.fixture(scope="session")
def api_client():
    base_url = os.getenv("API_BASE_URL", "http://127.0.0.1:8080")
    if httpx.URL(base_url).host not in _ALLOWED_HOSTS:
        raise RuntimeError("Pagination tests require an approved test host")

    with httpx.Client(
        base_url=base_url,
        headers={"Authorization": f"Bearer {os.getenv('API_TEST_TOKEN', 'local-test-token')}"},
        timeout=httpx.Timeout(5.0, connect=2.0),
        follow_redirects=False,
    ) as client:
        yield client
# test_offset_pagination.py
import pytest

def get_page(client, *, offset, limit, marker):
    response = client.get(
        "/v1/orders",
        params={
            "offset": offset,
            "limit": limit,
            "sort": "createdAt,id",
            "testMarker": marker,
        },
    )
    assert response.status_code == 200, response.text
    return response.json()

def test_offset_pages_cover_static_dataset(api_client, order_fixture):
    expected_ids = order_fixture["ordered_ids"]
    marker = order_fixture["marker"]
    collected = []

    for offset in range(0, len(expected_ids), 3):
        page = get_page(
            api_client, offset=offset, limit=3, marker=marker
        )
        assert page["offset"] == offset
        assert page["limit"] == 3
        collected.extend(item["id"] for item in page["items"])

    assert collected == expected_ids
    assert len(collected) == len(set(collected))

@pytest.mark.parametrize(
    "offset,limit,expected_status",
    [(-1, 10, 400), (0, 0, 400), (0, 101, 400)],
)
def test_rejects_invalid_window(
    api_client, offset, limit, expected_status
):
    response = api_client.get(
        "/v1/orders", params={"offset": offset, "limit": limit}
    )
    assert response.status_code == expected_status
    assert response.json()["code"] == "INVALID_PAGE_WINDOW"

order_fixture is application-specific setup that returns a marker and independent expected IDs. That is intentionally not invented as an HTTPX or pytest API. The pagination calls and assertions are directly runnable when the fixture is implemented for the tested service.

6. Traverse and Validate Cursor Pagination

A cursor traversal helper must preserve the original filters and sort, replace only the cursor, detect repeated cursors, and stop at the documented terminal representation. It should not inspect token content. The following generator yields every item and fails fast if the server creates a loop.

# cursor_helpers.py
def iterate_cursor(client, path, *, params):
    request_params = dict(params)
    seen_cursors = set()

    while True:
        response = client.get(path, params=request_params)
        assert response.status_code == 200, response.text
        body = response.json()

        for item in body["items"]:
            yield item

        next_cursor = body["pageInfo"].get("nextCursor")
        if next_cursor is None:
            break
        assert next_cursor not in seen_cursors, "cursor loop detected"
        seen_cursors.add(next_cursor)
        request_params["cursor"] = next_cursor
# test_cursor_pagination.py
from cursor_helpers import iterate_cursor

def test_cursor_traversal_is_complete(api_client, order_fixture):
    items = list(iterate_cursor(
        api_client,
        "/v2/orders",
        params={
            "limit": 3,
            "status": "paid",
            "sort": "-createdAt,-id",
            "testMarker": order_fixture["marker"],
        },
    ))

    ids = [item["id"] for item in items]
    assert ids == order_fixture["paid_ids_desc"]
    assert len(ids) == len(set(ids))
    assert all(item["status"] == "paid" for item in items)

Add a maximum-page guard derived from fixture size to protect CI from a server that emits a new nonrepeating cursor forever. A production client also needs a defensive request budget and cancellation. For the test, repeated-token detection gives a precise diagnostic for the common loop case.

Verify cursor opacity. The client code should not base64-decode a token, replace sort fields, or manufacture a previous token. Server upgrades may change encoding while preserving the opaque contract. If backward traversal is supported, test previousCursor separately and define whether reversing direction changes item order within the returned page.

7. Cover Boundaries, Links, and Invalid Inputs

Empty collections should return 200 with an empty items array and terminal metadata unless the API documents another representation. Test a collection with exactly limit records because implementations often set hasNext by checking len(items) == limit, which is insufficient. A common technique fetches limit + 1 internally or uses a verified continuation query. Your test should assert there is no next cursor when no further authorized item exists.

For one record beyond the limit, the first response should advertise continuation and the final response should contain one record with no continuation. Request a position beyond the end for page or offset models and assert the contract, usually an empty page or explicit range error. Do not assume page totals remain present on an empty beyond-end result unless documented.

Validate next links as client inputs. Parse them with a URL library, confirm their scheme and host are approved, and verify original filter, sort, effective limit, and encoding. A malicious or misconfigured absolute link can redirect credentials to another host. Some APIs avoid that risk by returning relative links or cursor fields. Tests should not blindly follow an arbitrary URL with authorization attached.

Invalid cursors include a single changed character, truncation, oversized input, wrong encoding, expired token, token from another endpoint, and token combined with a changed filter or sort. Each should fail with the documented 4xx and safe error code. Never allow a malformed token to produce 500 with decoder or cryptographic details.

Test parameter pollution according to gateway behavior. limit=10&limit=1000 may be interpreted as first, last, list, or rejection by different layers. Define and test one policy at the public boundary so an attacker cannot bypass a limit by exploiting disagreement between proxy and application.

8. Test Mutable Collections and Concurrent Changes

Static fixtures prove arithmetic and traversal, but production collections change. Define mutation scenarios relative to the current continuation: insert before it, insert after it, delete an unseen item, delete a seen item, update a sort key forward, and update it backward. Expected behavior depends on the consistency model. Document it rather than promising an impossible universal snapshot.

For offset pagination, insert a new item before the current window after fetching page one. A subsequent offset can repeat the last item from page one because every existing position shifted. If this is accepted behavior, the client may need deduplication and the documentation should say so. If exports require exact once-only coverage, offset without a snapshot is the wrong contract.

For keyset pagination, an insert before the last seen key should normally not reappear during forward traversal. An insert after it may appear. Updating an unseen item's sort key to before the cursor can make it absent from that traversal. Snapshot-bound cursors can instead keep a stable membership view, at the cost of snapshot storage or database transaction semantics. Test whichever guarantee is offered.

Use a controllable clock and timestamps to create deterministic ties. Coordinate mutations after page one returns, not with a fixed sleep. Then complete traversal and compare against the expected membership rule. Run the same case across multiple API instances if cursor state is encoded or stored in a distributed cache. A cursor valid only on the instance that created it will fail behind a load balancer.

Soft deletion and permission changes also mutate visibility. A user's role can be revoked between pages. Authorization must be evaluated according to policy, and a cursor must never freeze broader access than the user currently has unless a carefully reviewed export snapshot contract explicitly allows it.

9. Evaluate Performance, Caching, and Client Recovery

Functional correctness at ten records says little about deep offset behavior. Create a production-like indexed dataset in a performance environment and measure first, middle, and late windows. Capture database query plans and rows examined where available. Avoid fabricated universal thresholds. Set budgets from the product's latency objective and infrastructure baseline, then compare trends across changes.

Test maximum limit because serialization, memory, database fetch size, response compression, and gateway limits interact. A page that is legal by count can still exceed practical response size when records contain large fields. Consider sparse collection representations or field selection. Enforce a server maximum and verify the client handles it.

Caching semantics differ. A stable public page may use cache keys containing every filter, sort, position, and authorization dimension. A cursor might be user-bound and unsuitable for shared caches. Test that cached data does not cross tenants and that continuation links remain coherent after a cache hit. Sensitive cursor responses usually need conservative cache controls.

Clients should detect cursor expiry and restart or reconcile according to product needs. An infinite background export should checkpoint processed stable IDs, not assume a cursor lives forever. Test network retry of the same page request: it should return an equivalent page under the cursor contract and should not advance hidden server state merely because it was requested. Stateful one-use cursors make transport retries fragile and require explicit design.

Observe page size, page latency, invalid-cursor rate, expired-cursor rate, traversal abandonment, and deep-offset usage with low-cardinality metrics. Do not record full cursors or customer filters in logs. A hashed diagnostic token may still be sensitive and should follow retention policy.

10. Scale API Pagination Testing in CI

Put deterministic small-dataset cases in every change pipeline: empty, one, exact limit, limit plus one, multi-page traversal, invalid parameters, sort ties, filter preservation, authorization, and terminal metadata. Run both minimum and maximum supported sizes where payload cost is safe. Keep fixture creation and expected identities independent of the list endpoint.

Add component-level mutation tests for precise interleavings. Add distributed integration tests for cursor validity across instances, signing-key rollout, and shared cursor storage. Run large-data performance and long traversal tests on schedule or before release. This layering preserves fast feedback while testing operational reality.

Generate a compact coverage matrix by endpoint and model. Record supported sort and filter combinations, consistency guarantee, maximum size, token expiry, cross-region behavior, and authorization binding. A shared helper can traverse and check invariants, but endpoint tests must still specify domain identity and correct order. Avoid a generic helper that assumes every response uses items and nextCursor if APIs do not.

When an OpenAPI document describes parameters and response schemas, validate those structures and generate boundary candidates, then add semantic invariants manually. See generating API tests from OpenAPI with AI for a controlled generation workflow. Schema checks cannot discover duplicates across pages or incorrect mutation consistency.

Treat any missing or duplicate production record as a correctness incident, not a cosmetic issue. Preserve sanitized page positions, sort values, fixture IDs, and request correlation in failure evidence so developers can reproduce the boundary.

Interview Questions and Answers

Q: How do you test a paginated API?

I start with the contract for model, index base, limits, total order, filters, metadata, and mutation consistency. I traverse a deterministic collection and assert coverage, uniqueness, order, authorization, and termination. Then I add boundaries, invalid inputs, concurrent changes, security, and late-page performance.

Q: What is the difference between offset and cursor pagination?

Offset identifies a numeric position and supports arbitrary jumps, but earlier inserts or deletes can shift later windows and deep offsets can be costly. A cursor carries opaque continuation state and can seek from the last position efficiently. Cursor correctness depends on stable ordering, validation, context binding, and lifecycle.

Q: Why is a unique tie-breaker required?

A primary sort such as timestamp can have equal values. Without a unique secondary key, the database can reorder tied rows between requests, causing gaps or duplicates at boundaries. A total order such as (createdAt, id) makes continuation deterministic.

Q: How do you detect missing or duplicate records?

I collect stable IDs across the full traversal, compare them with an independently known authorized set, and compare list length with set length. I also assert adjacent ordering and filters. Checking page sizes alone cannot find replacement duplicates.

Q: How do you test cursor security?

I tamper with and truncate tokens, send oversized and expired tokens, reuse them across users, tenants, endpoints, filters, and sorts, and test after permissions change. The API must reject invalid context safely without leaking token internals or data.

Q: How should pagination behave when data changes?

It depends on the documented consistency model. Offset on a live set may permit drift, keyset normally moves forward from the last key, and snapshot cursors preserve membership. I design insert, delete, and sort-update cases against that explicit promise.

Q: How do you test pagination performance?

I use a production-like dataset and measure early and late windows, maximum page size, payload, query plan, and resource use. I set budgets from product objectives and baselines rather than universal numbers. Deep offsets receive special attention.

Q: What pagination cases belong in CI?

Empty, one, exact limit, limit plus one, complete traversal, sort ties, invalid parameters, filter and auth preservation, and terminal behavior should be fast CI cases. Large data, distributed cursor rollout, and mutation stress can run in specialized jobs.

Common Mistakes

  • Testing only the first page and checking only item count.
  • Sorting by a nonunique field without a deterministic tie-breaker.
  • Building the expected result by calling the same list endpoint without pagination.
  • Forgetting to preserve filters, sort, and effective limit on next requests.
  • Decoding or manufacturing an opaque cursor in client or test code.
  • Following an absolute next link without validating its origin.
  • Ignoring cross-tenant cursor reuse and permission changes.
  • Declaring offset duplicates a bug without defining the live-data consistency promise.
  • Using fixed sleeps to coordinate collection mutations.
  • Running deep-page performance checks on tiny fixtures and assuming the result will scale.

Conclusion

API pagination testing is a collection-level correctness problem, not a page-size check. Reliable tests prove total ordering, full authorized coverage, uniqueness, filter continuity, safe token behavior, termination, and the API's explicit response to data changing between requests.

Start with a deterministic tied dataset and a full traversal oracle. Once static correctness is secure, add mutations, cross-instance cursors, security boundaries, and production-like late-page measurements for the pagination model your clients actually use.

Interview Questions and Answers

What invariants do you use for pagination testing?

I use authorized-set coverage, identity uniqueness, documented total order, filter preservation, metadata consistency, and finite termination. For mutable data, I add the declared snapshot or live-continuation guarantee. I verify these across the whole traversal.

How do offset and cursor pagination differ?

Offset addresses a numeric position and is simple for random page jumps, but data shifts and deep query cost are concerns. A cursor represents continuation state and normally supports efficient forward seeks. It introduces opacity, validation, binding, expiry, and deployment concerns.

Why can sorting by timestamp break pagination?

Multiple records can share a timestamp, so the database may return tied rows in different orders. A boundary can then skip or duplicate them. Adding a unique tie-breaker and using it in both `ORDER BY` and continuation predicates creates a total order.

How do you verify complete pagination coverage?

I create or obtain an independent expected ID set, traverse all pages, and compare the ordered collected IDs with the expected list. I separately assert that collected length equals unique-ID count. The oracle does not come from the endpoint under test.

How do you test an opaque cursor?

I return it unchanged, detect loops, and test tampering, truncation, size, expiry, wrong endpoint, changed filter or sort, different identity, and permission changes. Invalid tokens should produce the documented safe client error, not 500 or leaked decoder details.

What happens to offset pagination during inserts?

An insert before the current offset shifts later positions and can repeat an item on the next page. A delete can cause a gap. That may be accepted for a live browsing contract, but exact exports need snapshot or another stable strategy.

How do you test pagination in distributed deployments?

I obtain a cursor from one instance and continue through another behind the load balancer. I also test signing-key rotation and shared token state. Cursors must remain valid according to rollout and region policy rather than depend on one process memory.

What pagination performance checks matter?

I compare early and late positions, maximum legal size, payload bytes, serialization cost, rows examined, query plans, and latency against product budgets. Deep offset and nonindexed filter-sort combinations are high risk. Functional CI remains small and separate from large-data performance jobs.

Frequently Asked Questions

What test cases are needed for API pagination?

Cover empty, one item, exact page size, one over, partial last page, beyond end, minimum and maximum size, invalid positions, complete traversal, sort ties, filters, authorization, and termination. Add cursor lifecycle and mutable-data cases for the chosen model.

How do I test cursor pagination?

Treat the cursor as opaque, preserve original filters and sort, follow each continuation until the terminal page, and collect stable identities. Assert complete authorized coverage, uniqueness, order, and no cursor loop. Then test tampering, expiry, context reuse, and concurrent changes.

Why do paginated APIs return duplicate records?

Offset windows can shift when earlier records are inserted or deleted. Unstable sorting can also move tied records across boundaries. Cursor implementations can duplicate records when continuation predicates or sort directions are wrong.

Should a page beyond the last page return 200 or 404?

Either can be documented, though an empty 200 response is common for collection queries. Tests should enforce one consistent public contract. They should also verify terminal metadata does not advertise another page.

Do cursor responses need a total count?

Not necessarily. Exact totals can be expensive or inconsistent with live data, and many cursor APIs intentionally omit them. If a total is offered, document whether it is exact, filtered, snapshot-bound, or estimated and test that meaning.

How can I test pagination with changing data?

Fetch a first page, perform a coordinated insert, delete, or sort-key update relative to its boundary, then continue traversal. Compare the result with the documented snapshot, live offset, or keyset consistency model. Avoid fixed timing sleeps.

Is cursor pagination always faster than offset pagination?

No universal performance claim is safe. Correct indexed keyset seeks usually avoid deep offset scanning, but query shape, filters, storage, cursor validation, and payload dominate real performance. Measure with a production-like dataset and query plans.

Related Guides