Pagination¶
Two models, one rule of thumb: offset pages for browsing, cursors for iterating.
Every list response uses the same envelope:
{
"data": [ ... ],
"pagination": { ... },
"meta": { "dataset": "...", "api_version": "v1", "last_updated": "...", "generated_at": "..." },
"links": { "self": "...", "next": "..." }
}
links.next is a ready-to-request URL for the next page (or null on the last one) — in
either pagination mode, you can simply follow it.
Every timestamp the envelope carries (generated_at, last_updated, and the discovery
fields updated_at / imported_at) is RFC 3339 UTC with seconds precision and a Z
suffix — one format everywhere: 2026-07-25T07:53:40Z.
Offset pagination¶
The default. ?limit= (1–1000, default 50) and ?offset= (default 0):
curl -H "Authorization: Bearer $API_KEY" \
"https://api.canovix.ro/v1/geospatial/siruta?limit=50&offset=100"
{
"data": [ ... ],
"pagination": {
"total": 16978,
"count": 50,
"limit": 50,
"offset": 100,
"has_next": true,
"next_offset": 150
},
"meta": { ... },
"links": { "self": "...", "next": ".../siruta?limit=50&offset=150" }
}
Combines freely with filters, sort, and fields. It's the right choice for UIs: page
numbers, "1–50 of 16,978" labels, jumping to page N.
Skip the count when you don't need it
Computing total is the expensive part of a filtered page on millions of rows. Add
?count=false and total comes back null while has_next/next_offset still
work — the cheap way to page through without caring how many rows match.
Very large datasets omit total by default
On the largest datasets the counting scan costs far more than the page it accompanies
— on the 19M-row company registry it was 331 ms against 5 ms for the rows themselves.
There, pagination.total is null unless you ask: pass ?count=true to get the
exact figure. has_next, next_offset and links.next work either way, so paging
code needs no change; only a "N of M" label does.
Always read total rather than assuming it is present — it is null whenever the
count was skipped, whether by your ?count=false or by this default.
Deep offsets are expensive
offset=100000 makes the database read and discard 100,000 rows before returning any.
If you're walking a large dataset front to back, use a cursor — or skip pagination
entirely with a bulk export.
Cursor pagination¶
Keyset iteration for deep scans: page 10,000 costs the same as page 1. Start with
?cursor=start and follow pagination.next_cursor (or just links.next) until it is
null:
{
"data": [ ... ],
"pagination": { "count": 500, "limit": 500, "has_next": true, "next_cursor": "djEuMTIzNDU2" },
"meta": { ... },
"links": { "self": "...", "next": "...?cursor=djEuMTIzNDU2&limit=500" }
}
curl -H "Authorization: Bearer $API_KEY" \
"https://api.canovix.ro/v1/transport/vehicle_registry?cursor=start&limit=500"
# ...then follow pagination.next_cursor (or links.next):
curl -H "Authorization: Bearer $API_KEY" \
"https://api.canovix.ro/v1/transport/vehicle_registry?cursor=djEuMTIzNDU2&limit=500"
def iter_rows(client, path, limit=1000, **filters):
cursor = "start"
while cursor is not None:
page = client.get(
path, params={**filters, "cursor": cursor, "limit": limit}
).json()
yield from page["data"]
cursor = page["pagination"]["next_cursor"]
for row in iter_rows(client, "/v1/transport/vehicle_registry", county="CLUJ"):
...
async function* iterRows(path, filters = {}, limit = 1000) {
let cursor = "start";
while (cursor !== null) {
const params = new URLSearchParams({ ...filters, cursor, limit });
const page = await (
await fetch(`${BASE}${path}?${params}`, {
headers: { Authorization: `Bearer ${API_KEY}` },
})
).json();
yield* page.data;
cursor = page.pagination.next_cursor;
}
}
for await (const row of iterRows("/v1/transport/vehicle_registry", { county: "CLUJ" })) {
// ...
}
Rules of the cursor mode:
- The cursor token is opaque — store and echo it, never parse or construct it.
- Rows arrive in stable insertion order; filters and
fieldswork as usual. - No
total— not counting matches is what makes deep iteration cheap. - Cannot be combined with
offsetorsort(422 if you try). - Cursors don't survive a dataset re-ingest that replaces the table; restart from
cursor=startif a long-running walk starts returning errors.
Which one — or neither?¶
| You want to… | Use |
|---|---|
| Show a paged table / page numbers in a UI | offset |
| Know how many rows match a filter | offset (pagination.total) |
| Page cheaply without a row count | offset + ?count=false |
| Get the exact total on a very large dataset | offset + ?count=true |
| Walk every row of a big dataset | cursor |
| Load a whole dataset into a file, DataFrame, or database | bulk export |