python 63 lines · 9 steps

Cursor pagination in a Flask API

A Flask blueprint paginates articles with an opaque, keyset-based cursor instead of fragile offset math.

Explained by highlit
1import base64
2import json
3from flask import Blueprint, request, jsonify, url_for, abort
4from sqlalchemy import and_, or_
5from app.models import Article
6from app.extensions import db
7 
8bp = Blueprint("articles", __name__, url_prefix="/api/articles")
9 
10DEFAULT_LIMIT = 20
11MAX_LIMIT = 100
12 
13 
14def encode_cursor(article):
15 payload = {"created_at": article.created_at.isoformat(), "id": article.id}
16 raw = json.dumps(payload, separators=(",", ":")).encode()
17 return base64.urlsafe_b64encode(raw).decode()
18 
19 
20def decode_cursor(token):
21 try:
22 raw = base64.urlsafe_b64decode(token.encode())
23 payload = json.loads(raw)
24 return payload["created_at"], int(payload["id"])
25 except (ValueError, KeyError, TypeError):
26 abort(400, description="Invalid cursor")
27 
28 
29@bp.get("")
30def list_articles():
31 limit = min(request.args.get("limit", DEFAULT_LIMIT, type=int), MAX_LIMIT)
32 cursor = request.args.get("cursor")
33 
34 query = Article.query.filter_by(published=True).order_by(
35 Article.created_at.desc(), Article.id.desc()
36 )
37 
38 if cursor:
39 created_at, last_id = decode_cursor(cursor)
40 query = query.filter(
41 or_(
42 Article.created_at < created_at,
43 and_(Article.created_at == created_at, Article.id < last_id),
44 )
45 )
46 
47 rows = query.limit(limit + 1).all()
48 has_more = len(rows) > limit
49 items = rows[:limit]
50 
51 next_url = None
52 if has_more:
53 next_url = url_for(
54 "articles.list_articles",
55 limit=limit,
56 cursor=encode_cursor(items[-1]),
57 _external=True,
58 )
59 
60 return jsonify(
61 data=[a.to_dict() for a in items],
62 paging={"next": next_url, "has_more": has_more},
63 )
01 / 01
STEP 01

Walkthrough

Space play step click any line
Three takeaways
  1. 1Keyset pagination stays correct and fast even as rows are inserted, unlike offset-based paging.
  2. 2Encoding the cursor as opaque base64 lets you evolve its internals without breaking clients.
  3. 3Fetching limit + 1 rows is a cheap trick to know whether another page exists.

Related explainers

Share this explainer

Here's the card — post it anywhere.

Cursor pagination in a Flask API — share card
Made with highlit — turn any snippet into a walkthrough like this in about a minute.
Explain your code