Pagination: walking large result sets
Most data endpoints in the API can return more rows than fit in a
single response. The protocol gives you one body-driven way to walk
the rest: an explicit limit/offset pair plus
the pagination.next_offset field in the JSON envelope. A
nominal link: rel="next" response header also appears on
paginated responses, but is unfollowable on this deployment — see
the gotcha below.
The two parameters that bound a page
Every data endpoint accepts two query parameters that together define a page of results:
limit— The maximum number of records to return in this response. Defaults to 1000; the server will not return more than 10 000 even if you ask for it.offset— The number of records to skip before the first row in this response. Defaults to 0.
Limit/offset is simple, debuggable, and stateless — the canonical choice for backfills and ad-hoc analysis. It is, however, prone to two well-known failure modes: at very deep offsets the engine still has to count past every preceding row (linear cost), and any concurrent insert/update upstream of your offset will shift rows under you (skipped or repeated rows). Neither matters for a static historical dataset queried with a stable sort, which is the common case here. For relentlessly streamed data you would want a cursor keyed off a monotonic field instead.
Reading where you are: the JSON body
Every JSON response from a paginated endpoint embeds a
pagination object alongside data:
{
"data": [
{
"TradeDate": "2023-01-17",
"EventDateTime": "2023-01-17T10:03:08.220987-05:00",
"EventType": "TRADE",
"Ticker": "AAPL",
"ASID": 1010000000001033,
"Price": 133.696,
"Quantity": 53,
"Exchange": "NASDAQ",
"ConditionCode": 2684354593
},
{
"TradeDate": "2023-01-17",
"EventDateTime": "2023-01-17T10:03:08.257651-05:00",
"EventType": "TRADE",
"Ticker": "AAPL",
"ASID": 1010000000001033,
"Price": 133.7044,
"Quantity": 2,
"Exchange": "FINRA",
"ConditionCode": 2147483649
},
{
"TradeDate": "2023-01-17",
"EventDateTime": "2023-01-17T10:03:08.270364-05:00",
"EventType": "TRADE",
"Ticker": "AAPL",
"ASID": 1010000000001033,
"Price": 133.701,
"Quantity": 1,
"Exchange": "FINRA",
"ConditionCode": 2147483649
}
],
"pagination": {
"offset": 0,
"limit": 3,
"next_offset": 3
}
}
The fields are:
pagination.offset— The offset of the current page (echoes the value the server interpreted from your request).pagination.limit— Echoes the limit you asked for.pagination.next_offset— An integer giving the offset to use for the next page, ornullwhen the current page is the last one. This is the canonical end-of-walk signal.
:::caution Gotcha
The pagination object on the deployed API is
{offset, limit, next_offset}; there is no
has_next boolean. Terminate the walk on
pagination["next_offset"] is None (Python) /
pagination.next_offset === null (JS); a typed client should
declare next_offset as Optional[int] /
int | null / *int.
:::
:::caution Gotcha
On responses where data is empty, pagination.offset
comes back as null rather than as an integer. If you have
declared offset as int in a typed client (Pydantic,
TypeScript, Go), use Optional[int] / int | undefined
/ *int so empty days do not crash the parser.
:::
Reading where you are: the link header
The same pagination state is nominally also exposed as an
RFC 5988 link: <URL>; rel="next" HTTP response header.
Inspect the response headers and you will see something like:
Content-Type: application/json
Content-Length: 704
Connection: keep-alive
Date: Fri, 08 May 2026 20:18:39 GMT
x-amzn-Remapped-Date: Fri, 08 May 2026 20:18:39 GMT
x-amzn-RequestId: f248981c-076d-4282-a00a-eaa2870ba925
x-amzn-Remapped-Content-Length: 704
x-amzn-Remapped-Connection: keep-alive
x-request-id: 9b8fcf85-cb5b-4aae-b1f7-d1639c41f9c1
x-amz-apigw-id: dD-9CHfKIAMEnkQ=
x-amzn-Remapped-Server: nginx/1.18.0 (Ubuntu)
link: <http://3.89.22.195/api/v1/data/us-equity/eq-trades/2023-01-17/AAPL?adjusted=true&offset=3&limit=3>; rel="next"
X-Cache: Miss from cloudfront
Via: 1.1 1223fdba7e12210962498f19cd8ed6d8.cloudfront.net (CloudFront)
X-Amz-Cf-Pop: GRU3-P3
X-Amz-Cf-Id: eK5LLQK_Wq6bEo4UAThDCxZRY7kH827y46clDj7JZEqfhKQy5Dlr2A==
Do not follow the Link URL. On the deployed dev
API the URL it carries points at an internal origin host over plain
http:// and is unsafe to follow; the body's
pagination object is the only correct pagination signal.
See pitfalls item 2 for the full account.
The X-Pagination-Limit, X-Pagination-Next-Offset,
and X-Pagination-Offset response headers are emitted
on csv and csv_gzip responses but not on
json; on page 1 the X-Pagination-Offset value comes
back empty. The canonical signal remains the body's
pagination.next_offset — see pitfalls item 1.
Walking a paginated dataset (Python)
A production-grade walker should:
- carry the per-call timing for telemetry,
- respect a minute-quota by sleeping when the quota header /
GET api/v1/account/my/quotasindicates the next request would exceed the limit, - be re-entrant: if the loop is interrupted, it should resume
from the last successful
offsetrather than restart, - validate the response against the expected schema, and
- retry transient errors (
5xx, network timeouts) with capped exponential backoff and jitter.
The full implementation appears in the ingestion-client chapter. Below is the kernel of the loop, decoupled from the production cross-cutting concerns:
from __future__ import annotations
from typing import Iterator
import requests
def paginate(session: requests.Session, url: str,
params: dict, page_size: int = 10_000) -> Iterator[dict]:
next_offset: int | None = 0
while next_offset is not None:
page_params = {**params, "limit": page_size, "offset": next_offset,
"response_format": "json"}
resp = session.get(url, params=page_params, timeout=60)
resp.raise_for_status()
payload = resp.json()
for row in payload.get("data", []):
yield row
next_offset = (payload.get("pagination") or {}).get("next_offset")
Choosing a sensible page size
The default page size is 1 000 records; the maximum is 10 000. The trade-off is between per-request overhead and per-request risk:
- Smaller pages (e.g. 100–500) make individual requests faster, fail less catastrophically (a transient timeout costs you only that page, not 10 000 records you have to refetch), and stream nicely into memory-bounded consumers.
- Larger pages (e.g. 5 000–10 000) reduce the per-request fixed overhead — the latency of TLS, routing, entitlement check, and JSON serialisation — which dominates on small responses.
For large historical backfills that need to complete in bounded wall-clock time, 10 000 minimises the per-request fixed cost. For streaming-style consumers that want to start emitting downstream rows as soon as possible, 1 000 strikes a better balance. Always benchmark against your own pipeline; the answer changes with response width (number of columns) more than with response height (number of rows).
Verifying you walked the whole dataset
A simple post-condition for any pagination walk is: did the last
response have pagination.next_offset == null? If yes, you
saw every row. If no (e.g. your loop exited early because of an
exception), you stopped partway and need to resume.
Backfill checkpointing.
Persist the (URL, query, last
successful offset) tuple after every page. On restart, resume from the
checkpoint rather than from offset 0. For idempotency, identify the
"natural key" of each row (typically TradeDate + EventDateTime + Ticker + Sequence or similar) and use a target-store
upsert so re-fetching the last page on resume is harmless. The
ingestion-client chapter walks through the full
implementation.