Pagination
A paginated method returns a page instead of a raw response. The page carries the items, remembers the request that produced them, and knows how to ask for the next one — so iterating it walks the whole collection, and no caller has to reimplement your paging rules.
Pagination is declared, never guessed. An offset parameter is not always a pager and a next field is not always a cursor, so the generator refuses to infer paging from parameter names: you state the scheme once, and every target generates its own idiomatic helper from it.
Start here
Say the operation takes offset and size as query parameters and returns the items under assets:
GET /workspaces/{workspace_id}/assets?offset=0&size=50
Declare one scheme and point the method at it:
{
"pagination": [
{
"name": "offsetPage",
"type": "offset",
"request": {
"offset": { "type": "offset", "param": "offset" },
"size": { "type": "limit", "param": "size" }
},
"response": {
"assets": { "type": "items" },
"total": { "type": "total" }
}
}
],
"resources": {
"assets": {
"methods": {
"list": {
"endpoint": "get /workspaces/{workspace_id}/assets",
"paginated": "offsetPage"
}
}
}
}
}
That is the whole change. offset and size stay ordinary parameters your callers can pass on the first request — the SDK takes over advancing them:
// Walks every page.
for await (const asset of client.assets.list(workspaceId, { size: 50 })) {
console.log(asset.id);
}
// Or a page at a time.
const page = await client.assets.list(workspaceId, { size: 50 });
page.assets; // the response fields are still there
page.total;
if (page.hasNextPage()) {
const next = await page.getNextPage();
}
for await (const p of page.iterPages()) {
console.log(p.assets.length);
}
# Walks every page.
for asset in client.assets.list(workspace_id, size=50):
print(asset.id)
# Or a page at a time.
page = client.assets.list(workspace_id, size=50)
page.assets
page.total
if page.has_next_page():
next_page = page.get_next_page()
for p in page.iter_pages():
print(len(p.assets))
The async client returns an async page, iterated with async for and async for p in page.iter_pages().
// Walks every page.
iter := client.Assets.ListAutoPaging(ctx, workspaceID, acme.AssetListParams{Size: acme.Int(50)})
for iter.Next() {
asset := iter.Current()
fmt.Println(asset.ID)
}
if err := iter.Err(); err != nil {
return err
}
// Or a page at a time.
page, err := client.Assets.List(ctx, workspaceID, acme.AssetListParams{Size: acme.Int(50)})
next, err := page.GetNextPage()
Declaring a scheme
A scheme can live in either place, and the shape is identical in both.
In the SDK configuration. pagination holds an array of complete named schemes, and methods select one by name. This is the better home when the same scheme covers several operations, several documents, or several targets.
In the OpenAPI document, through x-scalar-pagination. At the document root the value is that same array of named schemes. On an operation it is a scheme name, false, or a complete scheme inline — which is the short path when one operation has one shape:
paths:
/workspaces/{workspace_id}/assets:
get:
operationId: listAssets
x-scalar-pagination:
name: offsetPage
type: offset
request:
offset: { type: offset, param: offset }
size: { type: limit, param: size }
response:
assets: { type: items }
total: { type: total }
Inline schemes that are structurally identical collapse onto one named scheme, because the name becomes a public class in every generated SDK and one shape must not produce several.
Values read from an OpenAPI document are untrusted input: a scheme that does not satisfy the configuration schema, or whose field names are not plain identifiers, is ignored rather than generated.
Binding a method
A method opts in through paginated:
| Value | Effect |
|---|---|
"offsetPage" |
Paginates with that named scheme. |
false |
Never paginates, whatever the document says. |
| Omitted | Falls back to the document's pagination markers. If a marker names a type and exactly one configured scheme has that type, the method adopts it. More than one match is ambiguous, so the method stays unpaginated and reports Pagination/AmbiguousMarker. |
true |
Treated as silence, the same as omitting it. It carries meaning only while importing a Fern, Speakeasy, or Stainless configuration, where it opts a non-list method into scheme discovery. |
Configuration always wins over the document: a named scheme or an explicit false is never overridden by a marker.
Scheme reference
| Key | Required | What it is |
|---|---|---|
name |
Yes | The name methods reference through paginated, and the stem of the generated page class. |
type |
Yes | The paging strategy. One of offset, pageNumber, cursor, cursorId, cursorUrl, fakePage. |
request |
Yes | The parameters the next page is built from, keyed by field name. |
response |
Yes | The fields read off the response, keyed by field name. |
description |
No | Human-readable note about how the strategy works, for whoever reads the configuration next. It is carried through the compiled output but no target renders it into generated code. |
paramLocation |
No | Default wire location for the request fields, query or body. A field's own location overrides it. |
continueOnEmptyItems |
No | Keep paging when a page returns no items. Off by default, because an empty page normally means the collection is exhausted. |
Request fields
Each entry under request describes one parameter the SDK sets when it asks for the next page.
| Key | What it is |
|---|---|
type |
The field's role: offset, pageNumber, cursor, cursorId, cursorUrl, limit, or pageSize. |
param |
The wire name of the parameter. Defaults to the entry's key. |
location |
query or body. Defaults to paramLocation, then query. The schema also accepts header, but a generated page sends its paging parameters in the query string or the body only, so a header paging parameter is advanced as a query parameter. |
path, property |
Where the field sits inside a request body, for body-located paging. |
value |
A literal value to send for this field on every request. |
schema |
The field's schema, which types the parameter in the generated params object. Absent, a cursor role types as a string and the numeric roles as a number. |
required |
Whether the generated parameter is required. |
limit and pageSize are the same role under two names — use whichever matches your API's vocabulary.
The wire name is param, defaulting to the entry key, and it is also what the field is called on the generated params object.
Response fields
Each entry under response describes one field the page reads out of the response.
| Key | What it is |
|---|---|
type |
The field's role: items, cursor, cursorId, cursorUrl, offset, pageNumber, hasMore, total, totalPages, currentPage, or pageSize. |
property |
The response property holding the value. |
path |
The path to it, for a value nested inside an envelope, such as ["data", "items"]. |
location |
body, header, linkHeader, or bodyLink. Defaults to body. See Where fields are read from. |
headerName, rel |
The header and link relation for a header or Link-header field. Default to Link and next. |
itemCursor |
The path within each item to the value used as the next cursor, for cursorId schemes. |
cursorPath |
The path within this field's own object to the cursor, such as ["next"] when the field is a links envelope. The field keeps its declared shape; only the cursor read descends. |
schema |
The field's schema, which types the member on the generated page. |
A field's entry key names the member on the generated page, while property and path say where to read it from. Both default to the key, so the two forms differ only in what the page member ends up called:
"assets": { "type": "items" }readsassetsoff the body and exposespage.assets."items": { "type": "items", "property": "assets" }reads the same field and exposespage.items.
Declare a field with the items role on every scheme. Without one the compiler falls back to a data property, which is a guess about your API rather than a statement about it.
Strategies
offset
Request an offset plus a limit/pageSize. The next request sets the offset parameter to the current offset plus the page size, where the page size is the limit the caller sent, falling back to the number of items the current page returned. Declare a total response field and paging stops once the next offset reaches it.
{
"name": "offsetPage",
"type": "offset",
"request": {
"offset": { "type": "offset", "param": "offset" },
"limit": { "type": "limit", "param": "limit" }
},
"response": {
"items": { "type": "items", "property": "data" },
"total": { "type": "total", "property": "total_count" }
}
}
An absent offset counts as 0, so a caller who passes neither parameter still gets correct paging from the first page. A page size that resolves to zero or to something that is not a number stops paging rather than looping on the same offset.
pageNumber
Request a page number plus an optional page size. The next request increments the page number, starting from 1 when the caller sent none. Declare a totalPages response field and paging stops on the last page.
{
"name": "numberedPage",
"type": "pageNumber",
"request": {
"page": { "type": "pageNumber", "param": "page" },
"per_page": { "type": "pageSize", "param": "per_page" }
},
"response": {
"items": { "type": "items", "property": "results" },
"total_pages": { "type": "totalPages", "property": "total_pages" }
}
}
cursor
The response carries an opaque token that the next request sends back. Paging stops when the token is missing, empty, or not a string or number — and immediately when a hasMore field comes back false.
{
"name": "cursorPage",
"type": "cursor",
"request": {
"cursor": { "type": "cursor", "param": "starting_after" },
"limit": { "type": "limit", "param": "limit" }
},
"response": {
"items": { "type": "items", "property": "data" },
"next_cursor": { "type": "cursor", "property": "next_cursor" },
"has_more": { "type": "hasMore", "property": "has_more" }
}
}
When the token sits inside an envelope, keep the envelope as the field and point cursorPath at the token:
{
"links": { "type": "cursor", "property": "links", "cursorPath": ["next"] }
}
cursorId
There is no cursor field: the next request sends an identifier taken from the last item on the page. Point itemCursor at the path within an item.
{
"name": "cursorIdPage",
"type": "cursorId",
"request": {
"starting_after": { "type": "cursorId", "param": "starting_after" }
},
"response": {
"items": { "type": "items", "property": "data", "itemCursor": ["id"] }
}
}
cursorUrl
The response carries a fully-formed URL for the next page rather than a token.
Support is uneven. Rust re-issues the request against the URL, which it reads from a bodyLink response field. Every other target writes the cursor's value into a request parameter, which is not what a URL is for, so such a scheme under-fetches rather than looping: Ruby declines the scheme, C# renders the last page, and the rest stop after the first. Prefer a cursor scheme over a URL where your API offers both.
fakePage
The operation returns the whole collection in one response. There is no next page, but the result is still wrapped in a page type so callers iterate collections uniformly across your SDK.
{
"name": "singlePage",
"type": "fakePage",
"request": {},
"response": {
"items": { "type": "items", "property": "data" }
}
}
When paging stops
Under every strategy, the page asks for another only when all of these hold:
- The current page returned at least one item, unless the scheme sets
continueOnEmptyItems. - A
hasMorefield, if declared, is notfalse. - The strategy can build a next request: a usable cursor, or an offset or page number that has not reached a declared
totalortotalPages.
Whatever the caller sent with the first request — headers, query parameters, a body, per-request options — is reused for every page after it, with only the paging parameters changed. An idempotency key is the exception: it belongs to the one request it was sent with, so each page gets its own.
What gets generated
The scheme name becomes the page type, cased for each language: offsetPage generates OffsetPage in TypeScript, SyncOffsetPage and AsyncOffsetPage in Python, and so on. Two schemes that would case to the same name get a numeric suffix, so the name in your configuration is worth choosing deliberately.
Alongside the page type, a target generates a params type carrying the scheme's request fields (so a paginated method accepts offset and size as normal arguments) and a response type carrying its response fields (so page metadata such as total stays accessible next to the items).
| Target | Walking every item | One page at a time |
|---|---|---|
| TypeScript | for await (const item of page) |
page.hasNextPage(), page.getNextPage(), page.iterPages() |
| Python | for item in page, async for item in page |
page.has_next_page(), page.get_next_page(), page.iter_pages() |
| Go | client.X.ListAutoPaging(...) with Next(), Current(), Err() |
page.GetNextPage() |
| Java, Kotlin | page.autoPager() |
page.hasNextPage() |
| Ruby | page.auto_paging_each with a block |
page.next_page |
| C# | await foreach (var item in page.Paginate()) |
page.HasNext(), page.Next() |
| PHP | foreach ($page as $item), $page->pagingEachItem() |
$page->hasNextPage(), $page->getNextPage() |
| Rust | let mut pager = ...paginate(); then pager.next().await |
Pager::next_page |
| Dart | — | Page<T> with hasNextPage() and getNextPage() |
| C++ | — | Page<T> carrying the items field and next-page metadata, without fetching it |
| Swift | — | Not yet: a paginated method returns the response as it is |
Your generated README also picks up a ## Pagination section walking a real paginated operation. Choose which one with readme.exampleRequests.pagination.
Where fields are read from
Generated pages read their fields out of the response body. A field declared with location: header or linkHeader is carried through the compiled output and the generated manifests, and C++ and Dart surface it as page metadata, but no target's page advances from a header, so a scheme whose only cursor lives in one stops after the first page. bodyLink is read by Rust, which follows it as a next-page URL, and by nothing else.
cursorPath and itemCursor are read by TypeScript and Python; C# reads itemCursor only. On the other targets a scheme relying on either stops after the first page, so prefer a top-level cursor field where your API offers one.
If your API pages by a Link header and you need it followed, tell us — it is a gap we are tracking, not a design decision.
Diagnostics
Three rules watch pagination, and all three report against a specific method or scheme:
| Code | Severity | What it means |
|---|---|---|
Pagination/UnknownScheme |
error |
A method's paginated names a scheme that pagination does not define. That method degrades to unpaginated. |
Pagination/AmbiguousMarker |
warn |
The document's markers matched more than one configured scheme of the same type. The compiler refuses to guess, so the method stays unpaginated. Set paginated explicitly. |
Pagination/UnusedScheme |
warn |
A configured scheme is bound to no method — usually the remains of a renamed or removed paginated reference. |
See Diagnostics for how findings are graded, gated, and suppressed.
Related
- Configuration — the
paginationblock in context, besideresourcesandclientSettings - OpenAPI Extensions — declaring schemes in the document with
x-scalar-pagination - Diagnostics — what a build reports when a scheme cannot be resolved