Paging Through a Large API
Cursor against offset paging, and a generator that yields items so the caller never holds the whole result set.
for item in iter_items(client): ... # generator yields; one page is in memoryNo API will hand you 400,000 rows in one response. It gives you a page and a way to ask for the next one, and your job is to turn that into something a caller can iterate without knowing it is happening.
Offset against cursor
Offset paging takes ?limit=100&offset=200. It is easy to reason about and jumps to any page, but it is unstable: if rows are inserted or deleted while you are walking, items shift between pages and you will read one twice or miss it entirely. It also degrades, because the database has to count past every skipped row.
Cursor (keyset) paging takes an opaque ?cursor=... that encodes where the last page stopped. It is stable under concurrent writes and stays fast at depth, at the cost of only moving forwards. Prefer it when the API offers both.
Yield, do not collect
Write the walk as a generator and yield each item. The caller writes an ordinary for loop, memory holds one page at a time, and anything that stops early — a break, itertools.islice — stops the requests as well. Inside the loop, yield from page["items"] is shorthand for for item in page["items"]: yield item; identical behaviour, one line.
Knowing when to stop
Stop on what the API documents: a null or absent next cursor, or no next link. Many APIs send RFC 8288 Link headers, which both requests and httpx expose as resp.links. Add a guard for the pathological cases — an empty page and a cursor that repeats itself — because the alternative is an infinite loop that quietly burns your rate limit.
Example
When to use it
- Exporting every customer from a CRM into a local file without exhausting memory.
- Summing a field across an API you cannot query directly.
- Taking the first 50 matches from an endpoint that only pages 20 at a time.
- Explaining why a nightly export has duplicate rows.
More examples
The real cursor loop
The shape to copy. max_pages is not paranoia: a server bug that returns the same cursor forever is a loop that stops only when your rate limit or your bill does.
import httpx
def iter_orders(client, page_size=100, max_pages=1000):
cursor, seen_pages = None, 0
while True:
params = {"limit": page_size}
if cursor is not None:
params["cursor"] = cursor # omit the key entirely on page one
r = client.get("/orders", params=params, timeout=10.0)
r.raise_for_status()
page = r.json()
yield from page["items"]
cursor = page.get("next_cursor")
seen_pages += 1
if not cursor or seen_pages >= max_pages: # documented end, or the guard
return
with httpx.Client(base_url="https://api.example.com/v1") as client:
for order in iter_orders(client):
print(order["id"]) # one page in memory, however many rowsFollowing a Link header instead
Use it whenever the API sends Link headers — GitHub's does. The appeal is that the server owns the next URL completely, so filters and sort order carry across pages without you re-encoding them.
import requests
def iter_items(url, session):
while url:
r = session.get(url, timeout=10)
r.raise_for_status()
yield from r.json()["items"]
# requests parses RFC 8288 Link headers into resp.links:
# Link: <https://api/...?page=2>; rel="next"
# becomes {"next": {"url": "https://api/...?page=2", "rel": "next"}}
nxt = r.links.get("next")
url = nxt["url"] if nxt else None # absent rel=next means last page
# GitHub, Stripe-style link paging: you never build the next URL yourself.Offset paging, and the drift it causes
Written out because plenty of internal APIs only offer offsets. If you must use one, sort by something immutable and accept that a snapshot taken during writes is approximate; say so wherever the numbers get reported.
def iter_offset(fetch, limit=100):
offset = 0
while True:
rows = fetch(limit=limit, offset=offset)
if not rows: # an empty page is the only reliable end here
return
yield from rows
offset += limit # NOT len(rows), or a short page shifts the window
# The hazard, concretely: you read rows 0-99, someone inserts a row that sorts
# into position 10, and the row previously at 99 moves to 100. Your next request
# starts at offset 100 and you read it twice. Ordering by an immutable key and
# filtering with `WHERE id > last_id` -- keyset paging -- avoids it entirely.
Discussion