Pagination
Table of Contents
Why Pagination Matters
When an API returns a list of items, the number of results can grow very large over time. Imagine:
- A blog with 1,000,000 posts
- An e commerce site with 500,000 products
- A log service with millions of events per day
If the API tried to return all items in a single response:
- The database would need to scan and send huge result sets.
- The backend would use a lot of memory and CPU.
- The network would be slow and expensive.
- The client (browser or mobile app) would freeze or crash.
Pagination solves this by splitting large result sets into smaller pieces called pages, and letting the client request one page at a time.
Core idea of pagination:
Never return an unbounded list of items. Always limit how many items you return, and let the client control which subset (page) they want.
Basic Pagination Concepts
Before looking at concrete strategies, it helps to know the common concepts.
| Concept | Meaning |
|---|---|
| Page / slice | A subset of the full result set, usually limited to a fixed size |
| Page size / limit | Maximum number of items in a page, for example 10, 20, or 100 |
| Offset | Number of items to skip at the start of the result set |
| Cursor | A pointer or token that represents a position in the result set |
| Total count | Total number of items matching the query, for example 12,345 |
Every pagination strategy combines one way to specify which page with one way to limit how many items are returned.
Offset-based Pagination
Offset-based pagination is the simplest and most common style. It uses two main query parameters:
limit: How many items to return.offset: How many items to skip from the start.
For example, for a products list:
GET /products?limit=20&offset=0 # first 20 products
GET /products?limit=20&offset=20 # next 20 products (items 21-40)
GET /products?limit=20&offset=40 # items 41-60
Or using page and page_size:
GET /products?page=1&page_size=20 # items 1-20
GET /products?page=2&page_size=20 # items 21-40
GET /products?page=3&page_size=20 # items 41-60Internally, for a relational database, offset-based pagination often becomes:
SELECT *
FROM products
ORDER BY id
LIMIT :limit OFFSET :offset;Example JSON Response
A common response structure for offset-based pagination:
{
"items": [
{"id": 21, "name": "Product 21"},
{"id": 22, "name": "Product 22"}
],
"limit": 2,
"offset": 20,
"total": 45
}You can also include URLs for convenience:
{
"items": [...],
"limit": 20,
"offset": 20,
"total": 200,
"links": {
"self": "/products?limit=20&offset=20",
"next": "/products?limit=20&offset=40",
"prev": "/products?limit=20&offset=0"
}
}Pros and Cons
| Aspect | Offset-based pagination |
|---|---|
| Simplicity | Very easy to understand and implement |
| Flexibility | Can jump directly to any offset (for example offset = 2000) |
| Performance | Gets slower with large offsets, databases need to skip many rows |
| Consistency | Can show duplicates or missing items if data changes while paging |
Example of inconsistency:
- Client loads
offset=0&limit=10and sees items with IDs 1 to 10. - A new item with ID 0 is inserted at the beginning.
- Client loads
offset=10&limit=10and now gets items with IDs 11 to 20.
The client has missed the new item with ID 0, and depending on sorting could see shifts or duplicates.
Offset-based pagination is good for:
- Admin panels.
- Small to medium result sets.
- Simple UIs with "Go to page X".
It is not ideal for:
- Very large result sets.
- Real time feeds that change constantly.
Page-based Pagination
Page-based pagination is just a friendly layer over offset pagination. Instead of offset, you use:
page: The page number, starting from 1.page_sizeorlimit: Number of items per page.
Example:
GET /articles?page=1&page_size=10 # items 1-10
GET /articles?page=2&page_size=10 # items 11-20
GET /articles?page=3&page_size=10 # items 21-30
Conversion between page and offset is:
offset = (page - 1) * page_size
For example, page=3, page_size=10 gives offset = (3 - 1) * 10 = 20.
Formula:
For page-based pagination, with 1-based page numbering,
$$\text{offset} = (\text{page} - 1) \times \text{page\_size}$$
Example JSON Response
{
"items": [
{"id": 11, "title": "Article 11"},
{"id": 12, "title": "Article 12"}
],
"page": 2,
"page_size": 2,
"total": 5,
"total_pages": 3
}
total_pages can be computed as:
$$
\text{total\_pages} = \left\lceil \frac{\text{total}}{\text{page\_size}} \right\rceil
$$
For example, total=5, page_size=2 gives ceil(5 / 2) = 3.
Pros and Cons
Page-based pagination:
- Is very intuitive for users, "Page 1", "Page 2".
- Is often used in classic websites.
- Internally still uses offset, so has the same performance and consistency limits as offset-based pagination.
It is often a good default for admin interfaces and moderate data sizes.
Cursor-based Pagination
Cursor-based pagination uses a cursor to mark where the next page starts, instead of a numeric offset or page number.
A cursor is usually:
- A unique column value, for example a timestamp or an ID.
- Or an encoded token that the server can decode.
The API uses parameters like:
cursororafterlimit(orpage_size)
For example:
GET /events?limit=3 # first page, no cursor
GET /events?limit=3&after=167 # next page, after id 167Imagine events:
| id | message |
|---|---|
| 101 | "User A logged in" |
| 102 | "User B logged out" |
| 167 | "User C posted" |
| 200 | "User D deleted post" |
| 205 | "User E created post" |
First request:
GET /events?limit=3Server response:
{
"items": [
{"id": 101, "message": "User A logged in"},
{"id": 102, "message": "User B logged out"},
{"id": 167, "message": "User C posted"}
],
"limit": 3,
"next_cursor": "167",
"has_next": true
}
Client then uses next_cursor:
GET /events?limit=3&after=167Server response:
{
"items": [
{"id": 200, "message": "User D deleted post"},
{"id": 205, "message": "User E created post"}
],
"limit": 3,
"next_cursor": null,
"has_next": false
}
Here the cursor is simply the id. Internally you can run:
SELECT *
FROM events
WHERE id > :cursor
ORDER BY id
LIMIT :limit;Encoded Cursors
Often, you do not want to expose direct IDs or timestamps, or you want more complex state. You can encode a cursor like this:
{
"items": [...],
"next_cursor": "eyJsYXN0X2lkIjoxNjcsIm9yZGVyIjoiYXNjIn0="
}This can be a Base64 encoded JSON object such as:
{"last_id":167,"order":"asc"}On each request:
- Decode the cursor.
- Extract necessary values, for example
last_id. - Use them in your database query.
Pros and Cons
| Aspect | Cursor-based pagination |
|---|---|
| Performance | Very good for large datasets, can use index-based queries |
| Consistency | More stable with changing data, less risk of duplicates or skipped items |
| Flexibility | Good for "infinite scroll" and real-time feeds |
| Complexity | Harder to implement and to debug than simple page or offset |
| Jump to page | Hard to jump directly to "page 100", often impossible or expensive |
Cursor-based pagination fits best when:
- You have large tables.
- Data changes rapidly.
- You use "Load more" or infinite scrolling.
- You care about performance and consistency over "go to page X".
Designing Pagination Parameters
Pagination fits naturally into query parameters in REST APIs. You typically choose one of these styles:
| Style | Example request |
|---|---|
| Offset-based | GET /users?limit=20&offset=40 |
| Page-based | GET /users?page=3&page_size=20 |
| Cursor-based | GET /users?limit=20&after=abc123 |
Some design tips:
- Use short, clear parameter names, for example
limit,offset,page,page_size,cursor,after,before. - Define default values, for example
limit=20when not specified. - Define maximum allowed values, for example
limitcannot exceed 100. - Document your pagination behavior in your API docs.
Example of a backend using defaults and maximums (in pseudocode):
MAX_LIMIT = 100
DEFAULT_LIMIT = 20
def get_limit(request):
# read from query parameter
limit = int(request.query_params.get("limit", DEFAULT_LIMIT))
# enforce minimum and maximum
if limit < 1:
limit = 1
if limit > MAX_LIMIT:
limit = MAX_LIMIT
return limit
Important rule:
Always validate and clamp pagination parameters.
Never trust the client to send reasonable limit, page, or offset values.
Including Pagination Metadata
A common design decision is: What information do you put in the response besides the items themselves?
Useful fields:
itemsordata: The list of resources.limitorpage_size: The requested page size.offsetorpage: The current position.total: Total number of matching items.total_pages: Number of pages (when using page-based).next/prevorlinks: URLs to fetch next or previous pages.has_next/has_prev: Helpful flags for UIs.next_cursor: Cursor for cursor-based pagination.
Example: Page-based Response with Metadata
{
"items": [
{"id": 101, "name": "Alice"},
{"id": 102, "name": "Bob"}
],
"page": 1,
"page_size": 2,
"total": 5,
"total_pages": 3,
"has_next": true,
"has_prev": false,
"links": {
"self": "/users?page=1&page_size=2",
"next": "/users?page=2&page_size=2"
}
}Example: Cursor-based Response with Minimal Metadata
{
"items": [
{"id": 101, "message": "First"},
{"id": 102, "message": "Second"}
],
"limit": 2,
"next_cursor": "102",
"has_next": true
}You can choose how much metadata you want to return. More metadata helps clients, but costs more CPU and queries.
Counting Total Items
Sometimes you want to show "Page 1 of 50" or "Showing 1-20 of 963 results". To do that, you need the total count of items.
You usually compute this in SQL with COUNT(*):
SELECT COUNT(*) FROM products WHERE category_id = :category;Then your backend can include it in the response.
However, counting can be expensive on huge tables. Some strategies:
- Always count. Simple, but slower for very large data.
- Optional count. Only count when the client requests it, for example
?include_total=true. - Approximate count. Some databases can return an estimated count quickly.
- Skip count. For infinite scrolling or real time feeds, you might not need a total.
Example of optional count in a request:
GET /products?limit=20&offset=0&include_total=trueResponse:
{
"items": [...],
"limit": 20,
"offset": 0,
"total": 1234
}
Without include_total, you may omit the total field to save work.
Pagination and Sorting
Pagination always depends on ordering. You need a consistent ORDER BY in your queries, or the pages will not be stable.
For example, you might allow clients to control the sort:
GET /users?sort=created_at&order=desc&limit=20Internally:
SELECT *
FROM users
ORDER BY created_at DESC
LIMIT :limit OFFSET :offset;Stable Ordering
To avoid random ordering, always include a tie-breaker in your order:
ORDER BY created_at DESC, id DESC
If two rows have the same created_at, the id ensures they appear in a consistent order.
For cursor-based pagination with timestamps, a common trick:
SELECT *
FROM messages
WHERE (created_at, id) > (:cursor_created_at, :cursor_id)
ORDER BY created_at, id
LIMIT :limit;This uses a composite condition to handle duplicate timestamps:
- First compare
created_at. - If equal, compare
id.
Key rule:
Pagination without a deterministic and stable sort order can cause duplicates and missing items.
Always define and document the ordering used by your paginated endpoints.
Combining Pagination with Filtering
In real APIs, lists are almost always filtered and sorted.
Example:
GET /orders?status=pending&sort=created_at&order=desc&limit=20&offset=0Your backend will:
- Apply filters (
status=pending). - Apply sorting (
ORDER BY created_at DESC, id DESC). - Apply pagination (
LIMIT 20 OFFSET 0).
If you include total, it should respect the same filters:
SELECT COUNT(*)
FROM orders
WHERE status = 'pending';This means "total number of pending orders," not total orders overall.
The important rule: Pagination is always applied after filters and sorting. The full process is:
- Start with all records.
- Filter by query parameters.
- Sort by requested order.
- Page the results, for example first 20 items.
Limits, Defaults, and Protection
Poorly controlled pagination can cause load problems. For example, if a client does:
GET /logs?limit=1000000The server may:
- Read a huge amount of data.
- Run out of memory.
- Cause timeouts.
To avoid that, you should:
- Set a safe default limit, for example 20.
- Set a maximum allowed limit, for example 100 or 500.
- Reject invalid parameters with a clear error.
Example of a validation error response:
{
"detail": "Invalid limit. Must be between 1 and 100."
}
If the client sends a negative or zero page or offset, you can:
- Either clamp to 0 or 1.
- Or return a 400 Bad Request.
Either behavior is OK, but be consistent and document it.
Example: Designing a Paginated Endpoint
Imagine a REST endpoint for listing blog posts.
Request format
GET /posts?tag=python&sort=published_at&order=desc&page=2&page_size=5Meaning:
- Filter posts by tag
python. - Sort by
published_atdescending. - Return page 2 with 5 posts per page.
Backend logic outline
- Read query parameters, with defaults:
tag: optional.sort: defaultpublished_at.order: defaultdesc.page: default1.page_size: default10, max100.- Validate parameters.
- Build SQL query:
SELECT *
FROM posts
WHERE (:tag IS NULL OR :tag = ANY(tags))
ORDER BY published_at DESC, id DESC
LIMIT :page_size
OFFSET (:page - 1) * :page_size;- Optionally, count total:
SELECT COUNT(*)
FROM posts
WHERE (:tag IS NULL OR :tag = ANY(tags));- Build JSON response:
{
"items": [...],
"page": 2,
"page_size": 5,
"total": 23,
"total_pages": 5,
"has_next": true,
"has_prev": true,
"links": {
"self": "/posts?tag=python&sort=published_at&order=desc&page=2&page_size=5",
"next": "/posts?tag=python&sort=published_at&order=desc&page=3&page_size=5",
"prev": "/posts?tag=python&sort=published_at&order=desc&page=1&page_size=5"
}
}This is a typical pattern for many REST APIs.
Choosing a Pagination Strategy
Different applications benefit from different strategies.
| Situation | Recommended approach |
|---|---|
| Admin dashboard, small datasets | Page-based or offset-based |
| Public API with search results | Page-based, maybe cursor for heavy use |
| Real-time feeds, chat, timelines | Cursor-based |
| Very large tables (millions of rows) | Cursor-based, or keyset pagination |
| Simple internal tools | Offset-based |
For many beginner backend projects, starting with page-based pagination is enough. As data grows or you build more advanced systems, you can introduce cursor-based pagination where needed.
Summary
- Pagination splits large lists of resources into smaller pages.
- Offset-based and page-based pagination are simple, but slower and less stable for huge or rapidly changing data.
- Cursor-based pagination is more complex, but scales better and is more consistent.
- Always validate and clamp
limit,page, and related parameters. - Always define a clear and stable sort order before paginating.
- Decide whether to include total counts based on performance needs.
- Combine pagination with filtering and sorting to provide flexible list endpoints.
With these concepts, you can design REST endpoints that remain fast and usable, even as your data grows.
Views: 7
KAHIBARO