Skip to content
AITroveRead. Build. Understand.
Make this comfortable

Django keyset pagination: stable ordering and a bounded next page

Last updated: 30 Sept 20264 min read
tutorial
IntermediateBy AITrove Editorial

Keyset pagination selects rows after an ordered cursor instead of repeatedly skipping an offset of earlier rows.

Download Python source kit

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

python
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

Output
[1, 2] 2
([3], None)
boolean cursor rejected

Costs 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.

python
django-pagination
Storage details