Pagination, chunking and cursors
Working with big tables
Pagination
let page = User::query().order_by("id").paginate(&db, 2, 15).await?; // page 2, 15 per page
&page.items; // Collection<User>
&page.total; // rows across all pages
&page.page; // 2
&page.per_page; // 15
page.last_page(); // 1 when there are no rows
page.has_more_pages();Runs a COUNT(*) and a page query. QueryBuilder::paginate works the same and returns Page<Row>. Order the query, or pages are not stable.
simple_paginate (Laravel’s simplePaginate) skips the count: it fetches one row more than the page to tell whether another page follows.
let page = User::query().order_by("id").simple_paginate(&db, 2, 15).await?; // SimplePage<User>
&page.items;
page.has_more_pages(); // or `page.has_more`; there is no totalCursor pagination
Offset pages get slower the deeper they go. cursor_paginate (Laravel’s cursorPaginate) filters on the ordering columns instead, so every page is one indexed range query. There is no total and no page number, only next and previous cursors:
let page = User::query()
.where_("active", "=", true)
.order_by_desc("id")
.cursor_paginate(&db, 15, cursor_from_request.as_deref()) // None for the first page
.await?;
&page.items; // Collection<User>
&page.next_cursor; // Option<String>; None on the last page
&page.prev_cursor; // Option<String>; None on the first page
page.has_more_pages();- Cursors are opaque strings, safe in a URL; their values are always sent as bindings.
- The ordering columns must be unique together and never NULL. Models append the primary key when it is missing; without ordering they page by key.
QueryBuilder::cursor_paginatereturnsCursorPage<Row>; there the ordering is required and its columns must be selected.order_by_rawcannot be paged on.
Chunking
Processes a big table in batches, keyed on the primary key (Laravel’s chunkById):
let mut chunks = User::query().where_("active", "=", true).chunk_by_id(500);
while let Some(users) = chunks.next(&db).await? {
for user in users.iter() { /* ... */ }
}- Each batch is a fresh query
WHERE id > last_id ORDER BY id LIMIT 500, so it is safe to update or delete rows as you go. - Your ordering is replaced by key order.
ORs in your conditions are grouped. - Composite keys work (
a > ? OR (a = ? AND b > ?)). with(...)works: each chunk is eager-loaded.QueryBuilder::chunk_by_id(500, "id")takes the column explicitly.
chunk(500) (Laravel’s chunk) pages with LIMIT / OFFSET in your own order instead. Rows that are inserted, deleted or changed while chunking shift the pages, so prefer chunk_by_id when you modify the rows you walk. Models without ordering are ordered by key; QueryBuilder::chunk needs an order_by and fails without one. Laravel’s lazy() / lazyById() are cursor and chunk_by_id here.
Cursors
Streams rows one at a time without loading them all:
let mut cursor = User::query().where_("active", "=", true).cursor(&db)?;
while let Some(user) = cursor.next().await? {
/* ... */
}- A background task reads up to 100 rows ahead on its own connection (or on the transaction’s, which the cursor then borrows).
cursor_buffered(&db, n)sets another read-ahead: more for fewer round trips, fewer to hold less in memory. On Oracle, the rows per round trip come fromDbBuilder::fetch_array_size(default 100). - On a transaction, the cursor holds the connection: until it returns
Noneor is dropped, any other statement on that transaction fails withError::InvalidQuery(waiting would deadlock). Collect the rows first, or write throughdb/ another transaction, if you need to write while reading. - Dropping the cursor stops it.
- Model cursors refuse
with(...); usechunk_by_idwhen you need relations. QueryBuilder::cursor(&db)?streamsRows.