python 104 lines · 3 tabs

Cursor-Paginated JSON Feed with a Django Class-Based ListView

Shared by codesnips Aug 2026
3 tabs
import base64
import json

from django.core.exceptions import BadRequest


class CursorPaginator:
    def __init__(self, page_size=20):
        self.page_size = page_size

    def encode(self, obj):
        payload = json.dumps(
            {"c": obj.created_at.isoformat(), "id": obj.id},
            separators=(",", ":"),
        ).encode("utf-8")
        return base64.urlsafe_b64encode(payload).decode("ascii")

    def decode(self, cursor):
        try:
            raw = base64.urlsafe_b64decode(cursor.encode("ascii"))
            data = json.loads(raw)
            return data["c"], int(data["id"])
        except (ValueError, KeyError, TypeError):
            raise BadRequest("Malformed cursor")

    def paginate(self, queryset, cursor=None):
        qs = queryset.order_by("-created_at", "-id")
        if cursor:
            c_created, c_id = self.decode(cursor)
            qs = qs.filter(
                Q(created_at__lt=c_created)
                | Q(created_at=c_created, id__lt=c_id)
            )

        rows = list(qs[: self.page_size + 1])
        has_more = len(rows) > self.page_size
        page = rows[: self.page_size]
        next_cursor = self.encode(page[-1]) if has_more and page else None
        return page, next_cursor, has_more


from django.db.models import Q  # noqa: E402
3 files · python Explain with highlit

Offset pagination (LIMIT ... OFFSET ...) degrades badly on large tables: the database still walks and discards every skipped row, and rows shifting under an active reader cause duplicates and gaps. This snippet shows keyset (cursor) pagination instead, where each page is anchored to the last row seen, giving stable results and consistent performance no matter how deep a client scrolls.

The CursorPaginator tab encapsulates the core technique. It orders strictly by (-created_at, -id) so the compound key is unique and total, then encodes the last row's values into an opaque, URL-safe token via encode (a base64'd JSON pair). paginate decodes an incoming cursor and applies a keyset filter: because ordering is descending, it asks for rows that are created_at < c_created OR equal on created_at but with a smaller id. That tie-breaker on id is what makes duplicate created_at timestamps safe. It deliberately fetches page_size + 1 rows so it can detect whether a further page exists without a second COUNT query, slicing the extra row off before building the next cursor.

The PostFeedView tab wires this into Django's class-based ListView. The get_queryset narrows to published posts and uses only(...) to fetch just the columns the feed serializes. Rather than relying on the framework's built-in Paginator, it overrides render_to_response to hand the queryset to CursorPaginator, serialize each row with serialize_post, and return a JsonResponse. The response envelope carries results plus a next_cursor and a has_more flag, which is exactly the shape a JSON client or infinite-scroll UI consumes. BadRequest from a malformed cursor is translated into an HTTP 400 rather than a 500.

The urls.py tab mounts the view and shows the intended request contract: an initial GET /feed/ with no cursor, then subsequent requests passing ?cursor=<token>&limit=<n>.

Trade-offs worth noting: keyset pagination cannot jump to an arbitrary page number and needs a stable, indexed ordering key — here a composite index on (created_at, id) is assumed. Its payoff is O(page-size) reads at any depth and immunity to insert/delete churn, which is why feeds, activity streams, and APIs prefer it over offsets.


Related snips

Share this code

Here's the card — post it anywhere.

Cursor-Paginated JSON Feed with a Django Class-Based ListView — share card
Link copied