Pagination
One paginator ships in the box: keyset, a cursor over the primary key.
It is O(1) on the index, stable under concurrent inserts, and pairs
naturally with UUID-v7 keys. Every #[crud]-generated list is
keyset-paginated unless you opt out.
SeaORM’s Cursor API drives it.
Pagination is the default
Section titled “Pagination is the default”A #[crud] list — REST or GraphQL — is keyset-paginated out of the box:
#[crud( service = svc, entity = PostEntity, output = Post, create = CreatePost, update = UpdatePost,)]impl PostsController {}No paginate knob needed — paginate = cursor is the default. The one
other value is paginate = none, which opts out into the full
(ability-scoped) collection in one response. Even then the backstop holds:
CrudService::list never returns more than LIST_CAP (1 000) rows, and
logs a warn when it truncates. Reserve none for small, finite
collections.
Keyset, the REST shape
Section titled “Keyset, the REST shape”The generated GET /posts returns a plain Vec<Post> body plus an
x-next-cursor response header when more rows remain. The body stays a
flat array so response masking works unchanged.
curl -s '/posts?first=20'# [ { "id": "...", "title": "..." }, ... ]# x-next-cursor: 0193f1b2-...
curl -s '/posts?first=20&after=0193f1b2-...'# next pageThe query carries first (page size, defaulting to 20, clamped to
1..=100) and after (the cursor returned by the previous response). An
unparseable cursor pages from the start — never an error.
Keyset, the GraphQL shape
Section titled “Keyset, the GraphQL shape”The generated list query takes the same two cursor arguments and returns a plain list — the body stays maskable, exactly like REST:
query { posts(first: 20) { id title }}# next page: posts(first: 20, after: "<last id of the previous page>")UUID-v7 keys are time-ordered, so the cursor is just the previous
page’s last id — no opaque cursor type. An empty page means you reached
the end.
What Page<M> carries
Section titled “What Page<M> carries”The keyset shape, before serialization:
pub struct Page<M> { pub items: Vec<M>, pub next_cursor: Option<Uuid>, pub has_more: bool,}The implementation fetches limit + 1 rows, truncates the probe row from
items, and uses its presence to set has_more and next_cursor. The
cursor is the last visible row’s primary key — present only when there is
more to fetch.
CrudService::page(first, after) returns a Page<E::Model> directly;
callers paginating a custom query reach for Repo::<E>::page(first, after).
A custom paginated query
Section titled “A custom paginated query”When the auto-emitted page does not match — say you want only one
author’s posts — call Repo::scoped and the same cursor helpers:
impl PostsService { pub async fn page_by_author( &self, author_id: Uuid, first: u64, after: Option<Uuid>, ) -> Result<Page<Post>, ServiceError> { let conn = Repo::<Posts>::conn()?; let limit = nest_rs_seaorm::page::clamp_page_size(first);
let mut cursor = Repo::<Posts>::scoped(Action::Read) .filter(entity::Column::AuthorId.eq(author_id)) .cursor_by(entity::Column::Id); if let Some(after) = after { cursor.after(after); } cursor.first(limit + 1);
let rows = cursor.all(&conn).await?; let (rows, has_more) = nest_rs_seaorm::page::split_overfetched(rows, limit); let next_cursor = has_more.then(|| rows.last()?.id);
Ok(Page { items: rows.iter().map(Post::from).collect(), next_cursor, has_more, }) }}Repo::scoped(Action::Read) is the entry point — the ability filter
applies, and a request without one denies every row (fail-closed).
Limits
Section titled “Limits”Keyset buys stability under concurrent inserts and O(1) reads on the PK index by giving up two things: no total count, and no jump-to-page.
There is no offset paginator in the framework, and no paginate = page
mode — one pagination story, one wire shape per surface. A consumer that
genuinely needs page numbers and a total (typically an admin table) writes
that operation by hand: a Repo::scoped(Action::Read) query with
.offset()/.limit() plus a .count(), returning a shape you own —
accepting that OFFSET n makes the database walk and discard n rows, so
past a few thousand it is a scan.
Going further
Section titled “Going further”- Database — the entity behind the generated list.
- CRUD — the
paginateknob on#[crud(...)]. - Repo and executor —
Repo::scopedandRepo::connbehind the cursor query. - GraphQL — the list query on a resolver.
Built by YV17labs