API Testing

Pagination Test Cases for APIs: 36 Cases with a Free CSV

Parveen Kumari12 min read

Quick answer

Pagination test cases check that an API splits a collection into pages without losing, duplicating or reordering items. Cover default and boundary page sizes, invalid parameters, the last and empty pages, cursor tampering, stable ordering, inserts and deletes during a walk, total counts and Link headers. This pack lists 36 cases for four pagination styles as a CSV.

Reviewed by Rishi Gaurav

Share:
Diagram of API pagination test coverage: offset, page, cursor and keyset styles, with checks for boundaries, ordering, concurrent inserts, totals and Link headers.

Part of the API Testing hub. Start with the pillar guide: What is API testing? Types, tools and how it works.

Pagination test cases check that an API splits a collection into pages without losing, duplicating or reordering items, and that it rejects bad paging input cleanly. This pack gives you 36 cases covering the four common styles (offset/limit, page/size, cursor and keyset), grouped by what they protect, with a pytest walker you can adapt and a free CSV of every case.

It is part of our API testing guide and pairs with the wider set of API testing examples. For the general method of turning requirements into cases, see how to write API test cases.

What to cover in pagination testing

Pagination bugs are rarely about one page. They show up across pages: the item that appears twice because a row was inserted, the record nobody can reach because the sort key has ties, the total that disagrees with what a client can actually walk. Good coverage has six areas:

  1. Page size boundaries. Default, minimum, maximum, above maximum, zero, negative, non-numeric.
  2. Position boundaries. First page, last partial page, exactly at the end, past the end, absurdly large offsets.
  3. Cursor integrity. Tampered, expired, reused with different filters, and borrowed from another tenant.
  4. Ordering. Deterministic order across pages, ties on the sort key, sort and filter applied to the whole collection rather than per page.
  5. Consistency under change. Inserts and deletes between page requests.
  6. Metadata. Totals, hasMore flags, Link headers and next-page URLs that keep the original query.

Before you start, seed a dataset you control: for example 103 items, so that a page size of 10 leaves a partial last page of 3, plus several items sharing the same created_at to expose tie-breaking bugs.

The four pagination styles and what each needs

StyleRequestStrengthTest focus
Offset/limit?offset=20&limit=10Random access, simple totalsArithmetic boundaries; documented skip/duplicate behaviour under change; deep-offset performance
Page/size?page=3&size=10Human-friendlySame as offset, plus whether pages are 0- or 1-based and totalPages maths
Cursor?cursor=eyJpZCI6MjB9&limit=10Stable under changeOpaque cursor validation, expiry, tenant binding, next absent on last page
Keyset (seek)?after_id=20&limit=10Fast, index-friendlyStrictly greater-than semantics, compound keys for ties, sort direction

Pagination test cases

Assumed defaults for the examples: default limit 20, maximum 100, 103 seeded items. Replace them with your API's documented values. Types: P positive, N negative, B boundary, C consistency, S security.

IDStyleInputExpectedType
PAG-001OffsetGET /items (no params)200, 20 items (default), first 20 in documented orderP
PAG-002Offsetlimit=5&offset=0Exactly 5 items, the first 5 by sort keyP
PAG-003Offsetlimit=5&offset=5Items 6–10; no overlap with PAG-002P
PAG-004Offsetlimit=1Exactly 1 itemB
PAG-005Offsetlimit=100 (max)Exactly 100 itemsB
PAG-006Offsetlimit=101 (max + 1)400 with a message naming the max, or clamped to 100 if documentedB
PAG-007Offsetlimit=0400 (or empty list if documented); never a full dumpB
PAG-008Offsetlimit=-1400 validation errorN
PAG-009Offsetoffset=-1400 validation errorN
PAG-010Offsetlimit=abc400, not 500 and not silently the defaultN
PAG-011Offsetoffset=103 (= total)200, empty list, no nextB
PAG-012Offsetoffset=100&limit=103 items (partial last page), no nextB
PAG-013Offsetoffset=99999999999400 or empty list; no 500, no timeoutN
PAG-014Pagepage=1&size=10First 10 items; page base (0 or 1) matches docsP
PAG-015Pagepage=0 on a 1-based API400, or treated as page 1 if documentedB
PAG-016Pagepage=12&size=10 (past end)200, empty list, totalPages still 11B
PAG-017Pageany pagetotalPages == ceil(total / size)P
PAG-018Cursorfirst request, no cursorItems plus a next cursorP
PAG-019Cursorfollow nextNext items, none repeated from the first pageP
PAG-020Cursorlast pagenext absent or null; hasMore falseB
PAG-021Cursortampered cursor (edited base64)400 with a generic message; no stack trace or decoded internalsN
PAG-022Cursorcursor issued to tenant A, used by tenant B400 or 403; never tenant A's dataS
PAG-023Cursorexpired cursor400 or 410 with a clear "restart from the first page" errorN
PAG-024Cursorcursor reused with a different filter400, or the filter consistently applied; documented either wayN
PAG-025Keysetafter_id=20&limit=10 (sort by id)Ids 21–30, all strictly greater than 20P
PAG-026Keysetsort by created_at with ties at the page boundaryNo item skipped or repeated (needs an id tie-breaker)C
PAG-027Allwalk every page of a static datasetEvery seeded item exactly onceC
PAG-028Allsame request twiceIdentical items in identical orderC
PAG-029Allsort=-price across pagesDescending order holds across page boundaries, not only within a pageP
PAG-030Allstatus=active filter across pagesOnly matching items; total reflects the filterP
PAG-031Cursor/keysetinsert an item between page 1 and page 2No duplicates; pre-existing items all still reachedC
PAG-032Offsetinsert an item before the current offset mid-walkDocumented behaviour: one item repeats on the next page (test pins it)C
PAG-033Alldelete an item between pagesNo duplicates; with cursor/keyset no remaining item skippedC
PAG-034Allcompare reported total with a full walkTotal (body or X-Total-Count) equals items walkedC
PAG-035Allinspect Link header on a middle pagerel="next" and rel="prev" present, absolute URLs, well-formed per RFC 8288P
PAG-036Allfollow rel="next" from page 1Same limit and filters preserved; first page has no prev, last has no nextP

Generate API test cases from your spec (free)

Free, runs in your browser, no sign-up needed.

Negative and edge cases teams miss

  • PAG-013 catches integer overflow and unbounded database scans. A deep offset on a large table is also a performance test: OFFSET 1000000 makes many databases read and discard a million rows.
  • PAG-021 and PAG-022 are security cases. Cursors often base64-encode internal state such as an id, a tenant or even a raw SQL fragment. Decode one; if it is readable, check that editing it cannot reach another tenant's data.
  • PAG-026 is the most common silent bug. Sorting only by a non-unique column means rows that share a value can fall on either side of a page boundary, so some are returned twice and others never. The fix is a compound sort key (created_at, id) and the test seeds ties on purpose.
  • PAG-032 is not a bug in offset pagination, it is how offset works. Pin the behaviour in a test so nobody promises clients "no duplicates" without moving to cursors.

Automate the pack with pytest

The full-walk test does more work than any other single case: it covers PAG-027, PAG-034 and, through Link headers, PAG-035 and PAG-036. requests parses RFC 8288 Link headers into response.links for you.

# tests/test_pagination.py  (pip install pytest requests)
import os

import pytest
import requests

BASE = os.environ.get("API_BASE_URL", "http://localhost:8080")
MAX_LIMIT = 100
session = requests.Session()


def walk(path, **params):
    """Follow rel="next" until it disappears; return every id in order."""
    url, ids, pages = f"{BASE}{path}", [], 0
    while url:
        r = session.get(url, params=params, timeout=10)
        assert r.status_code == 200, r.text
        ids.extend(item["id"] for item in r.json()["items"])
        url = r.links.get("next", {}).get("url")
        params = None  # the next URL already carries the query
        pages += 1
        assert pages <= 10_000, "pagination never terminates"
    return ids


def test_full_walk_returns_every_item_once_in_order():   # PAG-027, PAG-034
    ids = walk("/items", limit=25, sort="id")
    assert len(ids) == len(set(ids)), "duplicates across pages"
    assert ids == sorted(ids), "order broken across page boundaries"

    total = int(session.get(f"{BASE}/items", params={"limit": 1}).headers["X-Total-Count"])
    assert len(ids) == total


@pytest.mark.parametrize("limit, expected_status", [
    (1, 200), (MAX_LIMIT, 200),                  # PAG-004, PAG-005
    (MAX_LIMIT + 1, 400), (0, 400), (-1, 400),   # PAG-006, PAG-007, PAG-008
    ("abc", 400),                                # PAG-010
])
def test_limit_boundaries(limit, expected_status):
    r = session.get(f"{BASE}/items", params={"limit": limit}, timeout=10)
    assert r.status_code == expected_status
    if expected_status == 200:
        assert len(r.json()["items"]) == limit


def test_page_past_the_end_is_empty_not_an_error():      # PAG-011
    total = int(session.get(f"{BASE}/items", params={"limit": 1}).headers["X-Total-Count"])
    r = session.get(f"{BASE}/items", params={"offset": total, "limit": 10}, timeout=10)
    assert r.status_code == 200
    assert r.json()["items"] == []
    assert "next" not in r.links


def test_tampered_cursor_is_rejected_cleanly():          # PAG-021
    r = session.get(f"{BASE}/items", params={"cursor": "not-a-real-cursor"}, timeout=10)
    assert r.status_code == 400
    assert "Traceback" not in r.text and "SELECT" not in r.text.upper()


def test_cursor_walk_survives_an_insert():               # PAG-031
    first = session.get(f"{BASE}/items", params={"limit": 25, "sort": "id"}, timeout=10)
    seen = [item["id"] for item in first.json()["items"]]

    session.post(f"{BASE}/items", json={"name": "inserted mid-walk"}, timeout=10)

    url = first.links.get("next", {}).get("url")
    while url:
        r = session.get(url, timeout=10)
        seen.extend(item["id"] for item in r.json()["items"])
        url = r.links.get("next", {}).get("url")

    assert len(seen) == len(set(seen)), "insert caused a duplicate"

Run the suite against a seeded, isolated environment: tests that insert data must not share a dataset with tests that count it. The public APIs for testing list includes a few paginated APIs if you want to practise the walker first.

Want a pack like this for your own endpoints instead of a generic /items? Try the free API test case generator and build positive, negative and boundary cases for your API in the same table format.

Free ZIP: 5 CSV packs + guide

API Test Case Library

159 ready-made API test cases in five CSV packs: login, pagination, search and filter, auth (API key, JWT, OAuth 2.0) and errors. Copy, adapt, run.

Download Free

Many APIs, GitHub's among them, return paging links in a Link header defined by RFC 8288:

Link: <https://api.example.com/items?cursor=eyJpZCI6MjV9&limit=25>; rel="next",
      <https://api.example.com/items?limit=25>; rel="first"
X-Total-Count: 103

Check four things on every paginated endpoint:

  • Presence by position. next on every page but the last, prev on every page but the first (PAG-036).
  • Absolute, well-formed URLs that a client can follow without rebuilding them, and that keep the original limit, sort and filters (PAG-035).
  • Totals that agree. If the body has total and a header has X-Total-Count, they must match each other and a full walk (PAG-034). Many APIs drop exact totals on very large collections for performance; then test that the documented substitute (hasMore, an estimate flag) is honest.
  • Body and header agree. If the body also carries next links, they should match the header.

When an OpenAPI spec documents limit with minimum and maximum, a spec-driven generator can plan the boundary cases for you. Shift-Left API's coverage engine, for example, reads those constraints and plans boundary tests plus "pagination limit respected" and "sort order applied" functional scenarios for each list endpoint, which overlaps with the PAG-004 to PAG-010 rows of this pack. The cross-page consistency cases (PAG-026 to PAG-033) still need a seeded dataset and tests like the walker above.

Download the pagination test case CSV

The pagination test case CSV contains all 36 cases with these columns: id, style, area, title, input, expected, type and priority. Import it into a spreadsheet, a test management tool or a data-driven test, change the defaults (page size, maximum, total) to your API's documented values, and delete the rows for styles you do not use.

Pagination test cases FAQ

How do you test pagination in an API?

Seed a known dataset, walk every page and check that each item appears exactly once, in the documented order, and that the count matches the reported total. Add boundary cases for page size, the last and empty pages, invalid or tampered cursors, and a walk with inserts and deletes between requests.

What are the most important pagination test cases?

The full walk (every item once), the maximum page size boundary, a page past the end returning an empty list, a tampered cursor returning 400 rather than 500, ordering stability when the sort key has ties, and consistency when records are inserted mid-walk.

What is the difference between offset and cursor pagination when testing?

Offset pagination can skip or duplicate items when data changes between requests, so tests document that behaviour. Cursor and keyset pagination should never duplicate items, so tests assert that strictly, and they add cases for invalid, expired and cross-tenant cursors.

Should an API return 404 or an empty list for a page past the end?

An empty list with 200 is the common, more useful convention: the collection exists, it just has no items at that position. Whatever the API chooses, pin it in a test.

Parse the Link header and check rel="next" on every page but the last and rel="prev" on every page but the first, that URLs are absolute and keep the original filters and page size, and that following next returns the whole collection exactly once.

Sources and further reading

Ready to shift left with your API testing?

Import your OpenAPI spec and get a runnable test suite, in our cloud or on your own infrastructure.