Testing Paginated API Responses Without Missing Edge Cases
Pagination looks simple on the surface: request a page, get some records, move to the next one. In practice, it's one of the most reliably broken areas of any API I've worked with. Teams write a happy-path test that fetches page one with ten results, it passes, and everyone moves on — until a user hits the last page with zero items, or a cursor goes stale mid-traversal, or someone passes page=-1 and the server returns a 500 instead of a 400. These aren't exotic edge cases; they're the ones that show up in production bug queues.
The challenge is that pagination edge cases require you to think about state across multiple requests, not just a single request-response pair. That's a different mental model from most API testing, and it's easy to skip if your test suite is organized around individual endpoints rather than workflows. I've seen teams with solid coverage on their core CRUD operations have almost no coverage on paginated traversal — and it's always a matter of time before that gap bites them.
This article walks through the specific scenarios that actually matter when testing paginated APIs: boundary conditions, malformed parameters, empty and single-item result sets, and the consistency problems that appear when data changes underneath an active traversal. Every pattern here translates directly into pytest or Behave test cases you can add to your suite today. If you're still getting comfortable with the fundamentals of what an API response should look like, understanding response structure and validation is the right place to start before diving in.
From Zero to Smarter API Automation with Node.js & Cucumber — Using AI, CI/CD, and Modern Tooling.
Boundary Conditions: The First Page, Last Page, and the One After That
The most commonly missed pagination tests are the ones at the edges of the data set. A typical happy-path test hits page one of a large result set and calls it done. What you actually need to cover are the transitions: first page to second, second-to-last to last, and — critically — the request that comes after the last page.
Here's the pattern I use to structure these in pytest. I'll use an offset/limit style API as the example, since it's the most common:
import requests
BASE_URL = "https://api.example.com/v1/products"
PAGE_SIZE = 10
def test_first_page_returns_correct_count():
resp = requests.get(BASE_URL, params={"limit": PAGE_SIZE, "offset": 0})
assert resp.status_code == 200
data = resp.json()
assert len(data["items"]) == PAGE_SIZE
assert data["pagination"]["offset"] == 0
assert data["pagination"]["has_next"] is True
def test_last_page_has_fewer_items_or_exactly_page_size():
# Seed or know total count; assume 25 items total
total = 25
last_offset = (total // PAGE_SIZE) * PAGE_SIZE
resp = requests.get(BASE_URL, params={"limit": PAGE_SIZE, "offset": last_offset})
assert resp.status_code == 200
data = resp.json()
expected_count = total % PAGE_SIZE or PAGE_SIZE
assert len(data["items"]) == expected_count
assert data["pagination"]["has_next"] is False
def test_page_beyond_last_returns_empty_not_error():
resp = requests.get(BASE_URL, params={"limit": PAGE_SIZE, "offset": 9999})
assert resp.status_code == 200
data = resp.json()
assert data["items"] == []
assert data["pagination"]["has_next"] is False
That third test is the one teams almost always skip. When you request a page beyond the end of the data, the correct behavior is 200 with an empty list — not a 404, not a 400, not a 500. A 404 here would break any client that uses the has_next flag to drive a loop, because it would never know whether "no more results" means "empty page" or "server error." I've seen this exact ambiguity cause client-side infinite retry loops in production.
For cursor-based pagination, the boundary test looks slightly different. You need to verify that the final page's response either omits the next_cursor field entirely, or returns it as null — and that passing a null or missing cursor doesn't crash the server:
def test_cursor_absent_on_last_page():
# Walk to the last page
cursor = None
last_response = None
while True:
params = {"limit": PAGE_SIZE}
if cursor:
params["cursor"] = cursor
resp = requests.get(BASE_URL, params=params)
assert resp.status_code == 200
data = resp.json()
last_response = data
cursor = data.get("next_cursor")
if not cursor:
break
assert last_response["next_cursor"] is None or "next_cursor" not in last_response
def test_null_cursor_returns_first_page():
resp = requests.get(BASE_URL, params={"limit": PAGE_SIZE, "cursor": ""})
assert resp.status_code in (200, 400) # Either is acceptable; 500 is not
The walking loop in test_cursor_absent_on_last_page is also a useful integration smoke test in its own right — it confirms you can traverse the entire data set without hitting an error or a cycle.
Invalid and Malformed Pagination Parameters: What the API Should Reject
Negative page numbers, zero-sized limits, non-numeric strings, and absurdly large offsets are all inputs that real users — and attackers — will try. Your tests need to confirm the API handles them gracefully and consistently. "Gracefully" means a clear 4xx with a useful error message, not a 500 that leaks a stack trace, and not silently clamping the value to something unexpected.
Here's a parameterized pytest approach that covers the common bad inputs in one block:
import pytest
import requests
BASE_URL = "https://api.example.com/v1/products"
INVALID_PAGINATION_PARAMS = [
({"limit": -1, "offset": 0}, 400, "negative limit"),
({"limit": 0, "offset": 0}, 400, "zero limit"),
({"limit": "abc", "offset": 0}, 400, "non-numeric limit"),
({"limit": 10, "offset": -5}, 400, "negative offset"),
({"limit": 10, "offset": "xyz"}, 400, "non-numeric offset"),
({"limit": 99999, "offset": 0}, 400, "limit exceeds max"),
]
@pytest.mark.parametrize("params,expected_status,label", INVALID_PAGINATION_PARAMS)
def test_invalid_pagination_params_return_4xx(params, expected_status, label):
resp = requests.get(BASE_URL, params=params)
assert resp.status_code == expected_status, (
f"Expected {expected_status} for '{label}', got {resp.status_code}"
)
body = resp.json()
assert "error" in body or "message" in body, (
f"Response for '{label}' should include an error message"
)
The "limit exceeds max" case deserves special attention. Many APIs advertise a maximum page size (say, 100 items) but silently return only 100 items when you ask for 500. That's a defensible design choice, but it needs to be tested explicitly — if the silent clamping behavior isn't documented and tested, a client that asks for 500 and assumes it got 500 will silently miss data. Your test should either assert the API rejects the request with a 400, or assert it returns the clamped count and indicates the clamping in the response metadata.
One pattern I see teams overlook: testing what happens when pagination parameters are provided alongside conflicting sort or filter parameters. For example, requesting page 3 of a filtered result set where the filter itself is invalid. The API should validate the filter first and return a 400 — not return an empty page that looks like a valid "no results" response. That kind of silent failure is hard to catch without an explicit test.
When you're building out this kind of negative-case coverage, it helps to think about real-world scenarios where bad inputs come from legitimate client bugs, not just malicious users — a misconfigured frontend that sends offset=NaN is just as damaging as an intentional attack.
Data Consistency During Traversal: Testing What Happens When Records Change Mid-Page
This is the edge case that trips up even experienced testers, because it requires thinking about the API as a stateful system over time rather than a stateless request-response machine. The scenario: a client starts paginating through a large result set, and between page 2 and page 3, a new record is inserted — or an existing one is deleted — that would fall within the sorted range the client is currently traversing. What happens?
With offset-based pagination, the answer is usually "you either see a duplicate record or skip one entirely." If a record is deleted between pages, every subsequent record shifts up by one, and your offset-based client silently skips a row. This is a known limitation of the design, but your tests should confirm the API's behavior is at least consistent and documented — not that it magically handles the race condition.
Here's a test that simulates this with a controlled data setup:
import requests
BASE_URL = "https://api.example.com/v1/products"
ADMIN_URL = "https://api.example.com/v1/admin/products"
HEADERS = {"Authorization": "Bearer test-admin-token"}
PAGE_SIZE = 5
def test_offset_pagination_skips_record_on_delete_between_pages():
# Fetch page 1 and collect IDs
resp1 = requests.get(BASE_URL, params={"limit": PAGE_SIZE, "offset": 0})
assert resp1.status_code == 200
page1_ids = [item["id"] for item in resp1.json()["items"]]
# Delete the last record from page 1 (simulates a mid-traversal deletion)
delete_id = page1_ids[-1]
del_resp = requests.delete(f"{ADMIN_URL}/{delete_id}", headers=HEADERS)
assert del_resp.status_code == 204
# Fetch page 2 — with offset-based pagination, the first record of page 2
# has now shifted into position 4 (0-indexed), so the client sees a gap
resp2 = requests.get(BASE_URL, params={"limit": PAGE_SIZE, "offset": PAGE_SIZE})
assert resp2.status_code == 200
page2_ids = [item["id"] for item in resp2.json()["items"]]
# Document the known behavior: no overlap, but a record was skipped
overlap = set(page1_ids) & set(page2_ids)
assert len(overlap) == 0, "Duplicate records detected across pages after deletion"
# NOTE: a skipped record is expected with offset pagination — this test
# documents the behavior, not fixes it.
The comment at the end is intentional. This test isn't asserting that the API is perfect — it's asserting that the known degraded behavior (a skipped record) is at least not worse (no duplicates, no errors). That's a meaningful contract to lock in.
Cursor-based pagination handles this better in theory, because the cursor encodes a position in the sorted index rather than a numeric offset. But it introduces its own edge case: cursor expiration. If the API expires cursors after a TTL (common in search APIs backed by Elasticsearch-style snapshots), a client that pauses mid-traversal will get an error when it resumes. Your tests should cover this explicitly:
def test_expired_cursor_returns_appropriate_error():
# Use a cursor that is known to be expired or fabricated as invalid
stale_cursor = "eyJleHBpcmVkIjogdHJ1ZX0=" # base64 of {"expired": true}
resp = requests.get(BASE_URL, params={"limit": PAGE_SIZE, "cursor": stale_cursor})
# Should be 400 (bad request) or 410 (gone) — never 200 with wrong data
assert resp.status_code in (400, 410)
body = resp.json()
assert "error" in body or "message" in body
A 410 Gone is actually the most semantically correct status code for an expired cursor — it signals "this resource existed but no longer does." If you want a refresher on how to reason about which status codes belong in which situations, understanding what REST status codes actually communicate makes these design decisions much clearer.
Finally, don't forget the empty data set case entirely. A brand-new environment, a filtered query that matches nothing, or a user with no records should all return a 200 with an empty items array and correct pagination metadata — total: 0, has_next: false, no cursor. A 404 here is wrong. A 200 with a null items field instead of an empty array will break every client that iterates over the result without a null check. These feel obvious, but they're the kind of thing that only gets caught if someone writes the test. If you're building out a broader test strategy and want a structured way to think about coverage priorities, the approach described in a comprehensive API testing guide gives a solid framework for deciding what to test first.