API StudioSDK StudioMonitoringMCP ServerSpec AuditFeatures
ComparePricingBlogDocs
Log inStart for free
API StudioSDK StudioMonitoringMCP ServerSpec AuditFeatures
ComparePricingBlogDocs
Log inStart for free
API StudioSDK StudioMonitoringMCP ServerSpec AuditFeatures
ComparePricingBlogDocs
Log inStart for free
Blog/Engineering
Engineering·September 1, 2026·5 min read

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.

#openapi#pagination#sdks#api-design
Four ways APIs paginate, and what each one does to your SDK

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.

json
1
{ "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.

json
1
{ "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:

typescript
123
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:

python
12
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:

go
1234
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:

java
123456
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.

← PreviousInside Octri API Studio: Docs You Edit Like an App, Not a Repo
Next →The API docs your OpenAPI spec cannot generate

Related articles

What counts as a breaking change when your SDK is generated
Engineering·5 min read

What counts as a breaking change when your SDK is generated

A one-line spec edit can break every call site your users wrote. Here is the table of what is additive, what is breaking, and which ones your spec diff will not warn you about.

September 11, 2026
Your operationIds are your public method names, and unique is not enough
Engineering·5 min read

Your operationIds are your public method names, and unique is not enough

An operationId is not documentation metadata. It is the name your users type. Here is why uniqueness does not save you, and what actually collides.

September 9, 2026
Should you hand-write your SDKs or generate them?
Engineering·5 min read

Should you hand-write your SDKs or generate them?

The honest answer depends on how many languages you ship and how often your API changes. For one language and a stable API, hand-writing wins.

September 9, 2026
41% of APIs drift within 30 days, and most of it is invisible
Engineering·5 min read

41% of APIs drift within 30 days, and most of it is invisible

Schema drift is not usually a breaking change. It is a field nobody told you about, found by a test that failed in CI two weeks later.

September 7, 2026

A letter when something ships

New SDK languages, changes in the generator, and now and then a longer piece on keeping docs from rotting. Roughly one a month.

Join developers keeping tabs on Octri.

Octri

Upload an OpenAPI spec. Get complete docs and production-ready SDKs in 10 languages, live in minutes.

Contact support

Product

  • API Studio
  • SDK Studio
  • Monitoring
  • MCP Server
  • Pricing
  • Compare
  • Blog
  • Changelog
  • Press Kit

From your spec

  • Spec Audit
  • TypeScript SDK
  • Python SDK
  • Go SDK
  • Java SDK
  • MCP Server

Developers

  • Documentation
  • API Reference
  • SDK Libraries
  • MCP Server
  • Monitoring
  • CLI
  • Support

Legal

  • Terms of Service
  • Privacy Policy
  • Fair Use Policy
  • Data Processing (DPA)
  • Cookie Policy
  • Security
  • Subprocessors

© 2026 Octri, LLC. All rights reserved.

Made by devs who got tired of hand-writing SDKs.