Autumn ships two complementary pagination flavours, both available out of the box in every #[repository]-backed resource and in scaffold-generated index views:

FlavourQuery paramsBest for
Offset (PageRequest / Page)?page=N&size=MBrowse-style UIs, admin tables
Cursor (CursorRequest / CursorPage)?cursor=<token>&size=MFeeds, large tables, replicas

Offset pagination

How it works

Offset pagination uses a LIMIT / OFFSET SQL pair. The client picks a 1-based page number (?page=2) and a page size (?size=25). The server executes two queries — COUNT(*) and the page slice — and returns a Page<T> response that bundles the content together with total-pages metadata.

In a #[repository] trait

Every #[repository]-generated struct gets a page method automatically:

Rust
// Defined in your repository trait (generated):
async fn page(&self, req: &PageRequest) -> AutumnResult<Page<Post>>;

Call it from any handler:

Rust
use autumn_web::pagination::{Page, PageRequest};
use crate::repositories::post::PgPostRepository;

#[get("/posts")]
async fn index(page: PageRequest, repo: PgPostRepository) -> AutumnResult<Json<Page<Post>>> {
    Ok(Json(repo.page(&page).await?))
}

In a scaffold-generated index view

autumn generate scaffold Post title:String body:Text emits an index action that uses the PageRequest extractor to call repo.page(). Out-of-range or missing values are clamped silently — consistent with the framework rule that list endpoints never return HTTP 400 for pagination parameters:

Code
GET /posts          → page 1, 20 items  (DEFAULT_PAGE_SIZE)
GET /posts?page=3   → page 3, 20 items
GET /posts?size=10  → page 1, 10 items
GET /posts?size=200 → page 1, 100 items (clamped to MAX_PAGE_SIZE)
GET /posts?size=abc → page 1, 20 items  (unparseable → default)

A Maud pagination_nav helper renders Previous / Next links with hx-get attributes for htmx-friendly partial updates — see Rendering the pager below.

Overriding page size

PageRequest uses DEFAULT_PAGE_SIZE = 20 and MAX_PAGE_SIZE = 100. Both are public constants you can reference in your own code:

Rust
use autumn_web::pagination::{DEFAULT_PAGE_SIZE, MAX_PAGE_SIZE, PageRequest};

let req = PageRequest::new(1, 50); // page 1, 50 items

Values outside the valid range are clamped silently — PageRequest never returns HTTP 400 on its own.

Response shape

Json
{
  "content": [ ... ],
  "page": 1,
  "size": 25,
  "total_elements": 137,
  "total_pages": 6,
  "has_next": true,
  "has_previous": false
}

Cursor pagination

When to use it

Cursor (keyset) pagination is O(1) regardless of page depth and produces zero duplicate or skipped rows under concurrent inserts — making it the correct choice for:

  • Real-time feeds and notification inboxes
  • Admin-safe full-table iteration (exports, data migrations)
  • Apps running on multiple read replicas where OFFSET can diverge

Declaring a cursor key

Add cursor_key = field to the #[repository] attribute to generate the cursor_page method. The field is used as the primary sort column (descending) with id as the tie-breaker:

Rust
#[autumn_web::repository(Post, cursor_key = created_at)]
pub trait PostRepository {}

This generates a cursor_page method that orders by (created_at DESC, id DESC) and uses id as the sole cursor payload — correct whenever created_at values are monotonically correlated with id (the typical case for auto-increment PKs).

For universally correct keyset pagination (e.g. backfilled or imported rows where timestamps and ids may diverge), also supply cursor_key_type:

Rust
#[autumn_web::repository(Post, cursor_key = created_at, cursor_key_type = chrono::NaiveDateTime)]
pub trait PostRepository {}

With cursor_key_type the cursor encodes both (NaiveDateTime, i64) and the WHERE clause becomes the full two-part predicate:

Sql
WHERE (created_at < $after_k) OR (created_at = $after_k AND id < $after_id)

Constraint: cursor_key must be declared on a non-nullable column. In SQL, comparisons involving NULL (<, =) evaluate to UNKNOWN, so a nullable sort key silently drops rows from all keyset pagination queries. Make the column NOT NULL or implement cursor_page manually.

Calling cursor_page

Rust
use autumn_web::pagination::{CursorPage, CursorRequest};
use crate::repositories::post::PgPostRepository;

#[get("/feed")]
async fn feed(cursor: CursorRequest, repo: PgPostRepository) -> AutumnResult<Json<CursorPage<Post>>> {
    Ok(Json(repo.cursor_page(&cursor).await?))
}

The first request omits ?cursor; subsequent requests pass the next_cursor token returned by the previous response.

Cursor token format

Cursor tokens are base64url-encoded JSON, URL-safe without percent-encoding, and opaque to clients. Forging a token is equivalent to seeking to an arbitrary offset — for sort-key-only cursors (timestamps + ids) this is not a security concern. If your cursor encodes sensitive data (tenant ids, access scopes) use the signed cursor API:

Rust
// Encoding
let token = Cursor::encode_signed(&my_value, signing_key);

// Decoding in a handler
let value = cursor_req.decode_signed::<MyValue>(signing_key);

See the pagination module docs for the full signing API.

Response shape

Json
{
  "content": [ ... ],
  "size": 20,
  "next_cursor": "eyJpZCI6MTIzfQ",
  "has_next": true
}

next_cursor is null on the final page.


Batched iteration (server-side, bounded memory)

Cursor pagination shapes an HTTP response. When you instead need to touch every row of a table from inside a #[autumn_web::task], a scheduled sweep, or a job — a backfill, a counter-cache recompute, a re-encryption pass — reach for batched iteration instead of find_all(). find_all() materializes the whole table into one Vec (a 1 M-row table is an instant OOM); the batched iterators walk the table in bounded-size chunks with flat memory.

Every #[repository] generates two methods:

  • find_in_batches(batch_size) — yields successive Vec<Model> chunks of at most batch_size rows.
  • find_each(batch_size) — yields one Model at a time, still fetching under the hood in batch_size-sized batches.

Iteration is keyset-based (primary-key ascending: WHERE id > last ORDER BY id ASC LIMIT batch_size), not LIMIT/OFFSET, so deep iteration never degrades and is stable under concurrent inserts. At most one batch_size chunk of models is resident at a time — memory is O(batch_size), never O(table). Unlike a cursor_page request, batch_size is not clamped to MAX_PAGE_SIZE (100); pass whatever fits your memory budget.

Batched iteration inherits the repository's soft-delete filter (trashed rows are skipped, matching find_all) and its read routing (a replica-routed repo iterates off the replica, a primary_reads repo off the primary), so backfills read off a replica with no extra ceremony. An error mid-iteration surfaces as an AutumnResult error on the failing batch, and errors are retryable: the keyset cursor only advances on success, so calling next_batch() again retries the same batch with no duplicated or skipped rows — Ok(None) always means the table is exhausted, never a swallowed failure.

Iteration ends at the first short batch (fewer than batch_size rows); rows inserted after that point are not seen by the current handle — start a new iteration to pick them up (matching Rails' find_each).

find_each in a task — per-row update

Rust
#[autumn_web::task(name = "backfill-slugs")]
pub async fn backfill_slugs(repo: PgPostRepository) -> AutumnResult<()> {
    let mut each = repo.find_each(500);
    while let Some(post) = each.next().await? {
        let slug = slugify(&post.title);
        repo.update(
            post.id,
            &UpdatePost {
                slug: Patch::Set(slug),
                ..Default::default()
            },
        )
        .await?;
    }
    Ok(())
}

Register it with .one_off_tasks(one_off_tasks![tasks::backfill_slugs]) and run autumn task backfill-slugs (see the tasks guide).

find_in_batches + upsert_many — recompute loop

Pair batched reads with the bulk writes from upsert_many to recompute a whole table with flat memory on both the read and write side (upsert_many takes existing &[Model] values and writes them back; save_many takes &[NewModel] and would insert duplicates here):

Rust
let mut batches = repo.find_in_batches(1_000);
while let Some(chunk) = batches.next_batch().await? {
    let recomputed: Vec<Account> = chunk
        .into_iter()
        .map(|mut account| {
            account.balance = recompute_balance(&account);
            account
        })
        .collect();
    repo.upsert_many(&recomputed).await?; // O(batch_size) memory, not O(table)
}

Note: upsert_many is not generated on repositories with hooks configured — use per-row update there.

A batch_size of 0 returns an error rather than spinning. On a sharded repository, cross-shard across_tenants() iteration is rejected (mirroring cursor_page); iterate each shard separately via from_shard(...) instead.


Offset vs cursor: decision guide

QuestionUse offsetUse cursor
Do users navigate to a specific page number?
Is the list mostly stable (rarely updated)?
Is the list a feed with concurrent inserts?
Is the table > 1 M rows?
Do you run read replicas?
Do you need infinite scroll / exports?

htmx wiring

Scaffold-generated pagination links carry hx-get and hx-target="body" attributes so htmx replaces the full page body on click — no additional JS needed. For partial updates (replacing only the list, not the full layout), change hx-target to the id of your list container and set hx-swap="innerHTML".

Example fragment from a generated index.html:

Html
<a href="/posts?page=2&size=25"
   hx-get="/posts?page=2&size=25"
   hx-target="body">
  Next →
</a>

Rendering the pager

You don't have to hand-roll that markup. Autumn ships a reusable Maud renderer, pagination_nav, that turns a Page into an accessible, filter-preserving, htmx-ready pager in one line. It is re-exported from the prelude alongside the other view widgets, so use autumn_web::prelude::*; brings it into scope.

Rust
use autumn_web::prelude::*; // pagination_nav, PagerOptions, Page, …

#[get("/posts")]
async fn index(page_req: PageRequest, mut db: Db) -> AutumnResult<Markup> {
    let total: i64 = posts::table.count().get_result(&mut db).await?;
    let items: Vec<Post> = posts::table
        .limit(page_req.limit()).offset(page_req.offset())
        .select(Post::as_select())
        .load(&mut db).await?;
    let page = Page::new(items, total, &page_req);

    Ok(html! {
        ul { @for post in &page.content { li { (post.title) } } }
        // One line: an accessible, windowed pager below the list.
        (pagination_nav(&page, &PagerOptions::new("/posts")))
    })
}

The renderer emits a <nav aria-label="Pagination"> containing previous/next affordances and a windowed page-number sequence with first/last anchors and ellipses (1 … 4 5 6 … 20). The active page carries aria-current="page", and disabled prev/next render as non-focusable aria-disabled spans.

Preserving filters and sort

Pass the current request's query string to PagerOptions::query and the pager keeps active filters, sort, and search on every link — swapping only the page param:

Rust
// With ?q=foo&sort=name in the URL, every page link keeps q=foo&sort=name.
let opts = PagerOptions::new("/posts").query("q=foo&sort=name");
(pagination_nav(&page, &opts))

htmx (opt-in)

By default the links are plain <a href> — pagination works with zero JavaScript. Opt into htmx partial swaps with PagerOptions::hx_target:

Rust
let opts = PagerOptions::new("/posts")
    .hx_target("#post-list") // adds hx-get + hx-target to every link
    .hx_push_url();          // and updates the address bar

Cursor feeds

For cursor pagination, cursor_pagination_nav renders prev/next affordances from a CursorPage (there are no page numbers, since a cursor feed has no total). The next link is built from next_cursor; supply PagerOptions::prev_cursor for a back-link.

Rust
let opts = PagerOptions::new("/feed");
(cursor_pagination_nav(&cursor_page, &opts))

Further reading