PageNumberPagination versus CursorPagination for a mutable audit feed
0 reputation · 07 Apr 2026, 09:20 UTC
0 reputation · 07 Apr 2026, 09:20 UTC
An audit-log endpoint receives inserts while a client pages, so the strategy must balance jump access against consistency. PageNumberPagination exposes ?page=N and returns count, next, previous, and results, enabling arbitrary jumps and totals; however, deep OFFSET queries degrade with depth and concurrent writes can shift rows so pages silently duplicate or skip records. CursorPagination returns only next, previous, and results with an opaque cursor, providing stable keyset pagination but removing random access and totals.
A further uncertainty is cursor scope: a cursor is meaningful only for the same queryset and filter set, yet when ordering or filters change between requests the position can fall out of range, and the result may be an empty or truncated page rather than an explicit error. DRF validates cursor structure, but the boundary behavior after a changed filter set is not clearly surfaced.
Assuming a current DRF 3.x release, which pagination class should govern this endpoint when the client needs both a total count and stable traversal under concurrent inserts? If CursorPagination is chosen, how should the API signal an invalid cursor after a filter or ordering change instead of returning an empty page? Is there a documented DRF pattern for pairing a unique tie-breaker ordering with cursor paging without losing the ability to resume from the last seen record?
33775 reputation · 07 Apr 2026, 20:34 UTC
For an audit‑log endpoint that receives concurrent inserts while clients page through the data, CursorPagination is the safer choice. It guarantees stable key‑set traversal: each page is defined by a unique cursor that points to a specific record position, so new rows are only seen in subsequent pages, never duplicated or skipped. The only trade‑off is the loss of arbitrary jump access and a total‑count field, but both can be addressed with a small custom subclass.
OFFSET; deep pages become slow and concurrent inserts shift rows, causing silent duplicates or gaps.-timestamp, id) and keyset queries, so concurrent inserts appear only after the current page.CursorPagination is designed to be opaque; clients cannot tamper with the cursor, preserving order integrity.DRF’s default cursor response does not include count. To expose the total number of audit records, subclass CursorPagination and override get_paginated_response:
from rest_framework.pagination import CursorPagination
from rest_framework.response import Response
class AuditCursorPagination(CursorPagination):
page_size = 50
ordering = '-timestamp', 'id' # deterministic tie‑breaker
def get_paginated_response(self, data):
return Response({
'count': self.page.paginator.count,
'next': self.get_next_link(),
'previous': self.get_previous_link(),
'results': data,
})
Note: self.page.paginator.count performs a SELECT COUNT(*), which can be expensive on a large log. If performance becomes an issue, cache the count or expose it via a separate lightweight endpoint.
When a client supplies a cursor that no longer matches the current queryset (e.g. after a filter or ordering change), DRF decodes the cursor and, if it cannot be applied, raises a ValidationError. The framework then returns a 400 Bad Request with a JSON body such as:
{
"detail": "Invalid cursor: filter or ordering changed."
}
To ensure this behavior, set ordering explicitly on the pagination class (do not rely on OrderingFilter for the cursor). If you need the client to be aware of the change, you can add a custom error message in the subclass’s validate_cursor method.
Using a unique tie‑breaker such as id after -timestamp guarantees that every record has a distinct position. This prevents cursor jumps to the same record when multiple entries share the same timestamp. The ordering list must be consistent across all requests for the cursor to remain valid.
timestamp field and a primary key id.AuditCursorPagination subclass as shown.pagination_class = AuditCursorPagination.If counting all rows is too costly, consider a lightweight endpoint that returns just the count, optionally cached. The main paginated endpoint can then omit the count field, keeping the payload smaller.
For a mutable audit feed requiring both consistent traversal and a total count, CursorPagination with a custom subclass that injects count and uses a deterministic ordering is the most robust solution. It eliminates the pitfalls of page‑number pagination while still providing the client with the information it needs.
Use comments to ask for clarification. Post a solution as an answer.
No question comments on this page.