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.
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.
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.