skip to content

In Django Ninja, how does the @paginate decorator turn a list operation into a paginated one, and what limits should you set?

level: middleimportance: should knowfreq 40%

answer

  1. decorator order matters
  2. return the whole queryset
  3. items plus count
  4. default limit has no ceiling

basics

~20 s

@paginate, placed under the operation decorator on a list response, adds paging query parameters, slices the queryset the view returns and wraps it as {items, count}. LimitOffsetPagination is the default, and its limit is uncapped unless you configure a maximum.

solid answer

~40 s

`@paginate` from `ninja.pagination` goes **below** `@api.get` on an operation whose `response=` is a collection such as `list[ProductOut]`. The view returns the whole, unsliced queryset; the paginator adds its query parameters to the operation, slices the queryset, runs a `count()`, and swaps the response schema for a `Paged<Item>` schema. With the default `LimitOffsetPagination` the client sends `limit` (default 100 via `NINJA_PAGINATION_PER_PAGE`) and `offset`, and gets `{"items": [...], "count": N}`. `@paginate(PageNumberPagination, page_size=20)` switches to `page` and an optional `page_size`, which is capped by `max_page_size`. The trap is that `limit` has **no maximum by default** - `NINJA_PAGINATION_MAX_LIMIT` is infinite - so a client can ask for the whole table; set that setting or pass `max_limit`, and give the queryset an `order_by()` so pages are stable.

code

python · 19 lines
python
from ninja import NinjaAPI
from ninja.pagination import LimitOffsetPagination, PageNumberPagination, paginate

api = NinjaAPI()


@api.get("/products", response=list[ProductOut])
@paginate(LimitOffsetPagination, max_limit=200)
def list_products(request, category: str | None = None):
    qs = Product.objects.select_related("category").order_by("name", "pk")
    if category:
        qs = qs.filter(category__slug=category)
    return qs  # sliced and counted by the paginator


@api.get("/brands", response=list[BrandOut])
@paginate(PageNumberPagination, page_size=50)
def list_brands(request):
    return Brand.objects.order_by("name", "pk")

go deeper

for a junior

Recall the decorator order, that the view returns the whole queryset, and the items plus count response shape.

for a middle

Explain the built-in paginators, their parameters and output keys, and how settings and constructor arguments change the defaults.

for a senior

Cap the limit, order the queryset with a tie-breaker, and weigh the per-request count query against cursor pagination for large tables.

for a principal

Choose one pagination contract for the whole API, since clients and generated SDKs depend on the parameter names and response shape.

## What @paginate does In **Django Ninja**, pagination is a decorator from `ninja.pagination`. Applied to a list operation it: 1. adds the paginator's **input fields** (for example `limit` and `offset`) to the operation as query parameters; 2. calls your view, which returns the **full** queryset or list; 3. slices it according to the input and counts the total; 4. replaces the operation's collection response, such as `list[ProductOut]`, with a generated schema named `PagedProductOut` that wraps the items. The decorator must sit **under** the operation decorator, so it wraps the function before Ninja registers it: ```python @api.get("/products", response=list[ProductOut]) @paginate(PageNumberPagination, page_size=20) def list_products(request): return Product.objects.order_by("name") ``` If `response=` has no collection type, Ninja raises `ConfigError` saying the view has no collection response. ## The built-in paginators | class | query parameters | output keys | notes | |---|---|---|---| | `LimitOffsetPagination` (default) | `limit`, `offset` | `items`, `count` | `limit` defaults to 100 | | `PageNumberPagination` | `page` (from 1), optional `page_size` | `items`, `count` | `page_size` defaults to 100, capped by `max_page_size` (100) | | `CursorPagination` | `cursor`, optional `page_size` | `results`, `next`, `previous` | orders by `("-pk",)` by default, no count | The project-wide default class is set by `NINJA_PAGINATION_CLASS`; constructor arguments go through the decorator, as in `@paginate(PageNumberPagination, page_size=20)`. Passing `pass_parameter="pagination"` hands the parsed input to the view as a keyword argument, for views that need to know which page is being served. ## Settings that bound the cost | setting | default | effect | |---|---|---| | `NINJA_PAGINATION_PER_PAGE` | `100` | default `limit` and default `page_size` | | `NINJA_PAGINATION_MAX_LIMIT` | unlimited | when set, a larger `limit` fails validation with 422 | | `NINJA_MAX_PER_PAGE_SIZE` | `100` | default cap for `page_size` | The constructor argument `LimitOffsetPagination(max_limit=...)` behaves differently from the setting: it **clamps** a larger `limit` silently instead of rejecting it. Either way, the important part is that some cap exists; with the defaults, `?limit=1000000` makes the operation serialise the whole table. ## Costs to know about - **Two queries per page** for limit-offset and page-number pagination: the sliced query and a `count()` over the full queryset. On large filtered tables the count can cost more than the page itself. - **Offsets get slower with depth**, because the database still has to walk past the skipped rows; cursor pagination avoids both the count and the deep offset. - **Stable ordering is your job**: slicing an unordered queryset lets the database return rows in any order between requests, so pages can repeat or skip rows. Add `order_by()` with a unique tie-breaker. - **Related data** read by the item schema is loaded per row unless the view's queryset loads it up front. ## Async operations On an `async def` operation, the built-in paginators use their async path: they slice the queryset, fetch it with async iteration and call `acount()`. Returning an unevaluated queryset from a paginated async operation is therefore fine; what is not fine is item schemas that touch unloaded relations, because serialisation afterwards is synchronous. ## Choosing a paginator - **`LimitOffsetPagination`** - the simplest contract; clients can jump to any position, but deep offsets get slower and every page pays for a count. - **`PageNumberPagination`** - matches numbered pages in a user interface; the page size is bounded by `max_page_size`. - **`CursorPagination`** - suits feeds, infinite scroll and large or fast-changing tables; it returns opaque `next` and `previous` links and no total, and needs an ordering field with a meaningful position. - **A custom class** - subclass `PaginationBase`, define its `Input` and `Output` schemas and implement `paginate_queryset()`; for `async def` operations subclass `AsyncPaginationBase` and implement `apaginate_queryset()` as well, otherwise Ninja raises `ConfigError` saying the pagination class is not configured for async requests. Whichever class is chosen, the parameter names and response keys become part of the public contract, so switching later is a breaking change for clients. ## Paginating a whole router `RouterPaginated` is a `Router` subclass that applies the default paginator to every operation whose response is a collection, which suits APIs where every list endpoint follows the same contract.

  • Why does a paginated Django Ninja list endpoint issue two queries per request?
    Limit-offset and page-number paginators return `count` alongside the items, so they run the sliced query for the page and a `count()` over the full queryset. `CursorPagination` skips the count: it fetches one extra row to know whether a next page exists and returns `next` and `previous` links instead.
  • What happens if you put @paginate above @api.get instead of below it?
    Decorators apply bottom-up, so `@api.get` would register the undecorated function first, and `@paginate` would wrap only the returned function object after registration. The registered operation would have no pagination parameters and would return the full list with the original schema.

saying these in an interview costs you the question

  • The view must slice the queryset itself before returning it.
  • LimitOffsetPagination caps limit at 100 by default.
  • @paginate works on operations whose response is a single object.
  • Paginating an async operation requires returning a list, not a queryset.
  • Pagination needs no ordering because Django sorts by primary key.