---
title: How to Implement Pagination in Rust APIs
summary: Use the Query extractor with a Deserialize struct to parse page and per_page parameters from the URL in Axum.
lang: en
slug: how-to-implement-pagination-in-rust-apis
published: 2026-04-17T12:05:42+00:00
updated: 2026-04-25T22:00:13+00:00
canonical: https://www.rustfaq.org/en/how-to-implement-pagination-in-rust-apis/
---

# How to Implement Pagination in Rust APIs



## The list that breaks your server

You built an endpoint that returns a list of users. It works great when you have five users. Then the database grows to fifty thousand. The client sends a request, the server loads every single row into memory, serializes the whole lot, and ships a megabyte of JSON across the wire. The browser chokes. The user stares at a spinning wheel. Your server's memory usage spikes and the garbage collector starts thrashing. You need to break that list into chunks.

Pagination splits a large dataset into smaller, manageable pages. The client asks for a specific page number and a page size. The server calculates where to start reading in the database and how many rows to grab. This keeps memory usage low and response times fast. The two main parameters are `page` (which chunk you want) and `per_page` (how many items per chunk).

Think of pagination like a library catalog. You don't get every book in the building at once. You ask for "page 3 of the mystery novels," and the librarian hands you exactly that shelf. The rest of the books stay on the shelves until someone asks for them.

## The minimal setup

Axum makes parsing query parameters straightforward with the `Query` extractor. You define a struct that matches the expected parameters, derive `serde::Deserialize`, and add `Query<YourStruct>` to your handler arguments. Axum handles the parsing and validation automatically.

```rust
use axum::{extract::Query, routing::get, Router};
use serde::Deserialize;

/// Configuration for pagination query parameters.
#[derive(Deserialize)]
struct Pagination {
    /// The page number requested by the client.
    page: usize,
    /// Number of items to return per page.
    per_page: usize,
}

/// Handles requests to list items with pagination.
async fn list_items(pagination: Query<Pagination>) {
    // Query wraps the deserialized struct in a tuple-like wrapper.
    let page = pagination.0.page;
    let per_page = pagination.0.per_page;

    // Calculate the offset for the database query.
    // Page 1 starts at offset 0. Page 2 starts at offset per_page.
    let offset = (page - 1) * per_page;

    // Use offset and per_page to fetch data from the database.
    // Example SQL: SELECT * FROM items LIMIT ? OFFSET ?
}

fn main() {
    // Set up the router with the paginated endpoint.
    let app = Router::new().route("/items", get(list_items));
    // axum::serve(listener, app).await.unwrap();
}
```

The `Query` extractor pulls the query string from the URL, such as `?page=2&per_page=10`, and hands it to Serde. Serde tries to fill the `Pagination` struct. If the client sends `?page=abc`, Serde fails to parse the string as a `usize`, and Axum automatically returns a 400 Bad Request response. You get type safety and basic validation for free.

Convention aside: Always use a struct with `Query` instead of `Query<HashMap<String, String>>`. The struct approach catches typos at compile time and documents your API surface. A hash map hides errors until runtime and makes the code harder to read.

## How the flow works

The client sends `GET /items?page=2&per_page=5`. Axum intercepts the request and looks for the `Query<Pagination>` argument. It extracts the query string and passes it to Serde. Serde deserializes the parameters into `Pagination { page: 2, per_page: 5 }`. Axum wraps this in `Query(Pagination { ... })` and passes it to the handler.

Inside the handler, you access the fields via `.0`. You calculate the database offset. The formula is `(page - 1) * per_page`. For page 2 with 5 items, the math is `(2 - 1) * 5 = 5`. This tells the database to skip the first 5 rows and return the next 5. You pass `LIMIT 5 OFFSET 5` to your database driver. The database executes the query, returns exactly five rows, and you serialize them into JSON.

The response goes back to the client. The client renders the five items and shows a "Next Page" button that links to `?page=3&per_page=5`.

## Building a realistic handler

Real-world APIs need more than basic parsing. Clients might omit parameters. Clients might send `page=0`. Clients might request `per_page=1000000` and crash your server. You need defaults, validation, and a structured response.

```rust
use axum::{
    extract::Query,
    http::StatusCode,
    response::Json,
    routing::get,
    Router,
};
use serde::{Deserialize, Serialize};

/// Query parameters with validation defaults.
#[derive(Deserialize)]
struct Pagination {
    /// Defaults to 1 if the client omits the parameter.
    #[serde(default = "default_page")]
    page: usize,
    /// Defaults to 20 if the client omits the parameter.
    #[serde(default = "default_per_page")]
    per_page: usize,
}

fn default_page() -> usize { 1 }
fn default_per_page() -> usize { 20 }

/// Response envelope containing items and pagination metadata.
#[derive(Serialize)]
struct PaginatedResponse<T> {
    items: Vec<T>,
    page: usize,
    per_page: usize,
    total_items: usize,
}

/// Validates pagination constraints and returns a result.
fn validate_pagination(p: &Pagination) -> Result<(), StatusCode> {
    // Page must be at least 1.
    if p.page == 0 {
        return Err(StatusCode::BAD_REQUEST);
    }
    // Per page must be reasonable. Cap at 100 to prevent abuse.
    if p.per_page == 0 || p.per_page > 100 {
        return Err(StatusCode::BAD_REQUEST);
    }
    Ok(())
}

/// Handler with validation and response construction.
async fn list_items(pagination: Query<Pagination>) -> Result<Json<PaginatedResponse<String>>, StatusCode> {
    // Validate business rules before doing any math.
    validate_pagination(&pagination.0)?;

    let page = pagination.0.page;
    let per_page = pagination.0.per_page;
    let offset = (page - 1) * per_page;

    // Simulate database fetch.
    // In real code, pass offset and per_page to your database query.
    let items = vec!["item".to_string(); per_page];
    let total_items = 1000;

    Ok(Json(PaginatedResponse {
        items,
        page,
        per_page,
        total_items,
    }))
}

fn main() {
    let app = Router::new().route("/items", get(list_items));
    // axum::serve(listener, app).await.unwrap();
}
```

The `#[serde(default = "...")]` attributes prevent 400 errors when clients omit parameters. The `validate_pagination` function enforces business rules. `page` must be greater than zero. `per_page` must be capped. A cap is essential. Without it, a malicious client can request `per_page=1000000` and force your server to allocate massive memory blocks or return huge payloads. The cap is a safety rail.

Convention aside: The community often caps `per_page` between 50 and 100. Some APIs expose a `max_per_page` in the response metadata so clients know the limit. This is helpful for UI development.

The response includes `total_items`. This allows the client to calculate the total number of pages and render a proper pagination bar. The formula for total pages is `(total_items + per_page - 1) / per_page`. This integer division trick rounds up without floating point math.

Validate inputs before math. An underflow on page zero crashes your handler and returns a 500 error to the user.

## Pitfalls and compiler errors

Forgetting `#[derive(Deserialize)]` on your struct triggers `E0277` (trait bound not satisfied) at the call site of `Query`. The compiler tells you the type does not implement `Deserialize`. Add the derive macro to fix this.

If you calculate `offset = (page - 1) * per_page` and the client sends `page=0`, the subtraction underflows. Rust panics in debug mode. In release mode, it wraps around to a huge number, and the database returns garbage or errors. Always validate `page > 0` before doing math.

If you forget to return a `Result` from your handler when using `?` for error propagation, you get `E0277` again because the handler doesn't implement `IntoResponse`. Axum handlers can return `Result<T, E>` where `E` implements `IntoResponse`. `StatusCode` implements this trait, so returning `Result<Json<...>, StatusCode>` works perfectly.

Convention aside: Use `Result` return types in handlers for clean error handling. The `?` operator propagates errors automatically, and Axum converts them to HTTP responses. This keeps your handler logic focused on success paths.

## The deep page problem

Offset pagination has a hidden cost. As the offset grows, performance degrades. When you request `LIMIT 10 OFFSET 1000000`, the database must scan 1,000,010 rows, discard the first 1,000,000, and return the last 10. This is slow. The database cannot skip rows efficiently with offset. It has to read them to know how many to skip.

This is called the deep page problem. It happens when users navigate to page 1000 or higher. The query takes seconds instead of milliseconds. Your API becomes unusable for large datasets.

The fix is to limit how deep users can go. You can cap the maximum page number. Or you can switch to a different pagination strategy. Cursor-based pagination avoids this problem entirely. Instead of an offset, the client sends a cursor token representing the last seen item. The server queries `WHERE id > last_id LIMIT 10`. The database uses an index to jump directly to the next batch. This is fast regardless of how deep you go.

Offset pagination is easy to implement but slow on deep pages. Switch to cursor or keyset pagination when your database starts groaning.

## Decision matrix

Use offset-based pagination for simple lists where the dataset does not change often and the client needs to jump to arbitrary pages. This is the standard `page` and `per_page` approach. It works well for admin panels and small datasets.

Use cursor-based pagination for infinite scroll interfaces or rapidly changing datasets. The client sends a cursor token representing the last seen item, and the server returns the next batch. This avoids skipping items when new rows are inserted. It is also fast on large datasets because it uses index seeks instead of offset scans.

Use keyset pagination when performance matters on large datasets and you want to avoid the overhead of encoding cursors. You filter by a sorted column and a value greater than the last seen ID. This is faster than offset because the database can use an index efficiently without scanning skipped rows. It is essentially cursor pagination without the token encoding.

Use `total_items` in the response when the client needs to render a full pagination bar with page numbers. Use `has_next` or `next_cursor` when the client only needs "Next" and "Previous" buttons. Returning `total_items` requires a separate count query, which can be expensive. Choose the response shape based on client needs.

## Where to go next

- [How to implement health check endpoint in Rust](/en/how-to-implement-health-check-endpoint-in-rust/)
- [How to Implement Authentication in Rust (JWT, Sessions)](/en/how-to-implement-authentication-in-rust-jwt-sessions/)
- [How to Use WASI (WebAssembly System Interface) with Rust](/en/how-to-use-wasi-webassembly-system-interface-with-rust/)