and Sorting for REST APIs
List endpoints are the most complex parts of a REST API: they must support pagination, filtering and sorting consistently without bloating the API surface. The difference between a well designed and a poorly designed list endpoint comes down to three decisions: cursor or offset, which filter parameters are allowed, and how the meta object informs consumers.
Table of contents
- 1. The fundamental decision: cursor or offset?
- 2. Offset pagination: simple, but with limits
- 3. Cursor pagination: consistent with changing data
- 4. The meta object: what consumers actually need
- 5. Filter parameters: syntax, types and limits
- 6. Sorting: keep it simply consistent
- 7. Fully documenting list endpoints in OpenAPI
- 8. Performance considerations for pagination and filtering
- 9. Pagination types compared directly
- 10. Summary
- 11. FAQ
1. The fundamental decision: cursor or offset?
The first and most important design decision for any list endpoint is choosing the pagination mechanism. Offset pagination (?page=2&per_page=20) is intuitive, widely used and easy to implement. Cursor pagination (?cursor=eyJpZCI6MTAwfQ&limit=20) is more complex, but more consistent with changing data and scales better for large datasets.
The decision depends on the use case: if users or clients need to jump to arbitrary pages (for example page 47 of 200), offset is the only practical option. If the data changes frequently and users navigate sequentially through results (for example an activity feed or log stream), cursor is the more reliable choice. The problem with offset on changing data: if a new item is inserted at the front between page 1 and page 2, the entire offset shifts, items get shown twice or not at all.
A pragmatic strategy: offset for admin interfaces and reports (arbitrary page jumps matter), cursor for user feeds and timelines (consistency matters). Both models can coexist within the same API, different endpoints with different requirements can use different pagination types. This should be clearly signaled in the OpenAPI documentation.
2. Offset pagination: simple, but with limits
Offset pagination is the simplest starting point and is fully sufficient for many use cases. The basic form: ?page=2&per_page=20 or alternatively ?offset=40&limit=20. Both variants are common, but only one should be used consistently within a given API. The page/per_page variant is more user friendly and closer to human intuition ("page 3"), the offset/limit variant is more precise and maps more directly to the SQL layer.
Important design decisions for offset pagination: the maximum for per_page must be limited (typically 100 or 200) to prevent resource-exhaustion attacks. The default should be sensible (20 or 25 is common). The total count in the meta object is useful with offset so clients can calculate the total number of pages, but it is also expensive because a COUNT query on large tables is slow. For very large datasets, an approximate total (totalApproximate) or no total at all can be a better option.
# Offset pagination, OpenAPI parameter definitions
components:
parameters:
PageParam:
name: page
in: query
description: Page number (1-indexed)
schema:
type: integer
minimum: 1
default: 1
PerPageParam:
name: per_page
in: query
description: Items per page. Maximum 100.
schema:
type: integer
minimum: 1
maximum: 100
default: 20
schemas:
OffsetPaginationMeta:
type: object
required: [total, page, perPage, totalPages]
properties:
total:
type: integer
minimum: 0
description: Total number of items matching the current filters
example: 842
page:
type: integer
minimum: 1
description: Current page number
example: 3
perPage:
type: integer
minimum: 1
maximum: 100
description: Items per page as requested
example: 20
totalPages:
type: integer
minimum: 0
description: Total number of pages (ceil(total / perPage))
example: 43
links:
type: object
description: Navigation links for common page transitions
properties:
self: { type: string, format: uri }
first: { type: string, format: uri }
prev: { type: string, format: uri, nullable: true }
next: { type: string, format: uri, nullable: true }
last: { type: string, format: uri }
3. Cursor pagination: consistent with changing data
Cursor pagination uses an opaque pointer (cursor) to a position in the dataset. The cursor is typically a Base64-encoded JSON object that holds the value of the sort field of the last item returned, for example {"id": 100, "createdAt": "2026-05-10T12:00:00Z"} as the cursor for a dataset sorted by createdAt DESC, id DESC. The server can safely decode this cursor and constrain the query with a WHERE (createdAt, id) < (cursorCreatedAt, cursorId) condition.
An important API design principle: cursors must be opaque to clients. Clients must not interpret or construct the cursor, it is generated by the server and sent back unchanged in the next request. This lets the server change the cursor implementation without breaking the API surface. The cursor should be time-limited (for example 24 hours) to prevent clients from using very old cursors indefinitely.
# Cursor pagination, OpenAPI definitions
components:
parameters:
CursorParam:
name: cursor
in: query
description: |
Opaque pagination cursor from the previous response's meta.cursors.next.
Do NOT construct or interpret this value, treat as a black box.
Cursors expire after 24 hours.
schema:
type: string
example: eyJpZCI6MTAwLCJjcmVhdGVkQXQiOiIyMDI2LTA1LTEwIn0
LimitParam:
name: limit
in: query
description: Number of items to return. Maximum 100.
schema:
type: integer
minimum: 1
maximum: 100
default: 20
schemas:
CursorPaginationMeta:
type: object
required: [limit, hasMore]
properties:
limit:
type: integer
description: Items per page as requested
example: 20
hasMore:
type: boolean
description: Whether more items exist after the current page
example: true
cursors:
type: object
properties:
next:
type: string
nullable: true
description: Cursor for the next page. Null if this is the last page.
example: eyJpZCI6MTIwfQ
prev:
type: string
nullable: true
description: Cursor for the previous page. Null if this is the first page.
example: eyJpZCI6ODl9
# Example usage in a list endpoint
paths:
/orders:
get:
summary: List orders (cursor-paginated)
parameters:
- $ref: '#/components/parameters/CursorParam'
- $ref: '#/components/parameters/LimitParam'
responses:
'200':
description: Order list
content:
application/json:
schema:
type: object
required: [data, meta]
properties:
data:
type: array
items:
$ref: '#/components/schemas/Order'
meta:
$ref: '#/components/schemas/CursorPaginationMeta'
4. The meta object: what consumers actually need
The meta object in a list response is the interface between the API and the pagination logic in the client. What consumers actually need varies by pagination type and use case. For offset pagination: total count, current page, total pages and direct navigation links. For cursor pagination: a hasMore flag, cursors for the next and previous page.
A common mistake: the meta object is designed so that the client still has to compute things. If total and perPage are returned, the client has to compute Math.ceil(total/perPage) to know the page count. Better: provide totalPages directly. If cursor is returned but no hasMore flag, the client has to test whether cursor is null. Better: explicitly state hasMore: false. The meta object should be designed so client code stays minimal.
5. Filter parameters: syntax, types and limits
Filter parameters are one of the most common sources of API design inconsistency. Three different teams build three different filter syntaxes: ?status=active, ?filter[status]=active and ?filter=status:active. Consumers have to learn three different syntaxes, documentation becomes more complex, and generated client code has to support all three variants.
The recommendation: simple equality filters as direct query parameters (?status=active&category=electronics). Multiple values via comma-separated lists or a repeated parameter (?status=active,pending or ?status=active&status=pending). Range filters via dedicated parameters (?price_min=10&price_max=100 or ?created_after=2026-01-01&created_before=2026-12-31). For complex filter requirements (AND/OR combinations, negation) a JSON-based filter parameter is a good fit, but only if the API genuinely needs to allow complex queries.
# Filter parameters, OpenAPI definitions for list endpoint
paths:
/products:
get:
summary: List products with filtering, sorting and pagination
parameters:
# Simple equality filter
- name: status
in: query
description: Filter by product status. Multiple values allowed (comma-separated).
schema:
type: string
enum: [active, inactive, draft, archived]
example: active
- name: category_id
in: query
description: Filter by category UUID. Matches exact category, not children.
schema:
type: string
format: uuid
# Range filter
- name: price_min
in: query
description: Minimum price (inclusive), in EUR cents.
schema:
type: integer
minimum: 0
- name: price_max
in: query
description: Maximum price (inclusive), in EUR cents.
schema:
type: integer
minimum: 0
# Date range filter
- name: created_after
in: query
description: Only products created after this date (ISO 8601 date).
schema:
type: string
format: date
example: "2026-01-01"
- name: created_before
in: query
schema:
type: string
format: date
example: "2026-12-31"
# Full-text search
- name: q
in: query
description: Full-text search across name and description. Min 2 chars.
schema:
type: string
minLength: 2
maxLength: 200
# Sorting
- name: sort
in: query
description: >
Sort field and direction. Prefix with - for descending.
Supported fields: name, price, createdAt, updatedAt.
schema:
type: string
enum: [name, -name, price, -price, createdAt, -createdAt, updatedAt, -updatedAt]
default: -createdAt
# Pagination
- $ref: '#/components/parameters/PageParam'
- $ref: '#/components/parameters/PerPageParam'
6. Sorting: keep it simply consistent
Sort parameters are simpler to design than filter parameters, but inconsistencies are still common. The most common pattern: ?sort=field for ascending, ?sort=-field for descending (the minus-sign prefix is inspired by many JSON:API implementations). Alternatively: ?sort_by=name&sort_order=asc as separate parameters.
Important design decisions: which fields are sortable? Not every field that is returned is meaningful or efficient to sort by. Defining the sortable fields in the OpenAPI documentation as an enum is better than allowing a free-form string, it prevents malformed sort parameters that lead to server errors or unexpected behavior. Multi-field sorting (for example ?sort=-createdAt,name) is optional and should only be added if consumers genuinely need it.
7. Fully documenting list endpoints in OpenAPI
The complete OpenAPI documentation of a list endpoint takes more effort than that of a single resource endpoint, because all parameter combinations and their effects on the response must be documented. Important: parameter combinations that are not supported (for example cursor pagination together with a page parameter) should be explicitly documented, either in the description field or via a 400 response with a description.
Reusable components significantly reduce repetition: pagination parameters as $ref references, meta schemas as reusable components, shared filter parameters (date range, search) as parameter components. This makes OpenAPI documents more maintainable and reduces the chance that different list endpoints use different conventions for the same concept.
| Criterion | Offset pagination | Cursor pagination | Recommendation |
|---|---|---|---|
| Implementation effort | Low | Medium-high | Offset for simple cases |
| Consistency under data changes | Low (duplicates/gaps) | High | Cursor for changing data |
| Arbitrary page jumps | Yes (page 47 directly) | No | Offset for admin UIs |
| Performance on large datasets | Low (OFFSET is expensive) | High (index scan) | Cursor from ~100k rows |
| Total count available | Yes (COUNT query) | No (by design) | Offset when total is needed |
8. Performance considerations for pagination and filtering
Pagination and filtering have a direct impact on database performance, which must be taken into account in API design. Offset pagination with large offsets (LIMIT 20 OFFSET 10000) is expensive: the database has to read and discard 10,000 rows. Cursor pagination avoids this via a WHERE condition on indexed columns. On large datasets (from around 100,000 rows), the performance difference is measurable.
Filter parameters that operate on unindexed fields can trigger full table scans. API design should be aware of the database reality: only fields that are indexed or can be queried efficiently should be exposed as filter parameters. This does not mean the API has to expose every database constraint, but it does mean the API designer coordinates with the database team on which filter combinations should be supported.
10. Summary
Designing clean pagination, filtering and sorting for REST APIs requires three clear decisions: cursor or offset (depending on consistency requirements and dataset size), which filter parameters and syntaxes are used consistently across all list endpoints, and how the meta object supplies consumers with all the information they need without requiring them to compute anything. Making these decisions early and holding to them consistently is more important than the specific syntax choice.
OpenAPI documentation for list endpoints pays off in particular: reusable parameters and schemas for pagination and filtering reduce repetition and enforce consistency. Clients generated from OpenAPI get the pagination logic for free. Contract tests with Postman/Newman verify that the meta object is populated correctly and that cursors work. The effort for careful list endpoint design is a one-time cost, poor design haunts an API for its entire lifetime.
Pagination, filtering and sorting, the essentials at a glance
Cursor vs. offset
Cursor for changing data and large datasets. Offset when page jumps or total count are needed. Both can coexist in the same API.
Meta object
Design it so the client doesn't have to compute anything: totalPages instead of total+perPage, hasMore instead of a cursor-null check, navigation links directly in the meta.
Filter consistency
Simple equality filters as direct query parameters. Range filters as _min/_max pairs. Expose only indexed fields as filters.
OpenAPI reuse
Pagination parameters and meta schemas as reusable components/$ref. Define sortable fields as an enum, it prevents malformed sort parameters.