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
‹ swipe to step through ›
Walkthrough
Space play
←→ step
click any line
Three takeaways
- 1Keyset pagination stays correct and fast even as rows are inserted, unlike offset-based paging.
- 2Encoding the cursor as opaque base64 lets you evolve its internals without breaking clients.
- 3Fetching limit + 1 rows is a cheap trick to know whether another page exists.
Related explainers
python
from fastapi import FastAPI, WebSocket, WebSocketDisconnect app = FastAPI()
Building a WebSocket chat with FastAPI
websockets
broadcast
connection-management
Intermediate
9 steps
python
import time import uuid from django.utils.deprecation import MiddlewareMixin
Attaching per-request context in Django
middleware
request lifecycle
multi-tenancy
Intermediate
7 steps
python
import random from typing import Iterator, List
How reservoir sampling picks k items
reservoir-sampling
streaming
randomness
Intermediate
5 steps
python
import secrets from django.contrib.auth import authenticate, login from django.core.cache import cache
Two-factor login with OTP in Django
two-factor-auth
one-time-passwords
caching
Intermediate
9 steps
python
import re from functools import total_ordering from typing import Optional
Parsing and comparing semantic versions
regex
operator-overloading
sorting
Intermediate
7 steps
python
from typing import Any, Sequence, Mapping def render_markdown_table(
Rendering an aligned Markdown table in Python
string formatting
data transformation
closures
Intermediate
8 steps
Share this explainer
Here's the card — post it anywhere.
Made with highlit — turn any snippet into a walkthrough like this in about a minute.
Explain your code
Embed this explainer
Drop the interactive walkthrough into a blog or docs. Views never cost a credit.
<iframe src="https://highlit.co/explainers/cursor-pagination-in-a-flask-api-explained-python-9278/embed?autoplay=1" width="100%" height="520" loading="lazy" style="border:0"></iframe>
Autoplay is on by default — add ?autoplay=0 to start paused.