Filter concepts by levelShowing all levels.

Django · DRF Pagination

DRF Pagination

Page number and limit/offset, cursor pagination and stable ordering, custom classes and size limits

  • OFFSET n produces and discards n rowspage cost grows with depth — page 900 is a genuinely slow query
  • An offset is a position, not a rowan insert between requests duplicates or skips items across page boundaries
  • A cursor is a value, so depth is freeand no concurrent write can shift what the next page returns
  • max_page_size is a memory budgetthe page exists as rows, model instances, and serialized data at once
  • The class alone does nothingDEFAULT_PAGINATION_CLASS without PAGE_SIZE leaves pagination inert

1.PageNumberPagination, LimitOffsetPagination, and the metadata

Pagination cuts a list response into pages. `PageNumberPagination` numbers them — `?page=3` — and `LimitOffsetPagination` takes a window — `?limit=50&offset=100`. Both wrap the results in the same metadata envelope: `count` (how many rows matched in total), `next` and `previous` (full URLs, or `null` at the ends), and `results` (the rows themselves). Both are opt-in: `DEFAULT_PAGINATION_CLASS` and `PAGE_SIZE` are both `None` out of the box, so an unpaginated list endpoint returns every row it matched — which is fine on a table of twenty and a serious problem on a table of two million.

Key features

  • `DEFAULT_PAGINATION_CLASS` and `PAGE_SIZE` both default to `None`; setting the class without also setting `PAGE_SIZE` (or `page_size` on the class) leaves pagination inactive
  • `PageNumberPagination` reads `?page=`; `page_size_query_param` is `None` by default, so clients cannot change the page size until you name it
  • `LimitOffsetPagination` reads `?limit=` and `?offset=`, with `default_limit` falling back to `PAGE_SIZE` and `max_limit` defaulting to `None` — unbounded until you set it
  • The envelope is `{"count", "next", "previous", "results"}` for both; `next`/`previous` are absolute URLs built from the current request, and are `null` at each end
  • `count` is a separate `SELECT COUNT(*)` on the filtered queryset, run on every request — on a large table it can dominate the response time
  • Pagination runs after filtering and ordering, so `count` and the links always describe the *filtered* set

The two offset-based classes

The two offset-based classes
Property`PageNumberPagination``LimitOffsetPagination`
Parameters`?page=3``?limit=50&offset=100`
Page size attribute`page_size``default_limit`
Client-settable size`page_size_query_param` (off by default)`limit_query_param` (`limit`, on by default)
Upper bound`max_page_size``max_limit` (`None` by default)
Out of range404empty `results`, `count` still correct
Best forUIs with numbered pagesclients that want an arbitrary window
What each page actually asks the database for

Page 1 — cheap

The database reads the first 50 rows in order and stops. An index on the ordering column serves this directly.

Page 2 — still cheap

Fifty rows are produced and discarded before the fifty that are returned. Barely measurable at this depth.

Page 900 — expensive

Forty-four thousand nine hundred and fifty rows are produced, sorted, and thrown away to return fifty. Cost grows linearly with depth.

And the response shape never changes

The client sees the same envelope whether the page cost 8ms or 12s — which is exactly why deep pagination goes unnoticed until it is a production incident.

  1. Page 1 — cheap — The database reads the first 50 rows in order and stops. An index on the ordering column serves this directly.
  2. Page 2 — still cheap — Fifty rows are produced and discarded before the fifty that are returned. Barely measurable at this depth.
  3. Page 900 — expensive — Forty-four thousand nine hundred and fifty rows are produced, sorted, and thrown away to return fifty. Cost grows linearly with depth.
  4. And the response shape never changes — The client sees the same envelope whether the page cost 8ms or 12s — which is exactly why deep pagination goes unnoticed until it is a production incident.

Command

python
class OrderPagination(PageNumberPagination):
    page_size = 50
    page_size_query_param = "page_size"
    max_page_size = 200

Both offset classes compile to `LIMIT … OFFSET …`, and `OFFSET n` produces and discards n rows — so page cost grows with depth, and an insert between requests shifts every later position, letting a row appear twice or not at all. `count` is a separate `COUNT(*)` on every request. Setting `DEFAULT_PAGINATION_CLASS` without `PAGE_SIZE` leaves pagination inert, and exposing `page_size_query_param` without `max_page_size` hands the caller your memory limit.

2.Cursor pagination, stable ordering, and large datasets

Cursor pagination replaces "give me page 900" with "give me what comes after this row". The `next` link carries an opaque cursor encoding the ordering value of the last row returned, and the next query becomes `WHERE created_at < <cursor> ORDER BY created_at DESC LIMIT 50` — which an index serves in the same time whether it is the first page or the ten-thousandth. It also fixes drift: because the boundary is a value rather than a position, rows inserted while a client is paging do not shift anything, so no item is returned twice or skipped. The price is that you cannot jump to an arbitrary page, and the ordering field has to satisfy real constraints — unchanging, effectively unique, non-nullable, and indexed.

Key features

  • `CursorPagination.ordering` defaults to `"-created"`; if your model has no `created` field the class raises until you set it
  • The DRF docs list the requirements on the ordering field: unchanging, unique or nearly unique, non-nullable, coercible to a string, not a float, and indexed
  • "Nearly unique" is not enough at a page boundary — use a compound ordering ending in the primary key, e.g. `ordering = ("-created_at", "-id")`
  • A cursor response has no `count` — the class deliberately omits it, which also removes the per-request `COUNT(*)`
  • Cursors are opaque base64 of an internal position dict; clients must treat them as tokens and never construct or parse them
  • Cursor pagination cannot serve `?page=900` — that capability is what you trade away, and no configuration brings it back

Offset versus cursor, on the properties that actually differ

Offset versus cursor, on the properties that actually differ
PropertyPage number / limit-offsetCursor
Cost of the 1000th pagegrows linearly with depththe same as the first page
Insert during pagingshifts positions — duplicates or skipsno effect — the boundary is a value
`count` in the responseyes, and it costs a `COUNT(*)`no
Jump to page Nyesno
Ordering constraintsany deterministic sortunchanging, unique, non-null, indexed
Fitsadmin tables, numbered-page UIsfeeds, logs, exports, sync APIs
The same insert, under offset and under cursor pagination
Client
API
Database
Another writer
  1. 1GET /events/?page=1 (offset)
  2. 2LIMIT 50 OFFSET 0
  3. 3rows 1–50 · newest is event #900
  4. 4INSERT a new eventeverything shifts down one position
  5. 5GET /events/?page=2
  6. 6LIMIT 50 OFFSET 50
  7. 7row 50 repeats — it moved into page 2
  8. 8GET /events/?cursor=cD0yMDI2… (cursor)
  9. 9WHERE (occurred_at, id) < (cursor) LIMIT 50an index seek — same cost at any depth
  10. 10the next 50, no repeat and no gap
  1. Client → API: GET /events/?page=1 (offset)
  2. API → Database: LIMIT 50 OFFSET 0
  3. Database → Client: rows 1–50 · newest is event #900
  4. Another writer → Database: INSERT a new event (everything shifts down one position)
  5. Client → API: GET /events/?page=2
  6. API → Database: LIMIT 50 OFFSET 50
  7. Database → Client: row 50 repeats — it moved into page 2
  8. Client → API: GET /events/?cursor=cD0yMDI2… (cursor)
  9. API → Database: WHERE (occurred_at, id) < (cursor) LIMIT 50 (an index seek — same cost at any depth)
  10. Database → Client: the next 50, no repeat and no gap

Command

python
class EventPagination(CursorPagination):
    page_size = 100
    ordering = ("-occurred_at", "-id")

Cursor pagination trades random access for constant cost and consistency: the boundary is a value, not a position, so depth is free and a concurrent insert cannot duplicate or skip a row. The ordering field must be unchanging, non-nullable, not a float, indexed, and unique — which in practice means ending the ordering with the primary key. You give up `count` and page numbers, so choose per endpoint: feeds, logs and exports get cursors; numbered-page tables keep offsets.

3.Custom pagination classes and page-size limits

A custom pagination class is usually a three-line subclass: set `page_size`, name `page_size_query_param` if clients may change it, and set `max_page_size` to bound what they can ask for. Beyond that, overriding `get_paginated_response(data)` changes the envelope — to nest the metadata, to add a `total_pages` field a front-end needs, or to drop `count` and its `COUNT(*)`. Overriding `get_paginated_response_schema()` alongside it keeps the generated OpenAPI document honest. Set the class per view with `pagination_class`, or globally with `DEFAULT_PAGINATION_CLASS`; set `pagination_class = None` on a view that genuinely must return everything.

Key features

  • `page_size` is the default page size; `max_page_size` is the cap on a client-requested one, and only matters once `page_size_query_param` is set
  • `pagination_class = None` on a view disables pagination for that endpoint, overriding the global default
  • `get_paginated_response(data)` builds the envelope — override it to add, rename, or remove fields
  • `get_paginated_response_schema(schema)` must be overridden alongside it, or generated OpenAPI documents keep describing the default envelope
  • `self.page.paginator.num_pages` is available inside `PageNumberPagination` for a `total_pages` field — it costs nothing extra, since the count query has already run
  • A `max_page_size` is a memory budget: the page is held as rows, as model instances, and as serialized data at the same time

The four attributes that decide the page-size behaviour

The four attributes that decide the page-size behaviour
AttributeDefaultWhat it does
`page_size``PAGE_SIZE` setting (`None`)the size when the client asks for nothing — `None` means no pagination at all
`page_size_query_param``None`the parameter name clients use; unset means the size is fixed
`max_page_size``None`the ceiling on a requested size; inert unless the parameter above is named
`page_query_param``"page"`the page-number parameter name
Every line of a custom pagination class, and what it decides

class OrderPagination(CursorPagination): page_size = 50 page_size_query_param = "page_size" max_page_size = 200 ordering = "-created_at"

CursorPagination

the strategy — Decides how a page is addressed at all — by cursor here, or by page number / limit-offset. Changing this changes the response envelope and the client contract.

page_size = 50

the default size — What a client that asks for nothing receives. Leave it unset and it falls back to the PAGE_SIZE setting, which is None — meaning no pagination.

page_size_query_param = "page_size"

the opt-in to a client-set size — Until this is named, ?page_size=100 is ignored silently. Naming it hands size control to the caller.

max_page_size = 200

the memory budget — The only bound on the line above. Inert without it, and essential with it — the page is held as rows, model instances, and serialized data at once.

ordering = "-created_at"

the cursor boundary — Cursor-only, and required — it must be indexed, unchanging and effectively unique, or pages skip rows at their edges.

  • Whole: class OrderPagination(CursorPagination): page_size = 50 page_size_query_param = "page_size" max_page_size = 200 ordering = "-created_at"
  • CursorPagination — the strategy: Decides how a page is addressed at all — by cursor here, or by page number / limit-offset. Changing this changes the response envelope and the client contract.
  • page_size = 50 — the default size: What a client that asks for nothing receives. Leave it unset and it falls back to the PAGE_SIZE setting, which is None — meaning no pagination.
  • page_size_query_param = "page_size" — the opt-in to a client-set size: Until this is named, ?page_size=100 is ignored silently. Naming it hands size control to the caller.
  • max_page_size = 200 — the memory budget: The only bound on the line above. Inert without it, and essential with it — the page is held as rows, model instances, and serialized data at once.
  • ordering = "-created_at" — the cursor boundary: Cursor-only, and required — it must be indexed, unchanging and effectively unique, or pages skip rows at their edges.

Command

python
class StandardPagination(PageNumberPagination):
    page_size = 50
    page_size_query_param = "page_size"
    max_page_size = 200

    def get_paginated_response(self, data):
        return Response({"meta": {...}, "data": data})

`page_size` is the default, `max_page_size` is the ceiling, and the ceiling does nothing until `page_size_query_param` makes the size caller-controlled. Choose the ceiling as a memory budget — the page exists as rows, model instances, and serialized data simultaneously. If you override `get_paginated_response()`, override `get_paginated_response_schema()` in the same class, or your OpenAPI document quietly describes an envelope you no longer return.

Remember

  • Both offset classes compile to LIMIT … OFFSET …, and count is a separate SELECT COUNT(*) on every request over the filtered set.
  • Cursor pagination needs an ordering field that is unchanging, non-nullable, not a float, indexed, and unique — in practice, ordering ending in the primary key.
  • Cursor responses have no count and cannot jump to a page: that is the trade, and no configuration brings it back.
  • page_size is the default; max_page_size is the ceiling; the ceiling is inert until page_size_query_param makes the size caller-controlled.

Pro tip

  • Choose per endpoint, not per project: numbered-page admin tables keep offset pagination, while feeds, logs, exports and sync APIs get cursors.
  • If you override get_paginated_response(), override get_paginated_response_schema() in the same class — otherwise the OpenAPI document describes an envelope you no longer return.

Next up

  • DRF Throttling and Rate Limiting
  • API Design
  • API Error Handling

Sources

  • DRF Pagination — django-rest-framework.org/api-guide/pagination/
  • Every claim checked against Django REST Framework 3.15 docs · checked 2026-09-04

Spotted a mistake on this page? Tell us.