Four ways APIs paginate, and what each one does to your SDK
Cursor, offset, page number, next URL. Your choice decides whether your users write a loop or you write it for them, and whether the loop is even correct.

A list endpoint that returns one page is a list endpoint every single one of your users writes a loop around.
They will each write it slightly differently. One will forget the terminating condition and hammer you until the cursor repeats. One will hold every page in memory because they wanted a count. One will paginate correctly and ship it, then copy it into four other services where the field names are different.
None of that is their fault. You published a contract that requires a loop and did not supply one.
The four conventions
Cursor. The response carries an opaque token pointing at the next page. The caller sends it back untouched.
{ "data": [...], "next_cursor": "eyJpZCI6MTAwfQ" }This is the one to pick if you are choosing today. It survives inserts and deletes between pages, because the cursor encodes a position in a stable ordering rather than a count. It is the only convention on this list where a row created mid-iteration cannot silently shift another row out of the results.
Offset. The caller sends ?offset=100&limit=50.
Cheap to implement, and wrong under writes. Delete a row on page one and a row on page two moves up into a position the caller has already passed, so it never appears. Insert one and a row appears twice. For an admin table over a quiet dataset this is fine. For anything a customer iterates while other people are writing, it produces bugs that reproduce once a month and never in staging.
There is also a performance floor: OFFSET 50000 makes most databases walk 50,000 rows to throw them away.
Page number. ?page=3&per_page=50. Offset wearing a friendlier name, with the same correctness problem and the same deep-page cost. It reads well in a UI and it is a poor contract for a machine.
Next URL. The response carries a fully-formed URL for the next page.
{ "items": [...], "next": "https://api.acme.com/v1/users?cursor=abc" }Pleasant for a hand-written client, awkward for a generated one, because the URL is opaque and carries whatever query parameters the server felt like including. It is a fine convention if you already ship it. It is not the one to adopt.
What the generator needs to know
Whichever you picked, a generator cannot infer it. next_cursor is a string field among other string fields. Nothing in OpenAPI marks it as the thing that advances the page.
So it is configuration, and it is small: which strategy the endpoint uses, which response field holds the items, and which field advances the page. Declare those and the loop gets written for your users, in every language you publish, correctly.
That last word is the point. The loop stops when your API says it is done, and it does not prefetch the collection. Nobody's first-draft pagination helper does both.
What it produces
The generated shape follows what each language's users expect, which means it is not the same shape everywhere.
TypeScript. The operation is itself an async generator that yields items rather than page envelopes:
for await (const user of acme.users.list({ limit: 100 })) {
console.log(user.id);
}No helper to discover, no envelope to unwrap. The natural way to consume it is the correct way.
Python. A _paginated companion beside the operation, iterated with async for:
async for user in acme.users.list_paginated(limit=100):
print(user.id)Go. A Paginated companion taking a yield callback. Return false to stop early:
err := client.Users.ListPaginated(ctx, nil, &limit, func(user *acme.User) bool {
fmt.Println(user.ID)
return true
})Java. The same companion shape with a predicate:
acme.users.listPaginated(
ListUsersRequest.builder().limit(100).build(),
user -> {
System.out.println(user.getId());
return true;
});Four idioms, one declaration on the endpoint. A caller in any of them gets a loop that terminates when your API says so and holds one page at a time.
The part worth arguing about
Auto-pagination has a real objection, and it deserves a straight answer rather than a footnote.
The objection is that hiding the page boundary hides the cost. A caller writes what looks like a loop over a list and issues four hundred requests, and because the helper is well-behaved and does not prefetch, it does this slowly and invisibly.
That is true, and it is still better than the alternative, because the alternative is not "callers who think carefully about page boundaries". The alternative is four hundred requests issued by a hand-rolled loop with a worse terminating condition. The cost does not disappear when you make people write it themselves. It gets paid by someone with less context than you had.
What does help is making the boundary visible where it matters. Every one of the shapes above can stop early. The Go and Java callbacks return false, the generators break out of the loop. A caller who wants the first fifty rows takes the first fifty rows and stops, which is the case people actually get wrong when they write it by hand.
If you are designing this now
Pick cursor. Name the fields plainly, data and next_cursor over results and paging.cursors.after. Return the same envelope from every list endpoint you have, because a caller who learns your pagination once should not have to learn it again on a different resource.
Then declare it on the endpoint so the loop ships with the client rather than living in your documentation as a code sample people copy wrong.
If you want to see the whole path from a spec to a client that paginates, the OpenAPI to TypeScript SDK and OpenAPI to Python SDK pages walk through what gets generated. And if your list endpoints do not declare a response schema at all, the spec audit will tell you which ones, since a page you cannot type is a page nobody can iterate.



