Changing the pagination contract is a breaking change in the strict sense: a client that only knows ?page=3 cannot survive a server that only accepts ?cursor=.... Postman's versioning guide lists renamed and removed parameters as breaking changes, and recommends planning versioning before you ship rather than after Postman's API versioning guide. Speakeasy's versioning guide makes the same point about additive versus breaking changes and notes that external clients may resist migration because of the effort involved Speakeasy versioning best practices.
That frames a three-phase rollout that does not need a version bump at all.
Phase 1, add cursors alongside pages. Accept both ?page=N&limit=M and ?cursor=..., return next_cursor in the envelope for every response regardless of which form the client used, and keep the existing page fields intact. Additive changes are non-breaking Speakeasy. You are now minting cursors on every request, so the translation path exists.
Phase 2, translate in both directions. For each page request, compute the cursor for the last row returned. For each cursor request, you may need to answer "what page number is this?" for clients that mix both. If the underlying storage is offset and the cursor encodes (sort_key, id), you can resolve a page by running a bounded keyset query or by a COUNT(*) WHERE (sort_key, id) < .... Cache those counts; they are the expensive part. Document that the page number for a cursor is approximate the moment data shifts.
Phase 3, switch storage and wait for traffic to drain. Add the composite index on (sort_key, id), flip the query planner to keyset reads, and watch logs for clients still sending ?page=. Sunset the page parameters on a published timetable. Akamai's glossary on API versioning notes that deprecating old behavior is easier when you can mark features deprecated in an older version and migrate consumers deliberately Akamai's API versioning glossary.
Two operational checks make the cutover safer. First, run compatibility tests that assert both request styles return the same row set for a static dataset; xMatters recommends exactly this callback to backward compatibility, with CI failing the build when response schemas drift xMatters API versioning strategies. Second, log which style each client uses and by which API key, so you know who has not migrated.
If you need to change page size later, keep the size out of the cursor's identity. A cursor that encodes position only can be replayed with ?limit=50 instead of ?limit=20 and still works: you resume from the same row and read further. A cursor that encodes "page 4 of 20" cannot. This is another reason position-based encodings age better than page numbers.
The same logic applies when the sort order changes. A cursor for created_at DESC is meaningless for title ASC, so the sort signature check turns a silent corruption into a visible 400. Ask the client to restart the listing.
def paginate(conn, sort_col, tiebreak, last_key=None, last_id=None, limit=20):
base = f"SELECT id, {sort_col} FROM items"
if last_key is None:
sql = f"{base} ORDER BY {sort_col}, id LIMIT %s"
params = (limit + 1,)
else:
sql = (
f"{base} WHERE ({sort_col}, id) > (%s, %s) "
f"ORDER BY {sort_col}, id LIMIT %s"
)
params = (last_key, last_id, limit + 1)
rows = conn.execute(sql, params).fetchall()
has_more = len(rows) > limit
rows = rows[:limit]
return rows, has_more
The limit + 1 trick tells you whether another page exists without a count query. You fetch one extra row, check it, and drop it.