Systematic methodology for debugging pagination issues in API integrations, especially when switching between API versions or endpoints...
Purpose: Systematically diagnose and fix pagination failures that prevent complete data import from APIs
Never assume pagination fields based on documentation or other endpoints. Always test actual responses:
curl -s API_ENDPOINT | jq 'keys'
Different API versions or endpoints may use different pagination patterns even within the same service.
APIs use distinct pagination patterns that require different implementations:
{nextPageCursor, results} - use cursor param{page, total_pages, results} - use page number param{offset, limit, total} - use offset/limit params{next, previous, results} - follow next URLUsing the wrong pattern causes pagination to stop after first page.
Most APIs support configurable page sizes (e.g., 50-1000 items per page). Using maximum page_size:
Before implementing pagination logic:
Symptoms of pagination failure:
Example:
Expected: 74,386 highlights
Actual: 463 files (< 1% of total)
Status: "completed_all_pages" after 1 page
Don't trust assumptions - verify response structure:
# Fetch first page and check structure
curl -s -H "Authorization: Token $TOKEN" \
"https://api.example.com/endpoint?page_size=50" | jq 'keys'
# Expected output reveals actual fields:
# ["count", "nextPageCursor", "results"]
# NOT ["count", "next", "previous", "results"]
Critical checks:
Common mismatches:
| Expected (Wrong) | Actual (Correct) | Impact |
|---|---|---|
next |
nextPageCursor |
Stops after page 1 |
page parameter |
pageCursor parameter |
Repeats page 1 |
| Page number increment | Cursor advancement | Never progresses |
has_more boolean |
null cursor |
Wrong termination check |
Verify pagination actually works:
# Get page 1
PAGE1=$(curl -s -H "Authorization: Token $TOKEN" \
"https://api.example.com/endpoint?page_size=50")
# Extract cursor
CURSOR=$(echo $PAGE1 | jq -r '.nextPageCursor')
# Get page 2 using cursor
curl -s -H "Authorization: Token $TOKEN" \
"https://api.example.com/endpoint?page_size=50&pageCursor=$CURSOR" \
| jq '{count, nextPageCursor, results_count: (.results | length)}'
Expected results:
results array contentsnextPageCursor value (or null if last page)Update implementation to match API design:
# Initialize
cursor = None
page_num = 0
while True:
page_num += 1
# Build params
params = {"page_size": 1000} # Use maximum
if cursor:
params["pageCursor"] = cursor # Use correct param name
# Fetch page
response = fetch_api(endpoint, params)
results = response.get("results", [])
if not results:
break # Empty results = done
# Process results
for item in results:
process(item)
# Get next cursor
next_cursor = response.get("nextPageCursor") # Use correct field name
if not next_cursor:
break # No more pages
cursor = next_cursor # Advance cursor
# Initialize
page_num = 1
while True:
# Build params
params = {"page": page_num, "page_size": 1000}
# Fetch page
response = fetch_api(endpoint, params)
results = response.get("results", [])
if not results:
break
# Process results
for item in results:
process(item)
# Check if more pages exist
if not response.get("next"): # Or check page_num < total_pages
break
page_num += 1 # Increment page number
Add debug logging to confirm pagination works:
logger.info(f"Page {page_num}: {len(results)} items, cursor={cursor}, next={next_cursor}")
Expected log output:
Page 1: 1000 items, cursor=None, next=55771679
Page 2: 1000 items, cursor=55771679, next=55114962
Page 3: 1000 items, cursor=55114962, next=54503291
...
Page 75: 386 items, cursor=12847563, next=null
Before optimization:
params = {"page_size": 50} # Small pages
# Result: 1,488 pages needed for 74,386 items
After optimization:
params = {"page_size": 1000} # Maximum supported
# Result: 75 pages needed for 74,386 items
# Improvement: 20x fewer API calls
Check API documentation for:
DO: Test actual API responses before implementing
Never rely on documentation alone. Always curl the endpoint and inspect response structure:
curl -s API_ENDPOINT | jq '.'
DO: Use maximum page_size supported by API
Default page sizes are often inefficient (50-100 items). Check API limits and use maximum:
# Efficient
params = {"page_size": 1000}
# Inefficient
params = {"page_size": 50} # 20x more API calls
DO: Match parameter names exactly
API field names are case-sensitive and specific:
# CORRECT
params["pageCursor"] = cursor
# WRONG (will not work)
params["page_cursor"] = cursor # Snake case instead of camelCase
params["cursor"] = cursor # Missing "page" prefix
DO: Add pagination logging for diagnosis
Always log pagination progress:
logger.info(f"Page {page}: {len(results)} items, next={next_cursor}")
DO: Verify termination conditions
Check both conditions to prevent infinite loops:
# Check empty results
if not results:
break
# AND check next cursor/page
if not next_cursor: # or not has_more, or page >= total_pages
break
DON'T: Assume pagination pattern from other endpoints
Different endpoints in same API may use different pagination:
# WRONG: Assume v2 uses same pagination as v3
# v3 endpoint uses page numbers
# v2 endpoint uses cursors
DON'T: Check wrong field for continuation
# WRONG
if not data.get("next"): # Field doesn't exist
break
# RIGHT
if not data.get("nextPageCursor"): # Actual field name
break
DON'T: Use inefficient page sizes
# WRONG: Causes 20x more API calls
params = {"page_size": 50}
# RIGHT: Minimizes API calls
params = {"page_size": 1000}
DON'T: Increment page numbers for cursor-based APIs
# WRONG: Page number ignored for cursor-based pagination
page_num = 1
while True:
params = {"page": page_num} # Repeats page 1 forever
page_num += 1
# RIGHT: Use cursor advancement
cursor = None
while True:
params = {"pageCursor": cursor} if cursor else {}
cursor = response.get("nextPageCursor")
DON'T: Skip manual testing before implementation
# WRONG: Implement without verifying
# Assume API uses page numbers, implement pagination
# Deploy and discover it uses cursors
# RIGHT: Test first
# curl endpoint | jq 'keys'
# Verify field names
# Test page 2 manually
# Then implement
NO - stops after 1 page:
NO - returns duplicates:
YES - but slow:
API returns nextPageCursor field:
โ Use cursor-based pagination with pageCursor parameter
API returns next URL:
โ Follow link-based pagination (use next URL directly)
API returns page and total_pages:
โ Use page-based pagination with page parameter
API returns offset and total:
โ Use offset-based pagination with offset and limit parameters
Problem:
if not data.get("next"): # Field doesn't exist in response
break
Solution:
# First, check actual response
curl API | jq 'keys'
# Output: ["count", "nextPageCursor", "results"]
# Then use correct field
if not data.get("nextPageCursor"):
break
Problem:
params["page"] = page_num # API doesn't use page numbers
Solution:
# Cursor-based APIs require cursor parameter
params["pageCursor"] = cursor # Not "page"
Problem:
params = {"page_size": 50}
# 74,386 items รท 50 = 1,488 API calls
Solution:
params = {"page_size": 1000} # Use maximum
# 74,386 items รท 1000 = 75 API calls
# 20x improvement
Context:
โ WRONG - Assumed page-based pagination
# Incorrect implementation
page_num = 1
while page_num < 1000:
params = {"page": page_num, "page_size": 50}
data = fetch_api("/export/", params, api_version="v2")
# Wrong field check
if not data.get("next"): # This field doesn't exist
break
page_num += 1 # Never executed because break on page 1
Problem: API uses cursor-based pagination, not page numbers. Field is nextPageCursor not next.
โ RIGHT - Cursor-based pagination with correct fields
# Correct implementation
cursor = None
page_num = 0
while page_num < 1000:
page_num += 1
# Use cursor parameter
params = {"page_size": 1000} # Increased from 50
if cursor:
params["pageCursor"] = cursor # Correct parameter name
data = fetch_api("/export/", params, api_version="v2")
results = data.get("results", [])
if not results:
break
# Process results...
# Use correct field name
next_cursor = data.get("nextPageCursor") # Not "next"
if not next_cursor:
break
cursor = next_cursor # Advance cursor
Result:
Context:
Step-by-step debugging:
# Step 1: Test API response structure
curl -s -H "Authorization: Token $TOKEN" \
"https://api.example.com/data?limit=10" | jq 'keys'
# Output: ["data", "pagination"]
# Step 2: Inspect pagination object
curl -s -H "Authorization: Token $TOKEN" \
"https://api.example.com/data?limit=10" | jq '.pagination'
# Output:
# {
# "total": 5000,
# "offset": 0,
# "limit": 10,
# "has_more": true
# }
# Step 3: Test offset advancement
curl -s -H "Authorization: Token $TOKEN" \
"https://api.example.com/data?limit=10&offset=10" | jq '.pagination'
# Output:
# {
# "total": 5000,
# "offset": 10,
# "limit": 10,
# "has_more": true
# }
Implementation:
# Offset-based pagination identified
offset = 0
limit = 100 # Use larger limit
while True:
params = {"limit": limit, "offset": offset}
response = fetch_api("/data", params)
items = response.get("data", [])
if not items:
break
# Process items...
pagination = response.get("pagination", {})
if not pagination.get("has_more"):
break
offset += limit # Advance offset
This skill auto-activates when:
Don't use when:
Related Skills:
Related Commands:
/readwise-import - Primary user of this debugging methodologyRelated Vault Documents:
Technical Context:
/Users/ngpestelos/src/readwise-mcp-server/server.py.claude/state/readwise-import.jsonAPI pagination failures usually stem from field name mismatches or wrong pagination pattern assumptions. Always verify actual API response structure with curl/jq before implementing pagination logic, use maximum page_size for efficiency, and test page 2 manually to confirm advancement works. The pattern is: inspect response โ identify pagination type โ match implementation โ optimize page size โ verify with logging.
Discovered January 30, 2026 during Readwise highlights backfill debugging Bug fix reduced 74,386 highlights import from theoretical 1,488 pages to actual 75 pages Pattern applies to any cursor-based, page-based, or offset-based pagination implementation