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
from django.http import JsonResponse
from django.core.exceptions import BadRequest
from django.views.generic import ListView
from .models import Post
from .pagination import CursorPaginator
def serialize_post(post):
return {
"id": post.id,
"title": post.title,
"slug": post.slug,
"created_at": post.created_at.isoformat(),
}
class PostFeedView(ListView):
model = Post
DEFAULT_LIMIT = 20
MAX_LIMIT = 100
def get_queryset(self):
return (
Post.objects.filter(status=Post.Status.PUBLISHED)
.only("id", "title", "slug", "created_at")
)
def _page_size(self):
try:
limit = int(self.request.GET.get("limit", self.DEFAULT_LIMIT))
except (TypeError, ValueError):
limit = self.DEFAULT_LIMIT
return max(1, min(limit, self.MAX_LIMIT))
def render_to_response(self, context, **kwargs):
paginator = CursorPaginator(page_size=self._page_size())
cursor = self.request.GET.get("cursor")
try:
page, next_cursor, has_more = paginator.paginate(
self.get_queryset(), cursor=cursor
)
except BadRequest as exc:
return JsonResponse({"error": str(exc)}, status=400)
return JsonResponse(
{
"results": [serialize_post(p) for p in page],
"next_cursor": next_cursor,
"has_more": has_more,
}
)
from django.urls import path
from .views import PostFeedView
# GET /feed/ -> first page
# GET /feed/?cursor=<token>&limit=50 -> subsequent pages
urlpatterns = [
path("feed/", PostFeedView.as_view(), name="post-feed"),
]
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
class PostsController < ApplicationController
def index
@posts = Post.includes(:author)
.order(created_at: :desc)
.page(params[:page])
.per(10)
Turbo Frames: infinite scroll with lazy-loading frame
class Product(models.Model):
name = models.CharField(max_length=200)
slug = models.SlugField(blank=True)
price = models.DecimalField(max_digits=10, decimal_places=2)
cost = models.DecimalField(max_digits=10, decimal_places=2)
margin = models.DecimalField(max_digits=5, decimal_places=2, blank=True)
Django model signals vs overriding save
package dbutil
import (
"context"
"github.com/jackc/pgconn"
Retry Postgres serialization failures with bounded attempts
require "csv"
class PeopleCsvStream
include Enumerable
HEADERS = %w[id full_name email signed_up_at plan].freeze
Resilient CSV Export as a Streamed Response
from django.urls import path
from . import views
app_name = 'blog'
urlpatterns = [
Django URL namespacing and reverse lookups
import graphene
from graphene_django import DjangoObjectType
from blog.models import Post, Comment
class PostType(DjangoObjectType):
Django GraphQL with Graphene
Share this code
Here's the card — post it anywhere.