In Django REST Framework, why does CursorPagination need a stable, unique ordering, and how would you configure it for an infinite-scroll activity feed?
answer
- a position, not a page number
- the default ordering is a field name
- only the first ordering field is encoded
- ties fall back to a capped offset
basics
~20 sCursorPagination encodes the value of the first ordering field and fetches rows beyond it, so that field must be unchanging, indexed, non-null and nearly unique. For a feed, order by '-created' with a unique tiebreaker.
solid answer
~50 s`CursorPagination` does not count pages. Its opaque `?cursor=` token carries the **position** — the string value of the first field in `ordering` for the last item served — plus a small offset and a direction, and the next page is fetched with a `__lt` or `__gt` filter on that field. That is why the field must be **unchanging** (an edited value moves the row and causes skips or repeats), **indexed**, **non-null** and **unique or nearly unique**: rows sharing the boundary value are handled by the offset, which is capped by `offset_cutoff = 1000`. The default `ordering` is `"-created"`, so a model without a `created` field fails; orderings with `__` lookups are rejected. For an activity feed, subclass with `page_size` and `ordering = ("-created", "-id")`. New activities inserted above don't shift the cursor, and the response has `next`, `previous` and `results` but no `count`.
code
python · 18 linesfrom rest_framework import generics
from rest_framework.pagination import CursorPagination
from .models import Activity
from .serializers import ActivitySerializer
class FeedCursorPagination(CursorPagination):
page_size = 30
ordering = ("-created", "-id")
class ActivityFeed(generics.ListAPIView):
serializer_class = ActivitySerializer
pagination_class = FeedCursorPagination
def get_queryset(self):
return Activity.objects.filter(recipient=self.request.user)go deeper
Know that CursorPagination uses next and previous links instead of page numbers and orders by '-created' unless told otherwise.
Explain the position, offset and reverse parts of the cursor, why the first ordering field must be stable and nearly unique, and the offset cap.
Pick and index the ordering field for a feed, restrict client-chosen orderings on cursor endpoints, and treat the unsigned cursor as untrusted input.
Decide which endpoints trade counts and random access for stable, constant-cost paging, and make that visible in the API contract.
## What a DRF cursor contains `CursorPagination` in Django REST Framework (DRF) replaces page numbers with an opaque `cursor` query parameter. Decoded, the token is a base64-encoded query string with up to three parts: - `p` — the **position**: the string value of the first `ordering` field on the boundary item; - `o` — an **offset** used when several rows share that value; - `r` — whether the client is paging **in reverse** (following a `previous` link). To build the next page, DRF orders the queryset by `ordering`, filters with `<field>__lt=position` or `<field>__gt=position` (depending on direction and sign), skips `offset` rows, and fetches `page_size + 1` rows — the extra one tells it whether a next page exists. A token that fails to decode raises `NotFound` with "Invalid cursor". The token is encoded, **not signed**: clients can read and forge it, so treat its contents as untrusted input. ## Why the ordering must be stable and unique The DRF pagination guide asks for an ordering field that is: | Requirement | What breaks without it | |---|---| | **unchanging** (set once, on creation) | an edited value moves the row across the cursor, so it is skipped or served twice | | **unique or nearly unique** | many rows share the boundary value and DRF falls back on the offset | | **non-null, string-coercible** | the position is stored as a string and compared in a filter | | **not a float** | precision errors in the round trip produce wrong boundaries | | **indexed** | the `__lt`/`__gt` filter plus `ORDER BY` becomes a scan | Two implementation details sharpen this: 1. **Only `ordering[0]` becomes the position.** Extra fields in `ordering` affect the SQL `ORDER BY`, which makes the order among ties deterministic, but they are not encoded in the cursor. 2. **The tie offset is capped.** `offset_cutoff = 1000` limits the offset DRF will honour, protecting the database from huge skips. A boundary value shared by more than a thousand rows cannot be paged through correctly. DRF also asserts that `ordering` is set and contains no `__` lookups. If the view uses a filter backend with `get_ordering()` — such as `OrderingFilter` — the cursor adopts the client-chosen ordering, which is why client-selectable orderings on a cursor endpoint must be limited to suitable fields. ## Configuring an infinite-scroll feed An activity feed shows newest first, keeps growing at the top while the user scrolls, and never needs "jump to page 40". That is the case cursor pagination is built for: ```python from rest_framework.pagination import CursorPagination class FeedCursorPagination(CursorPagination): page_size = 30 ordering = ("-created", "-id") ``` - `-created` is set once, indexed, and nearly unique at fine timestamp resolution; `-id` fixes the order among exact ties. - New activities have larger `created` values, so they sit above the position and never shift the next page. - The client stores `next` and requests it when the user nears the bottom; the feed ends when `next` is `null`. - To let clients choose the batch size, set `page_size_query_param` and a `max_page_size`. ## What you give up - **No `count`.** The response body is `{"next", "previous", "results"}` only. - **No random access.** There is no page 7; clients can only follow links. - **Fixed ordering.** Sorting by an arbitrary column requires that column to satisfy the table above. ## Common mistakes - Leaving the default `ordering = "-created"` on a model whose timestamp is called something else. - Ordering by `updated`, a status or a name — all mutable or heavily duplicated. - Ordering through a relation (`"author__name"`), which DRF rejects outright.
- What goes wrong if a DRF CursorPagination feed orders by '-updated'?The cursor stores the boundary item's `updated` value. When a row is edited while a user is scrolling, its `updated` jumps past the cursor: it vanishes from pages not yet seen or reappears at the top, and other rows can shift. The ordering field must be set once and never change.
- Why can't a DRF CursorPagination class order by 'author__name'?`get_ordering()` asserts that the ordering contains no double-underscore lookups. The position is read from an attribute of each result and filtered back with `__lt`/`__gt`, which the implementation supports only for a field on the model itself. Denormalise the value onto the model if you truly need that order.
A cursor is a bookmark slipped in after the last story you read, not a page number: if new stories are pinned to the front of the magazine, 'page 3' changes, but the bookmark still marks the same story.
saying these in an interview costs you the question
- CursorPagination works with any ordering, like page numbers do.
- Every field in ordering is encoded in the DRF cursor.
- The DRF cursor is signed, so clients cannot tamper with it.
- CursorPagination responses include a total count.
- Ordering by updated time is ideal for an activity feed.
- Rows inserted during scrolling shift a DRF cursor feed like page numbers.