Pagination Test Cases for APIs: 36 Cases with a Free CSV
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
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:
- Page size boundaries. Default, minimum, maximum, above maximum, zero, negative, non-numeric.
- Position boundaries. First page, last partial page, exactly at the end, past the end, absurdly large offsets.
- Cursor integrity. Tampered, expired, reused with different filters, and borrowed from another tenant.
- Ordering. Deterministic order across pages, ties on the sort key, sort and filter applied to the whole collection rather than per page.
- Consistency under change. Inserts and deletes between page requests.
- Metadata. Totals,
hasMoreflags,Linkheaders 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
| Style | Request | Strength | Test focus |
|---|---|---|---|
| Offset/limit | ?offset=20&limit=10 | Random access, simple totals | Arithmetic boundaries; documented skip/duplicate behaviour under change; deep-offset performance |
| Page/size | ?page=3&size=10 | Human-friendly | Same as offset, plus whether pages are 0- or 1-based and totalPages maths |
| Cursor | ?cursor=eyJpZCI6MjB9&limit=10 | Stable under change | Opaque cursor validation, expiry, tenant binding, next absent on last page |
| Keyset (seek) | ?after_id=20&limit=10 | Fast, index-friendly | Strictly 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.
| ID | Style | Input | Expected | Type |
|---|---|---|---|---|
| PAG-001 | Offset | GET /items (no params) | 200, 20 items (default), first 20 in documented order | P |
| PAG-002 | Offset | limit=5&offset=0 | Exactly 5 items, the first 5 by sort key | P |
| PAG-003 | Offset | limit=5&offset=5 | Items 6–10; no overlap with PAG-002 | P |
| PAG-004 | Offset | limit=1 | Exactly 1 item | B |
| PAG-005 | Offset | limit=100 (max) | Exactly 100 items | B |
| PAG-006 | Offset | limit=101 (max + 1) | 400 with a message naming the max, or clamped to 100 if documented | B |
| PAG-007 | Offset | limit=0 | 400 (or empty list if documented); never a full dump | B |
| PAG-008 | Offset | limit=-1 | 400 validation error | N |
| PAG-009 | Offset | offset=-1 | 400 validation error | N |
| PAG-010 | Offset | limit=abc | 400, not 500 and not silently the default | N |
| PAG-011 | Offset | offset=103 (= total) | 200, empty list, no next | B |
| PAG-012 | Offset | offset=100&limit=10 | 3 items (partial last page), no next | B |
| PAG-013 | Offset | offset=99999999999 | 400 or empty list; no 500, no timeout | N |
| PAG-014 | Page | page=1&size=10 | First 10 items; page base (0 or 1) matches docs | P |
| PAG-015 | Page | page=0 on a 1-based API | 400, or treated as page 1 if documented | B |
| PAG-016 | Page | page=12&size=10 (past end) | 200, empty list, totalPages still 11 | B |
| PAG-017 | Page | any page | totalPages == ceil(total / size) | P |
| PAG-018 | Cursor | first request, no cursor | Items plus a next cursor | P |
| PAG-019 | Cursor | follow next | Next items, none repeated from the first page | P |
| PAG-020 | Cursor | last page | next absent or null; hasMore false | B |
| PAG-021 | Cursor | tampered cursor (edited base64) | 400 with a generic message; no stack trace or decoded internals | N |
| PAG-022 | Cursor | cursor issued to tenant A, used by tenant B | 400 or 403; never tenant A's data | S |
| PAG-023 | Cursor | expired cursor | 400 or 410 with a clear "restart from the first page" error | N |
| PAG-024 | Cursor | cursor reused with a different filter | 400, or the filter consistently applied; documented either way | N |
| PAG-025 | Keyset | after_id=20&limit=10 (sort by id) | Ids 21–30, all strictly greater than 20 | P |
| PAG-026 | Keyset | sort by created_at with ties at the page boundary | No item skipped or repeated (needs an id tie-breaker) | C |
| PAG-027 | All | walk every page of a static dataset | Every seeded item exactly once | C |
| PAG-028 | All | same request twice | Identical items in identical order | C |
| PAG-029 | All | sort=-price across pages | Descending order holds across page boundaries, not only within a page | P |
| PAG-030 | All | status=active filter across pages | Only matching items; total reflects the filter | P |
| PAG-031 | Cursor/keyset | insert an item between page 1 and page 2 | No duplicates; pre-existing items all still reached | C |
| PAG-032 | Offset | insert an item before the current offset mid-walk | Documented behaviour: one item repeats on the next page (test pins it) | C |
| PAG-033 | All | delete an item between pages | No duplicates; with cursor/keyset no remaining item skipped | C |
| PAG-034 | All | compare reported total with a full walk | Total (body or X-Total-Count) equals items walked | C |
| PAG-035 | All | inspect Link header on a middle page | rel="next" and rel="prev" present, absolute URLs, well-formed per RFC 8288 | P |
| PAG-036 | All | follow rel="next" from page 1 | Same limit and filters preserved; first page has no prev, last has no next | P |
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 1000000makes 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 FreeLink headers, totals and response metadata
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.
nexton every page but the last,prevon 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,sortand filters (PAG-035). - Totals that agree. If the body has
totaland a header hasX-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
nextlinks, 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.
How do you test Link headers for pagination?
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
- RFC 8288: Web Linking
- GitHub REST API docs: Using pagination in the REST API
- Stripe API reference: Pagination
- Google AIP-158: Pagination
- Microsoft: Azure REST API guidelines (collections and paging)
- Use The Index, Luke: Paging through results (keyset vs offset)
- Requests documentation: Link headers
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.