Keyset pagination selects rows after an ordered cursor instead of repeatedly skipping an offset of earlier rows.
Django keyset pagination: stable ordering and a bounded next page
Operation contract
The receipt cursor is a positive primary-key position under ascending ID order. Each query fetches at most the page size plus one row; the extra row indicates whether a next page existed at query time. Returned pages own their ID lists. Boolean cursors and excessive page sizes fail before querying.
Failure and ownership boundary
An ID cursor is not a snapshot across requests. Concurrent inserts or deletes can change later results, and a user-visible API needs authorization plus an opaque or validated cursor contract. Ordering by a nonunique timestamp would require a tie-breaker. Django select_related: measure the foreign-key N plus one query pattern and Django atomic transactions: rollback the batch and defer callbacks help distinguish bounded retrieval from consistency guarantees.
Tested environment
Dependency check: this program was executed on CPython 3.14.6 with Django==5.2.17. Install these versions in a separate virtual environment. The download includes the recorded environment snapshot; no third-party package is part of the website runtime.
Working program
from django.conf import settings
settings.configure(INSTALLED_APPS=[], DATABASES={"default": {"ENGINE": "django.db.backends.sqlite3", "NAME": ":memory:"}},
DEFAULT_AUTO_FIELD="django.db.models.AutoField", USE_TZ=True)
import django
django.setup()
from django.db import connection, models, IntegrityError, transaction
class DispatchPartner(models.Model):
region = models.CharField(max_length=3, unique=True)
class Meta:
app_label = "receipt_fixture"
class StoredReceipt(models.Model):
receipt_id = models.CharField(max_length=6, unique=True)
partner = models.ForeignKey(DispatchPartner, on_delete=models.PROTECT)
amount = models.IntegerField()
class Meta:
app_label = "receipt_fixture"
constraints = [models.CheckConstraint(condition=models.Q(amount__gte=0), name="receipt_amount_nonnegative")]
with connection.schema_editor() as schema:
schema.create_model(DispatchPartner)
schema.create_model(StoredReceipt)
def receipt_page(after_id=0, size=2):
if type(after_id) is not int or after_id < 0 or type(size) is not int or not 1 <= size <= 20:
raise ValueError("invalid page request")
selected = list(StoredReceipt.objects.filter(id__gt=after_id).order_by("id").values_list("id", flat=True)[:size + 1])
page = selected[:size]
cursor = page[-1] if len(selected) > size else None
return page, cursor
partner = DispatchPartner.objects.create(region="DEL")
for number in (41, 42, 43):
StoredReceipt.objects.create(receipt_id=f"R-{number:04d}", partner=partner, amount=125)
first, cursor = receipt_page()
print(first, cursor)
print(receipt_page(cursor))
try:
receipt_page(True)
except ValueError:
print("boolean cursor rejected")
connection.close()Output
[1, 2] 2
([3], None)
boolean cursor rejectedCosts and limits
The fixture fetches at most size plus one values. With a suitable ordered index, keyset retrieval avoids offset scanning; actual database work still depends on plans, filters and backend. It provides no stable total-count claim.
Common Mistakes
- Always define deterministic ordering and a tie-breaker when needed.
- A cursor across separate requests is not a transaction snapshot.
Connected lessons
Django select_related: measure the foreign-key N plus one query pattern, Django atomic transactions: rollback the batch and defer callbacks, Python binary search: use a half-open interval and require sorted input.
Trace the related workflow
Python Flask keyset list: validate a cursor before selecting the next page.
