[hypequery](https://hypequery.com/) › [Recipes](https://hypequery.com/recipes) › Cursor pagination in ClickHouse: keyset vs LIMIT/OFFSET, measured
Index for agents: https://hypequery.com/llms.txt

# Cursor pagination in ClickHouse: keyset vs LIMIT/OFFSET, measured (https://hypequery.com/recipes/cursor-pagination)

Page a tenant's events newest first with a keyset cursor on (occurred_at, event_id) and see from EXPLAIN and query_log why it beats OFFSET on deep pages.

## Run it

```bash
npx @hypequery/cli create cursor-pagination -y
cd cursor-pagination
pnpm install
pnpm dev
```

- Packages: @hypequery/clickhouse (tested from hypequery 2.14.0)
- Stack: node, clickhouse
- Source: https://github.com/hypequery/hypequery/tree/main/recipes/cursor-pagination
- Test: `pnpm test`
- Verify: `node dist/recipe.js --json`

| Env var | Required | Description |
| --- | --- | --- |
| CLICKHOUSE_URL | no | ClickHouse HTTP endpoint (default http://localhost:8123) |
| CLICKHOUSE_USERNAME | no | ClickHouse user (default: default) |
| CLICKHOUSE_PASSWORD | no | ClickHouse password (default: hypequery, the seed's password) |
| CLICKHOUSE_DATABASE | no | Database holding the seed tables (default hypequery_examples) |

For cursor pagination in ClickHouse, order by a unique key that follows the table's sort key, `ORDER BY occurred_at DESC, event_id DESC`, and fetch the next page with `WHERE occurred_at < :t OR (occurred_at = :t AND event_id < :id)`, where `:t, :id` is the last row of the previous page. On the seed, page 1,001 of one tenant read about 6,000 to 9,000 rows that way and about 105,000 rows with `OFFSET 50000`, the tenant's whole history. Write the condition as the expanded `OR`, not as the tuple `(occurred_at, event_id) < (:t, :id)`: on ClickHouse 25.8 the tuple form is not used by partition pruning or the primary key. Longer background: [How to paginate ClickHouse results in TypeScript](https://hypequery.com/blog/clickhouse-pagination-typescript).

## Demo

```text
Tenant 47, newest first, 50 per page

page 1   825213  2026-10-31 23:58:36  api_call   /api/v1/datasets
         777014  2026-10-31 23:44:38  page_view  /dashboard
         ...
         499194  2026-10-31 16:49:06  login      /login        <- cursor
page 2   196645  2026-10-31 16:46:30  page_view  /explore
         635639  2026-10-31 16:44:31  api_call   /api/v1/query
         ...
         508131  2026-10-31 12:14:23  api_call   /api/v1/users <- next cursor

OFFSET vs keyset, 50 rows a page

page   offset    granules after the primary key    rows read (query_log)
                 OFFSET      keyset                OFFSET     keyset
   1        0       106         106                9-12k      9k
  21    1,000       106         105                27k        9-12k
 201   10,000       106          96                89-104k    9-12k
1001   50,000       106          54                105k       6-9k
```

Granule counts come from `EXPLAIN indexes = 1` and are pinned by the test. Rows read come from `system.query_log` and move by a granule or two between runs with `max_threads` (8 cores here), so the test asserts their shape, not exact values.

## Install

```bash
npx @hypequery/cli create cursor-pagination -y
cd cursor-pagination
docker compose -f seed/docker-compose.yml up -d --wait && (cd seed && pnpm install && pnpm seed)   # or set CLICKHOUSE_* for your own ClickHouse
pnpm install
pnpm dev
```

`pnpm dev` prints the first two pages, then builds `cursor_pagination_events` (a compacted copy of `events`, see below), prints the cost table and drops the copy again.

## What it shows

- **A keyset page with the typed query builder** (`src/pagination.ts`): `whereGroup` / `orWhereGroup` build the `OR` condition, and the cursor is an opaque base64url string of `{ occurredAt, eventId }`.
- **The generated SQL is asserted** (`expected.sql`): page 2's statement, with the cursor bound as parameters.
- **The first two pages are pinned** (`output.json`), and the test walks five pages both ways to prove keyset and OFFSET return the same rows.
- **Ties are handled**: tenant 47 has three events at 2026-10-30 14:03:47; a cursor on `occurred_at` alone skips the rest of that second, the `(occurred_at, event_id)` cursor does not. The test checks both.
- **Cost, measured** (`src/cost.ts`): granules from `EXPLAIN indexes = 1`, rows read from `system.query_log` by `queryId`.

## Key file

```ts
export function keysetPageQuery(tenantId: number, pageSize: number, after: Cursor | null) {
  let query = db.table('events')
    .select(['event_id', 'occurred_at', 'event_type', 'user_id', 'path'])
    .where('tenant_id', 'eq', tenantId);
  if (after) {
    query = query.whereGroup((q) => {
      q.where('occurred_at', 'lt', after.occurredAt).orWhereGroup((tie) => {
        tie.where('occurred_at', 'eq', after.occurredAt).where('event_id', 'lt', after.eventId);
      });
    });
  }
  return query.orderBy('occurred_at', 'DESC').orderBy('event_id', 'DESC').limit(pageSize);
}
```

## Why keyset wins, and when it does not

- **OFFSET reads what it skips.** `LIMIT 50 OFFSET 50000` has to produce 50,050 rows in order before it can drop 50,000. Already at `OFFSET 10000` the server read 89,000 to 104,000 rows, close to all of the tenant's 97,376.
- **Keyset skips with the index.** The cursor condition is a range on the sort key `(tenant_id, occurred_at, event_id)`, so partition pruning drops months after the cursor, the primary key narrows the granules (54 of 977 on page 1,001), and the plan reads in reverse sort-key order (`ReadType: InReverseOrder` in `EXPLAIN actions = 1`), so it stops once it has 50 rows.
- **The tuple trap.** `(occurred_at, event_id) < ('2026-07-09 18:05:48', 735428)` leaves 106 of 977 granules (only `tenant_id` is used, all 12 parts scanned); the expanded `OR` leaves 54 of 620 after pruning 3 parts. Same rows, different plan.
- **The sort key decides.** Keyset is fast here because `tenant_id` comes first in `ORDER BY`. The same cursor across all tenants gets partition pruning but almost nothing from the primary key (583 of the remaining 620 granules kept), because `occurred_at` is not a prefix of it. Give such a feed its own table or projection ordered by `(occurred_at, event_id)`.
- **What you give up.** No "jump to page 47" and no total page count for free. For a feed or an API that is usually fine; for a small admin table OFFSET is simpler and cheap.

### Why a copy for the measurements

The seed's `events` table holds a million rows in about 60 small parts with 8,192-row granules, and background merges change that layout over time. One granule then covers a large share of a tenant's month, so skipping barely shows, and the numbers drift. `src/cost.ts` copies the same rows into `cursor_pagination_events` with the same sort key, 1,024-row granules and one part per month (`OPTIMIZE ... FINAL`), so the comparison is stable. The pages themselves are read from `events`.

## Architecture

```text
client ── GET /events?cursor=<base64url>
             │
src/pagination.ts  keysetPage(tenant, 50, cursor)
             │       WHERE tenant_id = ? AND (occurred_at < ? OR (occurred_at = ? AND event_id < ?))
             │       ORDER BY occurred_at DESC, event_id DESC LIMIT 50
             ▼
          { rows, nextCursor }   ──▶ output.json (pages 1 and 2, asserted in CI)
```

## Adapt it

- **Oldest first.** Flip both `DESC` to `ASC` and `lt` to `gt`.
- **Another sort.** The cursor must be the full `ORDER BY` plus a unique tie-breaker, in the same direction, and ideally a prefix-compatible slice of the table's sort key.
- **Filters.** Add `where` clauses freely; they do not change the cursor. Changing the sort or filters invalidates old cursors, so put a version or the filter hash in the cursor if clients keep them.
- **Serve it.** Return `{ rows, nextCursor }` from any handler; `@hypequery/serve` or the FastAPI example show the HTTP side.
- **Aggregated lists.** Dataset queries already page grouped results with `limit` / `offset` and report `meta.pagination.hasMore`; those results are small, so OFFSET is fine there.

## Related examples

- [multi-tenant-embedded-analytics](../../examples/multi-tenant-embedded-analytics): per-tenant APIs where a feed like this lives.
- [fastapi-analytics-api](../../examples/fastapi-analytics-api): the HTTP side in Python.

Tested on ClickHouse 25.8 with `@hypequery/clickhouse` 2.14.

## Key file: src/pagination.ts

```typescript
import { db } from './client.js';
import type { Schema } from './schema.js';

export type EventsTable = keyof Schema;

/** The last row of a page: where the next page starts. Opaque to the client. */
export interface Cursor {
  occurredAt: string;
  /** UInt64 arrives as a string from the JSON API; keep it one. */
  eventId: string;
}

export const encodeCursor = (c: Cursor) => Buffer.from(JSON.stringify(c)).toString('base64url');
export const decodeCursor = (s: string): Cursor => JSON.parse(Buffer.from(s, 'base64url').toString('utf8'));

const COLUMNS = ['event_id', 'occurred_at', 'event_type', 'user_id', 'path'] as const;

/**
 * Keyset ("cursor") pagination, newest first: rows strictly before the
 * cursor in (occurred_at, event_id) order. The condition is spelled out as
 * `occurred_at < t OR (occurred_at = t AND event_id < id)` rather than the
 * tuple `(occurred_at, event_id) < (t, id)`: ClickHouse 25.8 uses the
 * expanded form for partition pruning and the primary key, and the tuple
 * form for neither.
 */
export function keysetPageQuery(tenantId: number, pageSize: number, after: Cursor | null, table: EventsTable = 'events') {
  let query = db
    .table(table)
    .select([...COLUMNS])
    .where('tenant_id', 'eq', tenantId);
  if (after) {
    query = query.whereGroup((q) => {
      q.where('occurred_at', 'lt', after.occurredAt).orWhereGroup((tie) => {
        tie.where('occurred_at', 'eq', after.occurredAt).where('event_id', 'lt', after.eventId);
      });
    });
  }
  return query.orderBy('occurred_at', 'DESC').orderBy('event_id', 'DESC').limit(pageSize);
}

export async function keysetPage(tenantId: number, pageSize: number, after: string | null) {
  const rows = await keysetPageQuery(tenantId, pageSize, after ? decodeCursor(after) : null).execute();
  const last = rows.at(-1);
  return {
    rows,
    // A short page is the last one, so no cursor; a full page may still be
    // the last, and the next call then returns zero rows.
    nextCursor: rows.length === pageSize && last ? encodeCursor({ occurredAt: String(last.occurred_at), eventId: String(last.event_id) }) : null,
  };
}

/** LIMIT/OFFSET, for comparison: ClickHouse reads and discards every skipped row. */
export function offsetPageQuery(tenantId: number, pageSize: number, page: number, table: EventsTable = 'events') {
  return db
    .table(table)
    .select([...COLUMNS])
    .where('tenant_id', 'eq', tenantId)
    .orderBy('occurred_at', 'DESC')
    .orderBy('event_id', 'DESC')
    .limit(pageSize)
    .offset(page * pageSize);
}
```

## Dataset: src/schema.ts

```typescript
/**
 * The seed's `events` table as hypequery sees it (`npx hypequery generate`
 * writes this for you). Sort key: (tenant_id, occurred_at, event_id), so
 * "one tenant's events in time order" is a contiguous range of the index,
 * and (occurred_at, event_id) is a unique, ordered cursor within a tenant.
 *
 * `cursor_pagination_events` is the recipe's own compacted copy, used only to
 * measure what each pagination style reads (see src/cost.ts).
 */
type EventColumns = {
  event_id: 'UInt64';
  tenant_id: 'UInt32';
  user_id: 'UInt32';
  event_type: 'LowCardinality(String)';
  path: 'String';
  status_code: 'UInt16';
  latency_ms: 'UInt32';
  device: 'LowCardinality(String)';
  occurred_at: "DateTime('UTC')";
};

export type Schema = {
  events: EventColumns;
  cursor_pagination_events: EventColumns;
};
```

## Generated SQL

```sql
SELECT event_id, occurred_at, event_type, user_id, path FROM events WHERE tenant_id = ? AND (occurred_at < ? OR (occurred_at = ? AND event_id < ?)) ORDER BY occurred_at DESC, event_id DESC LIMIT 50
```

## Output on the seed dataset

```json
{
  "page1": [
    {
      "event_id": "825213",
      "occurred_at": "2026-10-31 23:58:36",
      "event_type": "api_call",
      "user_id": 1889,
      "path": "/api/v1/datasets"
    },
    {
      "event_id": "777014",
      "occurred_at": "2026-10-31 23:44:38",
      "event_type": "page_view",
      "user_id": 1891,
      "path": "/dashboard"
    },
    {
      "event_id": "935492",
      "occurred_at": "2026-10-31 23:33:03",
      "event_type": "login",
      "user_id": 2031,
      "path": "/login"
    },
    {
      "event_id": "294756",
      "occurred_at": "2026-10-31 23:27:04",
      "event_type": "api_call",
      "user_id": 2058,
      "path": "/api/v1/query"
    },
    {
      "event_id": "294478",
      "occurred_at": "2026-10-31 23:07:33",
      "event_type": "api_call",
      "user_id": 1909,
      "path": "/api/v1/export"
    },
    {
      "event_id": "93102",
      "occurred_at": "2026-10-31 22:32:44",
      "event_type": "page_view",
      "user_id": 1903,
      "path": "/dashboard"
    },
    {
      "event_id": "397022",
      "occurred_at": "2026-10-31 22:26:59",
      "event_type": "api_call",
      "user_id": 1889,
      "path": "/api/v1/events"
    },
    {
      "event_id": "821664",
      "occurred_at": "2026-10-31 22:20:03",
      "event_type": "page_view",
      "user_id": 2059,
      "path": "/billing"
    },
    {
      "event_id": "179828",
      "occurred_at": "2026-10-31 22:19:17",
      "event_type": "query_run",
      "user_id": 1905,
      "path": "/explore"
    },
    {
      "event_id": "656327",
      "occurred_at": "2026-10-31 22:10:11",
      "event_type": "page_view",
      "user_id": 1963,
      "path": "/reports"
    },
    {
      "event_id": "299529",
      "occurred_at": "2026-10-31 21:48:27",
      "event_type": "query_run",
      "user_id": 2003,
      "path": "/explore"
    },
    {
      "event_id": "637913",
      "occurred_at": "2026-10-31 21:21:46",
      "event_type": "login",
      "user_id": 1968,
      "path": "/login"
    },
    {
      "event_id": "780308",
      "occurred_at": "2026-10-31 21:13:57",
      "event_type": "page_view",
      "user_id": 1891,
      "path": "/alerts"
    },
    {
      "event_id": "968988",
      "occurred_at": "2026-10-31 21:09:48",
      "event_type": "query_run",
      "user_id": 1972,
      "path": "/explore"
    },
    {
      "event_id": "127334",
      "occurred_at": "2026-10-31 20:50:17",
      "event_type": "export",
      "user_id": 1972,
      "path": "/export"
    },
    {
      "event_id": "555402",
      "occurred_at": "2026-10-31 20:49:16",
      "event_type": "login",
      "user_id": 1863,
      "path": "/login"
    },
    {
      "event_id": "82528",
      "occurred_at": "2026-10-31 20:40:52",
      "event_type": "query_run",
      "user_id": 1905,
      "path": "/explore"
    },
    {
      "event_id": "980412",
      "occurred_at": "2026-10-31 20:38:07",
      "event_type": "query_run",
      "user_id": 1979,
      "path": "/explore"
    },
    {
      "event_id": "21641",
      "occurred_at": "2026-10-31 20:35:06",
      "event_type": "page_view",
      "user_id": 1984,
      "path": "/alerts"
    },
    {
      "event_id": "489677",
      "occurred_at": "2026-10-31 20:30:10",
      "event_type": "page_view",
      "user_id": 1963,
      "path": "/dashboard"
    },
    {
      "event_id": "774752",
      "occurred_at": "2026-10-31 20:26:42",
      "event_type": "page_view",
      "user_id": 2023,
      "path": "/settings"
    },
    {
      "event_id": "908309",
      "occurred_at": "2026-10-31 20:19:41",
      "event_type": "api_call",
      "user_id": 1885,
      "path": "/api/v1/users"
    },
    {
      "event_id": "669774",
      "occurred_at": "2026-10-31 20:02:40",
      "event_type": "query_run",
      "user_id": 1945,
      "path": "/explore"
    },
    {
      "event_id": "829012",
      "occurred_at": "2026-10-31 20:01:07",
      "event_type": "page_view",
      "user_id": 1889,
      "path": "/billing"
    },
    {
      "event_id": "716654",
      "occurred_at": "2026-10-31 20:00:53",
      "event_type": "query_run",
      "user_id": 1949,
      "path": "/explore"
    },
    {
      "event_id": "738629",
      "occurred_at": "2026-10-31 20:00:49",
      "event_type": "page_view",
      "user_id": 1848,
      "path": "/explore"
    },
    {
      "event_id": "901894",
      "occurred_at": "2026-10-31 19:31:55",
      "event_type": "page_view",
      "user_id": 1978,
      "path": "/reports"
    },
    {
      "event_id": "704107",
      "occurred_at": "2026-10-31 19:19:05",
      "event_type": "api_call",
      "user_id": 1879,
      "path": "/api/v1/events"
    },
    {
      "event_id": "966726",
      "occurred_at": "2026-10-31 19:05:31",
      "event_type": "login",
      "user_id": 2008,
      "path": "/login"
    },
    {
      "event_id": "873359",
      "occurred_at": "2026-10-31 19:05:15",
      "event_type": "page_view",
      "user_id": 1889,
      "path": "/dashboard"
    },
    {
      "event_id": "709106",
      "occurred_at": "2026-10-31 19:00:05",
      "event_type": "page_view",
      "user_id": 1841,
      "path": "/reports"
    },
    {
      "event_id": "648913",
      "occurred_at": "2026-10-31 18:58:48",
      "event_type": "query_run",
      "user_id": 2059,
      "path": "/explore"
    },
    {
      "event_id": "80483",
      "occurred_at": "2026-10-31 18:56:34",
      "event_type": "error",
      "user_id": 2058,
      "path": "/dashboard"
    },
    {
      "event_id": "597547",
      "occurred_at": "2026-10-31 18:40:29",
      "event_type": "api_call",
      "user_id": 1979,
      "path": "/api/v1/users"
    },
    {
      "event_id": "973335",
      "occurred_at": "2026-10-31 18:38:36",
      "event_type": "api_call",
      "user_id": 1889,
      "path": "/api/v1/users"
    },
    {
      "event_id": "721142",
      "occurred_at": "2026-10-31 18:21:54",
      "event_type": "api_call",
      "user_id": 2008,
      "path": "/api/v1/export"
    },
    {
      "event_id": "384713",
      "occurred_at": "2026-10-31 18:17:22",
      "event_type": "page_view",
      "user_id": 2028,
      "path": "/explore"
    },
    {
      "event_id": "571022",
      "occurred_at": "2026-10-31 18:16:00",
      "event_type": "page_view",
      "user_id": 1933,
      "path": "/explore"
    },
    {
      "event_id": "970250",
      "occurred_at": "2026-10-31 18:14:56",
      "event_type": "page_view",
      "user_id": 1979,
      "path": "/settings"
    },
    {
      "event_id": "205863",
      "occurred_at": "2026-10-31 17:56:37",
      "event_type": "query_run",
      "user_id": 2023,
      "path": "/explore"
    },
    {
      "event_id": "477621",
      "occurred_at": "2026-10-31 17:54:03",
      "event_type": "api_call",
      "user_id": 1972,
      "path": "/api/v1/export"
    },
    {
      "event_id": "777899",
      "occurred_at": "2026-10-31 17:48:18",
      "event_type": "query_run",
      "user_id": 1935,
      "path": "/explore"
    },
    {
      "event_id": "510795",
      "occurred_at": "2026-10-31 17:46:47",
      "event_type": "page_view",
      "user_id": 1859,
      "path": "/reports"
    },
    {
      "event_id": "961248",
      "occurred_at": "2026-10-31 17:38:34",
      "event_type": "page_view",
      "user_id": 1933,
      "path": "/reports"
    },
    {
      "event_id": "421069",
      "occurred_at": "2026-10-31 17:36:47",
      "event_type": "page_view",
      "user_id": 2033,
      "path": "/alerts"
    },
    {
      "event_id": "239439",
      "occurred_at": "2026-10-31 17:25:42",
      "event_type": "api_call",
      "user_id": 1899,
      "path": "/api/v1/users"
    },
    {
      "event_id": "907866",
      "occurred_at": "2026-10-31 17:22:43",
      "event_type": "page_view",
      "user_id": 1934,
      "path": "/alerts"
    },
    {
      "event_id": "810745",
      "occurred_at": "2026-10-31 16:55:06",
      "event_type": "query_run",
      "user_id": 1909,
      "path": "/explore"
    },
    {
      "event_id": "3037",
      "occurred_at": "2026-10-31 16:49:18",
      "event_type": "query_run",
      "user_id": 1841,
      "path": "/explore"
    },
    {
      "event_id": "499194",
      "occurred_at": "2026-10-31 16:49:06",
      "event_type": "login",
      "user_id": 2059,
      "path": "/login"
    }
  ],
  "cursor": "eyJvY2N1cnJlZEF0IjoiMjAyNi0xMC0zMSAxNjo0OTowNiIsImV2ZW50SWQiOiI0OTkxOTQifQ",
  "page2": [
    {
      "event_id": "196645",
      "occurred_at": "2026-10-31 16:46:30",
      "event_type": "page_view",
      "user_id": 1933,
      "path": "/explore"
    },
    {
      "event_id": "635639",
      "occurred_at": "2026-10-31 16:44:31",
      "event_type": "api_call",
      "user_id": 2058,
      "path": "/api/v1/query"
    },
    {
      "event_id": "541872",
      "occurred_at": "2026-10-31 16:44:07",
      "event_type": "query_run",
      "user_id": 2032,
      "path": "/explore"
    },
    {
      "event_id": "105753",
      "occurred_at": "2026-10-31 16:42:46",
      "event_type": "api_call",
      "user_id": 1896,
      "path": "/api/v1/datasets"
    },
    {
      "event_id": "259012",
      "occurred_at": "2026-10-31 16:40:25",
      "event_type": "api_call",
      "user_id": 1889,
      "path": "/api/v1/datasets"
    },
    {
      "event_id": "432446",
      "occurred_at": "2026-10-31 16:37:14",
      "event_type": "login",
      "user_id": 2033,
      "path": "/login"
    },
    {
      "event_id": "407452",
      "occurred_at": "2026-10-31 16:36:35",
      "event_type": "page_view",
      "user_id": 1879,
      "path": "/explore"
    },
    {
      "event_id": "430566",
      "occurred_at": "2026-10-31 16:29:07",
      "event_type": "page_view",
      "user_id": 2008,
      "path": "/settings"
    },
    {
      "event_id": "87885",
      "occurred_at": "2026-10-31 16:18:49",
      "event_type": "query_run",
      "user_id": 1936,
      "path": "/explore"
    },
    {
      "event_id": "915802",
      "occurred_at": "2026-10-31 16:08:14",
      "event_type": "api_call",
      "user_id": 1891,
      "path": "/api/v1/query"
    },
    {
      "event_id": "253611",
      "occurred_at": "2026-10-31 16:06:17",
      "event_type": "page_view",
      "user_id": 1842,
      "path": "/reports"
    },
    {
      "event_id": "73579",
      "occurred_at": "2026-10-31 16:06:06",
      "event_type": "page_view",
      "user_id": 2003,
      "path": "/team"
    },
    {
      "event_id": "794247",
      "occurred_at": "2026-10-31 15:56:09",
      "event_type": "query_run",
      "user_id": 1944,
      "path": "/explore"
    },
    {
      "event_id": "528414",
      "occurred_at": "2026-10-31 15:48:16",
      "event_type": "api_call",
      "user_id": 2058,
      "path": "/api/v1/events"
    },
    {
      "event_id": "736289",
      "occurred_at": "2026-10-31 15:46:39",
      "event_type": "page_view",
      "user_id": 1933,
      "path": "/explore"
    },
    {
      "event_id": "37820",
      "occurred_at": "2026-10-31 15:29:45",
      "event_type": "page_view",
      "user_id": 2049,
      "path": "/alerts"
    },
    {
      "event_id": "499559",
      "occurred_at": "2026-10-31 15:25:23",
      "event_type": "page_view",
      "user_id": 1891,
      "path": "/billing"
    },
    {
      "event_id": "443249",
      "occurred_at": "2026-10-31 15:21:51",
      "event_type": "page_view",
      "user_id": 1889,
      "path": "/dashboard"
    },
    {
      "event_id": "413561",
      "occurred_at": "2026-10-31 15:04:21",
      "event_type": "page_view",
      "user_id": 1960,
      "path": "/team"
    },
    {
      "event_id": "625421",
      "occurred_at": "2026-10-31 14:55:00",
      "event_type": "api_call",
      "user_id": 1905,
      "path": "/api/v1/users"
    },
    {
      "event_id": "498146",
      "occurred_at": "2026-10-31 14:51:04",
      "event_type": "api_call",
      "user_id": 2049,
      "path": "/api/v1/events"
    },
    {
      "event_id": "141196",
      "occurred_at": "2026-10-31 14:40:21",
      "event_type": "api_call",
      "user_id": 1889,
      "path": "/api/v1/export"
    },
    {
      "event_id": "607155",
      "occurred_at": "2026-10-31 14:39:35",
      "event_type": "api_call",
      "user_id": 1963,
      "path": "/api/v1/users"
    },
    {
      "event_id": "349393",
      "occurred_at": "2026-10-31 14:33:08",
      "event_type": "page_view",
      "user_id": 1935,
      "path": "/billing"
    },
    {
      "event_id": "246017",
      "occurred_at": "2026-10-31 14:29:26",
      "event_type": "api_call",
      "user_id": 1879,
      "path": "/api/v1/users"
    },
    {
      "event_id": "185406",
      "occurred_at": "2026-10-31 14:27:51",
      "event_type": "api_call",
      "user_id": 1978,
      "path": "/api/v1/users"
    },
    {
      "event_id": "797027",
      "occurred_at": "2026-10-31 14:17:54",
      "event_type": "api_call",
      "user_id": 1909,
      "path": "/api/v1/query"
    },
    {
      "event_id": "749079",
      "occurred_at": "2026-10-31 14:01:44",
      "event_type": "page_view",
      "user_id": 1838,
      "path": "/alerts"
    },
    {
      "event_id": "472087",
      "occurred_at": "2026-10-31 13:58:14",
      "event_type": "api_call",
      "user_id": 1949,
      "path": "/api/v1/users"
    },
    {
      "event_id": "734706",
      "occurred_at": "2026-10-31 13:56:21",
      "event_type": "api_call",
      "user_id": 1905,
      "path": "/api/v1/events"
    },
    {
      "event_id": "949525",
      "occurred_at": "2026-10-31 13:55:40",
      "event_type": "api_call",
      "user_id": 1831,
      "path": "/api/v1/events"
    },
    {
      "event_id": "575126",
      "occurred_at": "2026-10-31 13:55:10",
      "event_type": "export",
      "user_id": 1903,
      "path": "/export"
    },
    {
      "event_id": "388478",
      "occurred_at": "2026-10-31 13:50:10",
      "event_type": "login",
      "user_id": 2042,
      "path": "/login"
    },
    {
      "event_id": "433315",
      "occurred_at": "2026-10-31 13:47:07",
      "event_type": "api_call",
      "user_id": 2028,
      "path": "/api/v1/query"
    },
    {
      "event_id": "387451",
      "occurred_at": "2026-10-31 13:47:06",
      "event_type": "page_view",
      "user_id": 1903,
      "path": "/explore"
    },
    {
      "event_id": "228165",
      "occurred_at": "2026-10-31 13:23:33",
      "event_type": "api_call",
      "user_id": 1828,
      "path": "/api/v1/users"
    },
    {
      "event_id": "249186",
      "occurred_at": "2026-10-31 13:18:10",
      "event_type": "page_view",
      "user_id": 1957,
      "path": "/settings"
    },
    {
      "event_id": "767466",
      "occurred_at": "2026-10-31 13:16:42",
      "event_type": "page_view",
      "user_id": 2032,
      "path": "/alerts"
    },
    {
      "event_id": "81308",
      "occurred_at": "2026-10-31 13:15:41",
      "event_type": "page_view",
      "user_id": 1933,
      "path": "/settings"
    },
    {
      "event_id": "116230",
      "occurred_at": "2026-10-31 13:11:01",
      "event_type": "page_view",
      "user_id": 1905,
      "path": "/billing"
    },
    {
      "event_id": "284524",
      "occurred_at": "2026-10-31 13:09:46",
      "event_type": "page_view",
      "user_id": 1905,
      "path": "/alerts"
    },
    {
      "event_id": "66466",
      "occurred_at": "2026-10-31 13:05:00",
      "event_type": "page_view",
      "user_id": 1970,
      "path": "/explore"
    },
    {
      "event_id": "665309",
      "occurred_at": "2026-10-31 12:58:25",
      "event_type": "page_view",
      "user_id": 1848,
      "path": "/explore"
    },
    {
      "event_id": "815771",
      "occurred_at": "2026-10-31 12:55:18",
      "event_type": "query_run",
      "user_id": 1831,
      "path": "/explore"
    },
    {
      "event_id": "507931",
      "occurred_at": "2026-10-31 12:53:29",
      "event_type": "query_run",
      "user_id": 1933,
      "path": "/explore"
    },
    {
      "event_id": "500073",
      "occurred_at": "2026-10-31 12:52:56",
      "event_type": "page_view",
      "user_id": 1842,
      "path": "/billing"
    },
    {
      "event_id": "590537",
      "occurred_at": "2026-10-31 12:42:29",
      "event_type": "page_view",
      "user_id": 1841,
      "path": "/dashboard"
    },
    {
      "event_id": "602011",
      "occurred_at": "2026-10-31 12:23:35",
      "event_type": "error",
      "user_id": 2033,
      "path": "/alerts"
    },
    {
      "event_id": "861576",
      "occurred_at": "2026-10-31 12:17:18",
      "event_type": "page_view",
      "user_id": 2028,
      "path": "/settings"
    },
    {
      "event_id": "508131",
      "occurred_at": "2026-10-31 12:14:23",
      "event_type": "api_call",
      "user_id": 2033,
      "path": "/api/v1/users"
    }
  ],
  "nextCursor": "eyJvY2N1cnJlZEF0IjoiMjAyNi0xMC0zMSAxMjoxNDoyMyIsImV2ZW50SWQiOiI1MDgxMzEifQ"
}
```

## Related

- [Multi-tenant embedded analytics on ClickHouse with signed tokens](https://hypequery.com/examples/multi-tenant-embedded-analytics): Embed tenant-scoped ClickHouse analytics in your product: your server mints a short-lived token, hypequery enforces the tenant on every query.
- [A FastAPI analytics API on ClickHouse with tenant-scoped datasets](https://hypequery.com/examples/fastapi-analytics-api): Serve governed ClickHouse analytics from FastAPI: datasets defined once in Python, query endpoints mounted beside your routes, tenant fixed by the token.
- [All recipes](https://hypequery.com/recipes)
- [/clickhouse-query-builder](https://hypequery.com/clickhouse-query-builder)
- [/clickhouse-api](https://hypequery.com/clickhouse-api)
- [/clickhouse-typescript](https://hypequery.com/clickhouse-typescript)
