Deciding on Cursor-Based Pagination in SpiceDB: Options, Trade-offs, and Implementation
A decision guide for SpiceDB cursor pagination: compare bounded queries, cursor iteration, and schema tuning, then implement a validated client loop.
24 Jul 2026, 16:55 UTC

The problem: walking large relationship sets
When a SpiceDB query matches more relationship tuples than a single API response can return, you need a reliable way to traverse the entire set. A tuple is one row in the permission graph: a resource, a relation, and a subject. SpiceDB does not expose SQL-style OFFSET pagination because its query engine evaluates Datalog rules rather than scanning a table with a numeric offset. Instead, the API provides cursor-based pagination: you send a cursor value in the request, and SpiceDB returns a next_cursor token in the response. The token is opaque, meaning your client should treat it as a string and never parse or modify it.
The useful takeaway: if your result set may exceed one page, implement a client loop that advances by next_cursor, and ensure your schema does not rely on deep relation nests that make each page expensive to compute.
Decision constraints and supported options
You are choosing how to retrieve a complete result set under three constraints:
- Completeness: every matching tuple must be visited exactly once.
- Statefulness: the client must remember the cursor between requests.
- Latency budget: each page must return quickly enough for your use case.
Given those constraints, three approaches are practical:
| Approach | Best for | Trade-off |
|---|---|---|
| Single bounded query | Result sets known to fit within the response limit | Simplest code, but returns a partial view if the set grows |
| Cursor iteration | Large or unbounded relationship sets | Requires a loop and cursor persistence; losing the token forces a restart |
| Schema flattening + indexing | Schemas with deep nesting or many join paths | Requires schema migration, but lowers per-page evaluation cost |
Trade-offs
A single bounded query is attractive because it avoids loop state, but it is only safe when you can prove the result set will never exceed the limit. If a subject gains access to thousands of resources overnight, the same code that worked yesterday silently truncates today.
Cursor iteration solves completeness at the cost of client complexity. Because the cursor is opaque, you cannot resume from an arbitrary offset or inspect how many results remain. If your process crashes and you did not persist the last next_cursor, you must re-query from the beginning. On large datasets that re-query is costly.
Schema optimization is an upstream decision. SpiceDB evaluates relationships using Datalog with incremental cursor advancement, but deep relation nests or many join paths can cause query times to grow sharply. Indexing relation tuples and keeping join depth moderate improves latency before pagination even begins. If each page takes seconds to compute, no amount of client-side looping will make the traversal feel fast.
Implementation pattern
The following loop is transport-agnostic and applies to both gRPC and HTTP/JSON endpoints. Run it from an application or script that holds SpiceDB API credentials. Replace <filter> and <limit> with your values. The example is illustrative; adapt the exact method names to your client library.
# Illustrative client loop for SpiceDB cursor pagination.
cursor = ""
processed = 0
while True:
# Fetch one page using the current cursor.
page = spicedb_client.read_relationships(
relationship_filter="<filter>",
limit=100,
cursor=cursor,
consistency="fully_consistent",
)
for relationship in page.relationships:
handle(relationship)
processed += 1
# An empty next_cursor means the result set is exhausted.
if not page.next_cursor:
break
# Advance the cursor for the next request.
cursor = page.next_cursor
# processed now reflects the total tuples visited.
For an HTTP/JSON call, include "cursor" in the request body and read next_cursor from the JSON response. The semantics are identical to gRPC.
Validation and verification
Because cursor tokens are opaque, correctness depends on your client passing them through unchanged. Use these checks:
- Cursor continuity: Log the
next_cursorfrom each response and assert that it matches thecursorfield in the next request. A mismatch means the client mutated or dropped the token. - Fixture-based completeness: Run the SpiceDB pagination test fixtures, or an equivalent local test, and confirm that iterating through pages yields the expected set of tuples with no gaps or duplicates.
- Count reconciliation: Query a relationship set you can independently count. Page through the results with a small limit, tally the tuples, and compare the total against the known count.
Limitations and version assumptions
- Opaque tokens: Cursors are not portable across schema changes or major SpiceDB versions. Treat them as short-lived session state.
- Deep nesting penalty: Pagination chunks delivery; it does not reduce Datalog evaluation cost. If your schema has deep nests, expect latency to remain high even with correct pagination.
- Cursor size: Very large cursors add per-request overhead. If cursors grow unwieldy, revisit schema depth and tuple indexing.
- Verification requirement: SpiceDB evolves. The field names and transport details above reflect well-established behavior, but you should confirm them against the documentation for the specific version you deploy.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.