A keyset list uses the last seen ordered key to request records after that key.
Python Flask keyset list: validate a cursor before selecting the next page
Operation contract
The local Flask endpoint accepts an optional ASCII decimal after cursor, caps its length and returns at most two records from an owned sorted list. The first response gives IDs 41 and 42 and cursor 42; a second call gives ID 43. Invalid cursor text returns 400 rather than changing the query into a broad scan. The test client makes these requests without opening a network listener.
Failure and ownership boundary
A cursor is not authorization, and a numeric ID can reveal ordering or cardinality. Real list handlers must filter by tenant and permissions before limiting, and persistent writes need stable key ordering and an index. Django keyset pagination: stable ordering and a bounded next page, Django REST Framework list visibility: filter the principal’s rows before paging and Flask routing: application factories, converters and test clients cover those boundaries.
Tested environment
Dependency check: this program was executed on CPython 3.14.6 with Flask==3.1.3. 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 flask import Flask, jsonify, request
application = Flask(__name__)
receipts = [(41, 125), (42, 75), (43, 200)]
@application.get("/receipts")
def list_receipts():
raw = request.args.get("after", "0")
if not 1 <= len(raw) <= 6 or any(char not in "0123456789" for char in raw):
return jsonify(error="invalid cursor"), 400
selected = [(identifier, amount) for identifier, amount in receipts if identifier > int(raw)][:2]
return jsonify(ids=[identifier for identifier, _ in selected],
next=selected[-1][0] if selected else None)
with application.test_client() as client:
first = client.get("/receipts").get_json()
second = client.get("/receipts?after=42").get_json()
print(first["ids"], first["next"])
print(second["ids"], second["next"])
print("bad cursor:", client.get("/receipts?after=+1").status_code)Output
[41, 42] 42
[43] 43
bad cursor: 400Costs and limits
The fixture scans all three in-memory records then slices two, so it is O(n) per call. A database implementation should use a tenant-scoped index range query and LIMIT. Cursor validation here does not prove identity, persistence or concurrent-page stability.
Common Mistakes
- Apply authorization filters before the page limit.
- An in-memory scan does not model indexed database paging cost.
Connected lessons
Django keyset pagination: stable ordering and a bounded next page, Django REST Framework list visibility: filter the principal’s rows before paging, Flask routing: application factories, converters and test clients.
