python 51 lines · 9 steps

Cursor pagination in a FastAPI endpoint

An articles endpoint pages through rows with opaque, base64-encoded cursors instead of numeric offsets.

Explained by highlit
1import base64
2import json
3from typing import Annotated, Optional
4 
5from fastapi import APIRouter, Depends, HTTPException, Query
6from pydantic import BaseModel
7from sqlalchemy import select
8from sqlalchemy.ext.asyncio import AsyncSession
9 
10from .database import get_session
11from .models import Article
12from .schemas import ArticleOut
13 
14router = APIRouter(prefix="/articles", tags=["articles"])
15 
16 
17def encode_cursor(article_id: int) -> str:
18 raw = json.dumps({"id": article_id}).encode()
19 return base64.urlsafe_b64encode(raw).decode()
20 
21 
22def decode_cursor(cursor: str) -> int:
23 try:
24 payload = json.loads(base64.urlsafe_b64decode(cursor.encode()))
25 return int(payload["id"])
26 except (ValueError, KeyError, json.JSONDecodeError):
27 raise HTTPException(status_code=400, detail="Invalid pagination cursor")
28 
29 
30class ArticlePage(BaseModel):
31 items: list[ArticleOut]
32 next_cursor: Optional[str] = None
33 
34 
35@router.get("", response_model=ArticlePage)
36async def list_articles(
37 session: Annotated[AsyncSession, Depends(get_session)],
38 cursor: Optional[str] = None,
39 limit: int = Query(20, ge=1, le=100),
40):
41 stmt = select(Article).order_by(Article.id.asc()).limit(limit + 1)
42 if cursor is not None:
43 stmt = stmt.where(Article.id > decode_cursor(cursor))
44 
45 rows = (await session.scalars(stmt)).all()
46 
47 has_more = len(rows) > limit
48 page = rows[:limit]
49 next_cursor = encode_cursor(page[-1].id) if has_more else None
50 
51 return ArticlePage(items=page, next_cursor=next_cursor)
01 / 01
STEP 01

Walkthrough

Space play step click any line
Three takeaways
  1. 1Cursor pagination keeps queries efficient at scale because it filters by a keyed boundary instead of skipping rows with OFFSET.
  2. 2Fetching limit+1 rows is a cheap trick to detect whether another page exists without a separate count query.
  3. 3Encoding the cursor keeps its shape opaque to clients, so you can change what it contains without breaking their code.

Related explainers

Share this explainer

Here's the card — post it anywhere.

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