---
title: Paginate Endpoint results with a cursor
meta:
    description: Use cursor pagination to page through Endpoint results.
headingMaxLevels: 3
---

# Paginate Endpoint results with a cursor

Cursor pagination, also called keyset pagination, uses values from the last row of one page to retrieve the next page. Use it to process a large result set sequentially without the increasing query cost of a large `OFFSET`.

This guide creates a Data Source and an API Endpoint that paginate tenant activity by timestamp and activity ID.

## Before you start

Before you create the resources, ensure you have:

- A Tinybird project
- The Tinybird CLI installed
- A dataset with columns that define a stable, unique order

## Step 1: Create the Data Source

Create a `tenant_activity_log.datasource` file:

```tinybird {% title="datasources/tenant_activity_log.datasource" %}
DESCRIPTION >
    Fake tenant activity data for testing keyset pagination

SCHEMA >
    `tenant_id` String `json:$.tenant_id`,
    `occurred_at` DateTime64(3) `json:$.occurred_at`,
    `activity_id` UInt64 `json:$.activity_id`,
    `activity_type` LowCardinality(String) `json:$.activity_type`,
    `details` String `json:$.details`

ENGINE "MergeTree"
ENGINE_SORTING_KEY "tenant_id, occurred_at, activity_id"
```

The sorting key matches how the Endpoint filters and orders the data:

- `tenant_id` identifies one tenant and appears first in the sorting key.
- `occurred_at` orders the tenant's activity chronologically.
- `activity_id` provides a unique tie-breaker when multiple activities have the same timestamp.

## Step 2: Create the API Endpoint

Create a `tenant_activity_page.pipe` file:

```tinybird {% title="pipes/tenant_activity_page.pipe" %}
TOKEN tenant_activity_page_read READ

NODE paginated_activity
SQL >
    %
    SELECT
        tenant_id,
        occurred_at,
        activity_id,
        activity_type,
        details
    FROM tenant_activity_log
    WHERE tenant_id = {{String(tenant_id, required=True)}}

    {% if defined(cursor_occurred_at) and defined(cursor_activity_id) %}
        AND (occurred_at, activity_id) < (
            {{DateTime64(cursor_occurred_at)}},
            {{UInt64(cursor_activity_id)}}
        )
    {% end %}

    ORDER BY
        occurred_at DESC,
        activity_id DESC

    LIMIT {{UInt16(page_size, 5)}}

TYPE ENDPOINT
```

The query orders results by `occurred_at` and `activity_id`, the same columns that follow `tenant_id` in the sorting key. This alignment lets ClickHouse® skip data that precedes the cursor instead of scanning rows for an increasingly large `OFFSET`.

The tuple comparison uses both ordered columns. Including `activity_id` prevents the query from skipping or repeating rows that share an `occurred_at` value.

## Step 3: Request the first page

Call the Endpoint without cursor parameters to retrieve the first page:

```shell {% title="Request the first page" %}
curl --get "$TENANT_ACTIVITY_URL" \
  -H "Authorization: Bearer $TENANT_ACTIVITY_TOKEN" \
  --data-urlencode "tenant_id=acme" \
  --data-urlencode "page_size=5"
```

Save the `occurred_at` and `activity_id` values from the last row in the response. These values form the cursor for the next request.

## Step 4: Request the next page

Pass both cursor values to retrieve rows that follow the last row from the previous response:

```shell {% title="Request the next page" %}
curl --get "$TENANT_ACTIVITY_URL" \
  -H "Authorization: Bearer $TENANT_ACTIVITY_TOKEN" \
  --data-urlencode "tenant_id=acme" \
  --data-urlencode "page_size=5" \
  --data-urlencode "cursor_occurred_at=2026-07-31 13:56:00.000" \
  --data-urlencode "cursor_activity_id=106"
```

Repeat the request with the cursor from each response until the Endpoint returns no rows.

## Choose a pagination method

Cursor pagination and `LIMIT` with `OFFSET` support different navigation patterns:

| Cursor pagination | `LIMIT` with `OFFSET` |
| ------ | ------ |
| Keeps page boundaries stable when new rows arrive before the cursor | Page boundaries can shift when new rows arrive |
| Retrieves pages sequentially | Supports jumping to a page number |
| Starts after a specific row | Skips a specified number of rows |
| Avoids scanning all preceding rows when the query matches the sorting key | Processes more preceding rows as the offset grows |
| Suits scrolling interfaces and batch processing | Suits interfaces that require direct page navigation |

Use cursor pagination when clients read results sequentially or when deep offsets make queries expensive. Use `LIMIT` with `OFFSET` when clients need direct access to numbered pages and the result set is small enough that offset cost is acceptable.

## See also

- [Use query parameters for pagination](/forward/query-data/query-parameters#pagination)
- [Review MergeTree sorting keys and indexes](/sql-reference/engines/mergetree#sorting-keys-and-indexes-in-queries)
