skip to content

In Django REST Framework, how do you change the paginated response envelope with get_paginated_response(), and how do you paginate inside a plain APIView?

level: middleimportance: should knowfreq 36%

answer

  1. one method builds the body
  2. the schema method should match
  3. three calls outside generic views
  4. a None page means no pagination

basics

~10 s

Subclass a pagination class and override get_paginated_response(data) to return any Response, such as bare results with Link headers, plus get_paginated_response_schema() for OpenAPI. In an APIView, call paginate_queryset(), serialise the page, then call get_paginated_response().

solid answer

~40 s

Every DRF pagination class ends with `get_paginated_response(data)`, which receives the serialised page and returns a `Response`. The built-ins return `{count, next, previous, results}` (cursor: no `count`), but a subclass can return anything — for example the bare list with `Link` and `X-Total-Count` headers built from `self.get_next_link()`, `self.get_previous_link()` and `self.page.paginator.count`. Override `get_paginated_response_schema()` too, or the generated OpenAPI schema describes an envelope you no longer send. Outside generic views nothing is automatic: an `APIView` instantiates the class, calls `paginate_queryset(queryset, request, view=self)`, serialises the returned list with `many=True`, and returns `paginator.get_paginated_response(serializer.data)`. `paginate_queryset()` returns `None` when no page size is configured, so handle that or set `page_size` on the class.

code

python · 19 lines
python
from rest_framework.pagination import CursorPagination
from rest_framework.views import APIView

from .models import Activity
from .serializers import ActivitySerializer


class FeedCursorPagination(CursorPagination):
    page_size = 30
    ordering = ("-created", "-id")


class ActivityFeedView(APIView):
    def get(self, request):
        queryset = Activity.objects.filter(recipient=request.user)
        paginator = FeedCursorPagination()
        page = paginator.paginate_queryset(queryset, request, view=self)
        serializer = ActivitySerializer(page, many=True)
        return paginator.get_paginated_response(serializer.data)

go deeper

for a junior

Know that get_paginated_response() builds the paginated body and that APIView needs manual pagination calls.

for a middle

Explain the split between paginate_queryset() and get_paginated_response(), the None case, and which state each class keeps for its links.

for a senior

Change the envelope without breaking generated clients by overriding the schema method, and make the custom style the project default.

for a principal

Weigh body envelopes against Link headers for the API's clients and caches, and fix one convention for every list endpoint.

## The two halves of a DRF pagination class Every pagination class in Django REST Framework (DRF) implements two methods from `BasePagination`: 1. `paginate_queryset(queryset, request, view=None)` — reads the query string, slices the queryset and returns a **list** of objects for this page, or `None` when pagination is not configured (for example, no page size). 2. `get_paginated_response(data)` — receives the **serialised** page and returns the `Response` sent to the client. Generic views glue them together in `ListModelMixin.list()`. Everything about the **wire format** lives in the second method, so changing the envelope never requires touching slicing logic. ## What the built-ins return | Class | Body keys | |---|---| | `PageNumberPagination` | `count`, `next`, `previous`, `results` | | `LimitOffsetPagination` | `count`, `next`, `previous`, `results` | | `CursorPagination` | `next`, `previous`, `results` | The link helpers `get_next_link()` and `get_previous_link()` are public methods on each class and return absolute URLs or `None`, so an override can reuse them. ## Overriding the envelope Common reasons to override `get_paginated_response()`: - return a **bare list** and move navigation into an RFC 8288 `Link` header; - add fields such as `total_pages` or `page_size`; - rename keys to match an existing client contract. ```python from rest_framework.pagination import PageNumberPagination from rest_framework.response import Response class LinkHeaderPagination(PageNumberPagination): page_size = 50 def get_paginated_response(self, data): links = [] if (url := self.get_next_link()) is not None: links.append(f'<{url}>; rel="next"') if (url := self.get_previous_link()) is not None: links.append(f'<{url}>; rel="prev"') headers = {"X-Total-Count": str(self.page.paginator.count)} if links: headers["Link"] = ", ".join(links) return Response(data, headers=headers) def get_paginated_response_schema(self, schema): return schema ``` `self.page` is set by `paginate_queryset()` in `PageNumberPagination`; `LimitOffsetPagination` keeps `self.count`, `self.limit` and `self.offset` instead, so an override must use the attributes of the class it extends. ## Keep the schema honest `get_paginated_response_schema(schema)` wraps the list schema for DRF's OpenAPI generator. The built-ins describe their own envelope; after changing the body you should override it as well — returning `schema` unchanged for a bare list — or generated clients will expect keys that no longer exist. ## Paginating inside an `APIView` `APIView` has no `pagination_class` handling, so `DEFAULT_PAGINATION_CLASS` does nothing there. The manual pattern is three calls: 1. create the paginator: `paginator = FeedCursorPagination()`; 2. `page = paginator.paginate_queryset(queryset, request, view=self)`; 3. serialise `page` with `many=True` and `return paginator.get_paginated_response(serializer.data)`. Passing `view=self` matters for `CursorPagination`, which looks at the view's filter backends when choosing the ordering. If `page` is `None`, serialise the full queryset and return a plain `Response`, exactly as `ListModelMixin.list()` does. ## Pitfalls - Calling `get_paginated_response()` before `paginate_queryset()` fails, because the link helpers depend on state that method sets. - Serialising the queryset instead of `page` silently returns every row inside a paginated envelope. - A custom envelope used by only some endpoints fragments the contract; set it as `DEFAULT_PAGINATION_CLASS` if it is the house style.

  • In DRF, why pass view=self to paginate_queryset() from an APIView?
    Some classes consult the view. `CursorPagination.get_ordering()` inspects `view.filter_backends` for an ordering filter; without the view it cannot, and relies on its own `ordering` only. Passing `view=self` keeps behaviour identical to a generic view and costs nothing.
  • Where would you put a total_pages field in a DRF PageNumberPagination response?
    Override `get_paginated_response()` and add `self.page.paginator.num_pages` alongside `count`, `next`, `previous` and `results`. Update `get_paginated_response_schema()` to add the integer property so the OpenAPI schema matches the body.

saying these in an interview costs you the question

  • Changing the envelope requires overriding paginate_queryset().
  • An APIView paginates automatically once DEFAULT_PAGINATION_CLASS is set.
  • get_paginated_response() receives the queryset rather than serialised data.
  • The OpenAPI schema updates itself when get_paginated_response() changes.
  • paginate_queryset() always returns a page, never None.