PageNumberPagination, LimitOffsetPagination, and the metadata
coreintermediatePagination 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.
Think of it as
Both of these classes compile to `LIMIT … OFFSET …`, and understanding what `OFFSET` actually does explains almost everything about their behaviour. `OFFSET 44950` is not a seek to row 44,951 — the database must produce the first 44,950 rows in sorted order and throw them away before it can return anything. So the cost of a page grows with how deep the page is, and page 900 of a large table is genuinely a slow query while page 1 of the same endpoint is instant. The second consequence is subtler: an offset is a position in a *result ordering*, not a handle on a row. If a row is inserted or deleted between two requests, every position after it shifts, so page 2 can repeat an item page 1 already returned, or skip one entirely. Neither problem is a bug in DRF; they are what offset pagination is. `count` deserves the same scrutiny — it is a separate `SELECT COUNT(*)` over the filtered set on every single request, which on a large table can cost more than fetching the page did. Those three facts — depth cost, drift, and the count query — are the reasons cursor pagination exists.
What we're doing: Paginate an orders endpoint, let clients ask for a bigger page within a cap, and drop the count query where it is not worth paying for.
- 3
- Without `page_size_query_param`, `?page_size=100` is ignored silently — the client asks and nothing changes.
- 4
- `max_page_size` is the only thing standing between an exposed `page_size` parameter and `?page_size=100000`. Naming the first without the second is the common mistake.
- 8–14
- Overriding the envelope to drop `count` removes a full `SELECT COUNT(*)` per request. Worth doing when the client only walks forward and the total is decoration.
- 20
- A deterministic sort with `-id` as the tiebreaker. Without it, two orders sharing a `created_at` can swap places between requests, and a row lands on both page 1 and page 2.
Why this works: The page size cap and the tiebreaking sort are the two lines that make offset pagination behave predictably; the count override is the one to reach for when profiling shows the total costs more than the page.
Setting `DEFAULT_PAGINATION_CLASS` and assuming the API is paginated
Wrong
Better
What you see: Endpoints return a bare list rather than the `{"count", "next", "previous", "results"}` envelope, and one request eventually exhausts the worker's memory. In development, with a hundred seeded rows, everything looks correct.
Why: `PageNumberPagination.page_size` defaults to the `PAGE_SIZE` setting, which is itself `None`. A page size of `None` means "do not paginate", so the class is installed and inert. Nothing warns about this, because an unpaginated response is a legitimate configuration — the failure only appears at a data volume development never sees.
- 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.
The two offset-based classes
Together
Remember: 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.
See also: cursor pagination and stable ordering · custom pagination and page size limits · query validation and complexity controls

