Text size
Dark mode
RecallRunGuides & benchmarks
Learn to build real systemsGuides•Benchmarks•Experiments•Community
Search the site/Free checklist

Guides/FastAPI

FastAPI: Pagination, concurrency & migrations

Four patterns show up in every serious API. Cursor pagination stays fast and consistent on huge tables. Safe filtering and sorting never puts user input into SQL.

In this post

What this post covers
  1. 1. Offset vs cursor pagination
  2. 2. Filtering and sorting without SQL injection
  3. 3. Optimistic concurrency with ETags
  4. 4. Idempotency keys: safe retries for POST
  5. 5. Schema migrations with Alembic
  6. Recap

Four patterns show up in every serious API. Cursor pagination stays fast and consistent on huge tables. Safe filtering and sorting never puts user input into SQL. Optimistic concurrency with ETags stops two users silently overwriting each other. Idempotency keys make “retry the payment” safe. Around them sits the discipline of schema migrations that do not take the service down. Each is shown running here, except Alembic, which is described and not run because it is not installed on this machine.

🔖

A bookmark, a version sticker, and a receipt number

A cursor is a bookmark: "continue after this line", instead of "skip 90,000 lines and count again". An ETag is a version sticker on a document: if the sticker changed since you read it, someone else edited it, so re-read before you overwrite. An idempotency key is a receipt number: hand the same receipt in twice, and the cashier gives you the same change, not a second purchase.

1. Offset vs cursor pagination

the cost of OFFSET on a deep page vs a keyset cursor (200,000 rows, SQLite)

import sqlite3
import time

db = sqlite3.connect(":memory:")
db.execute("CREATE TABLE orders (id INTEGER PRIMARY KEY, created_at TEXT, total REAL)")
db.execute("CREATE INDEX ix_created ON orders (created_at, id)")
db.executemany("INSERT INTO orders (created_at, total) VALUES (?, ?)",
               ((f"2026-{1 + i // 20000:02d}-{1 + (i // 700) % 28:02d}T{i % 24:02d}:00", i % 997) for i in range(200_000)))

def timed(sql, params=()):
    start = time.perf_counter()
    rows = db.execute(sql, params).fetchall()
    return rows, (time.perf_counter() - start) * 1000

page_size = 20
for page in (1, 100, 9_000):
    _, ms = timed("SELECT id, created_at FROM orders ORDER BY created_at, id LIMIT ? OFFSET ?",
                  (page_size, (page - 1) * page_size))
    print(f"OFFSET page {page:>5}: {ms:7.2f} ms")

# keyset: remember the last row of the previous page and continue AFTER it
last = db.execute("SELECT created_at, id FROM orders ORDER BY created_at, id LIMIT 1 OFFSET 179999").fetchone()
rows, ms = timed("SELECT id, created_at FROM orders WHERE (created_at, id) > (?, ?) ORDER BY created_at, id LIMIT ?",
                 (*last, page_size))
print(f"cursor, same depth: {ms:7.2f} ms  (first row id {rows[0][0]})")

Output

OFFSET page     1:    0.16 ms
OFFSET page   100:    0.06 ms
OFFSET page  9000:    2.28 ms
cursor, same depth:    0.24 ms  (first row id 196009)

Even on a small in-memory table, the deep page costs about 15 times the cursor. On disk-backed tables with wide rows and real load, deep offsets grow into hundreds of milliseconds or seconds. OFFSET 179980 makes the database walk and discard 179,980 rows on every request, and rows inserted meanwhile shift pages, so users see duplicates or miss items. A keyset cursor seeks straight to “after (created_at, id)” using the index. It costs the same at any depth and is stable while data changes. Always include a unique tie-breaker (the id) in both the ORDER BY and the cursor.

a cursor-paginated endpoint with an opaque cursor

import base64
import json
import sqlite3
from typing import Annotated

from fastapi import FastAPI, HTTPException, Query, Request, Response
from fastapi.testclient import TestClient
from pydantic import BaseModel

db = sqlite3.connect(":memory:", check_same_thread=False)
db.execute("CREATE TABLE products (id INTEGER PRIMARY KEY, name TEXT, price REAL)")
db.executemany("INSERT INTO products (name, price) VALUES (?, ?)", [(f"Product {i:03d}", (i * 37) % 500) for i in range(1, 58)])

def encode(price, pid):
    return base64.urlsafe_b64encode(json.dumps([price, pid]).encode()).decode()

def decode(cursor):
    try:
        price, pid = json.loads(base64.urlsafe_b64decode(cursor))
        return float(price), int(pid)
    except Exception:
        raise HTTPException(400, "invalid cursor") from None

class Product(BaseModel):
    id: int
    name: str
    price: float

class Page(BaseModel):
    items: list[Product]
    next_cursor: str | None

app = FastAPI()

@app.get("/products")
def list_products(request: Request, response: Response,
                  limit: Annotated[int, Query(ge=1, le=50)] = 20, cursor: str | None = None) -> Page:
    after = decode(cursor) if cursor else (-1.0, 0)
    rows = db.execute("SELECT id, name, price FROM products WHERE (price, id) > (?, ?) ORDER BY price, id LIMIT ?",
                      (*after, limit + 1)).fetchall()          # fetch one extra to know if there is a next page
    items = [Product(id=r[0], name=r[1], price=r[2]) for r in rows[:limit]]
    next_cursor = encode(items[-1].price, items[-1].id) if len(rows) > limit else None
    if next_cursor:
        response.headers["Link"] = f'<{request.url.include_query_params(cursor=next_cursor)}>; rel="next"'
    return Page(items=items, next_cursor=next_cursor)

client = TestClient(app)
cursor, pages, seen = None, 0, []
while True:
    r = client.get("/products", params={"limit": 25, **({"cursor": cursor} if cursor else {})}).json()
    pages += 1
    seen += [p["id"] for p in r["items"]]
    cursor = r["next_cursor"]
    if not cursor:
        break
print(f"{pages} pages, {len(seen)} products, all unique: {len(set(seen)) == len(seen)}")
print("tampered cursor:", client.get("/products", params={"cursor": "not-base64!"}).status_code)

Output

3 pages, 57 products, all unique: True
tampered cursor: 400

2. Filtering and sorting without SQL injection

ORDER BY from a whitelist, never from the request string

from typing import Annotated, Literal

from fastapi import FastAPI, Query
from fastapi.testclient import TestClient

SORTS = {"price": "price", "-price": "price DESC", "name": "name", "newest": "created_at DESC"}

app = FastAPI()

@app.get("/search")
def search(sort: Annotated[Literal["price", "-price", "name", "newest"], Query()] = "newest",
           min_price: Annotated[float, Query(ge=0)] = 0):
    order_by = SORTS[sort]                                        # mapped from a closed set: safe
    sql = f"SELECT id, name FROM products WHERE price >= ? ORDER BY {order_by}, id LIMIT 20"
    return {"sql": sql, "params": [min_price]}                    # values ALWAYS as bound parameters

client = TestClient(app)
print(client.get("/search?sort=-price&min_price=100").json())
r = client.get("/search", params={"sort": "price; DROP TABLE products--"})
print(r.status_code, r.json()["detail"][0]["msg"])

Output

{'sql': 'SELECT id, name FROM products WHERE price >= ? ORDER BY price DESC, id LIMIT 20', 'params': [100.0]}
422 Input should be 'price', '-price', 'name' or 'newest'

Bound parameters (?, or SQLAlchemy expressions) protect values. Identifiers (column names, sort directions) cannot be bound, so they must come from a closed mapping. Validate the incoming string with Literal or an Enum, then translate it.

3. Optimistic concurrency with ETags

AshaAPI (version)Ravi GET → ETag "v3" GET → ETag "v3" PUT If-Match "v3" → 200, now v4 PUT If-Match "v3" → 412 Ravi re-reads v4 and merges; nothing is overwritten silently

GET returns an ETag; writes must send If-Match

from typing import Annotated

from fastapi import FastAPI, Header, HTTPException, Response
from fastapi.testclient import TestClient
from pydantic import BaseModel

ORDERS = {1: {"id": 1, "address": "12 MG Road", "version": 3}}

class AddressChange(BaseModel):
    address: str

app = FastAPI()

def etag(order):
    return f'"v{order["version"]}"'

@app.get("/orders/{order_id}")
def get_order(order_id: int, response: Response):
    order = ORDERS[order_id]
    response.headers["ETag"] = etag(order)
    return order

@app.put("/orders/{order_id}/address")
def change_address(order_id: int, body: AddressChange, response: Response,
                   if_match: Annotated[str | None, Header()] = None):
    order = ORDERS[order_id]
    if if_match is None:
        raise HTTPException(428, "send If-Match with the ETag you last read")    # 428 Precondition Required
    if if_match != etag(order):
        raise HTTPException(412, "order changed since you read it; re-read and retry")
    order["address"] = body.address
    order["version"] += 1                  # in SQL: UPDATE … SET version = version + 1 WHERE id = ? AND version = ?
    response.headers["ETag"] = etag(order)
    return order

client = TestClient(app)
tag = client.get("/orders/1").headers["etag"]                     # both users read version 3
asha = client.put("/orders/1/address", json={"address": "Flat 4, Marine Drive"}, headers={"If-Match": tag})
ravi = client.put("/orders/1/address", json={"address": "Old warehouse"}, headers={"If-Match": tag})
print("asha:", asha.status_code, asha.headers.get("etag"), "| ravi:", ravi.status_code, ravi.json()["detail"])
print("no If-Match:", client.put("/orders/1/address", json={"address": "x"}).status_code)

Output

asha: 200 "v4" | ravi: 412 order changed since you read it; re-read and retry
no If-Match: 428

4. Idempotency keys: safe retries for POST

A mobile client sends “create payment”, the network drops, and the client cannot know whether it arrived. If it retries, the customer may be charged twice. With an Idempotency-Key header (a UUID the client generates per operation), the server stores the first result and replays it for repeats:

first call executes, retries replay, key reuse with a different body is rejected

import hashlib
import json
from typing import Annotated

from fastapi import FastAPI, Header, HTTPException, Response
from fastapi.testclient import TestClient
from pydantic import BaseModel

class PaymentIn(BaseModel):
    order_id: int
    amount: int

STORE: dict[str, dict] = {}               # in production: a table with a unique key and an expiry (e.g. 24 h)
CHARGES = []

app = FastAPI()

@app.post("/payments", status_code=201)
def create_payment(body: PaymentIn, response: Response,
                   idempotency_key: Annotated[str, Header(min_length=16, max_length=64)]):
    fingerprint = hashlib.sha256(json.dumps(body.model_dump(), sort_keys=True).encode()).hexdigest()
    if (saved := STORE.get(idempotency_key)) is not None:
        if saved["fingerprint"] != fingerprint:
            raise HTTPException(422, "Idempotency-Key reused with a different request body")
        response.headers["Idempotent-Replayed"] = "true"
        response.status_code = saved["status"]
        return saved["body"]
    CHARGES.append(body.amount)                                   # the side effect happens once
    result = {"payment_id": f"pay_{len(CHARGES)}", "status": "captured", "amount": body.amount}
    STORE[idempotency_key] = {"fingerprint": fingerprint, "status": 201, "body": result}
    return result

client = TestClient(app)
key = {"Idempotency-Key": "2f8c1e0a-7b1d-4c4e-9a55-3c2f1d9e8b71"}
first = client.post("/payments", json={"order_id": 1042, "amount": 49900}, headers=key)
retry = client.post("/payments", json={"order_id": 1042, "amount": 49900}, headers=key)
misuse = client.post("/payments", json={"order_id": 1042, "amount": 1}, headers=key)
print(first.status_code, first.json(), "| retry:", retry.status_code, retry.headers.get("idempotent-replayed"))
print("misuse:", misuse.status_code, "| charges made:", CHARGES)

Output

201 {'payment_id': 'pay_1', 'status': 'captured', 'amount': 49900} | retry: 201 true
misuse: 422 | charges made: [49900]
In production

Store keys in the database with a unique constraint, write the key and the side effect in one transaction, handle concurrent duplicates (a second request while the first is still running should get 409 or wait), and expire keys after a sensible window. The guide project (part 25) implements this on SQLAlchemy.

5. Schema migrations with Alembic

the everyday commands (pip install alembic; not run here)

alembic init -t async migrations                       # async template for SQLAlchemy asyncio engines
alembic revision --autogenerate -m "add orders.version" # diff models vs database → a migration script
alembic upgrade head                                   # apply
alembic downgrade -1                                   # roll back one step (write downgrades!)
Zero-downtime ruleWhy
Expand, migrate, contract: add the new column (nullable), deploy code that writes both, backfill, switch reads, then drop the old column in a later releaseduring a rolling deploy, old and new code run at the same time against the same schema
Review every autogenerated scriptautogenerate misses renames (it sees drop + add, which loses data) and some constraint changes
Run migrations as a separate deploy step, not in app startupten pods starting at once would race to migrate
Beware locking DDL on large tablesadding an index or a NOT NULL column can lock writes. In PostgreSQL use CREATE INDEX CONCURRENTLY and add constraints in steps.
Backfills in batchesone giant UPDATE holds locks and bloats the transaction log

Recap

  • Cursor (keyset) pagination: constant cost at any depth, stable under inserts; opaque cursors with a unique tie-breaker.
  • Sort and filter safely: values as bound parameters, identifiers from a closed whitelist.
  • ETag + If-Match (412/428) prevents lost updates; Idempotency-Key makes POST retries safe.
  • Migrations: expand/contract, reviewed scripts, run as a deploy step, careful with locking DDL.

Get the free RAG production checklist

25 checks to run before real users see your RAG feature: exact-match misses, invented citations, prompt injection through documents, quality that slips after a prompt change. Confirm your email and the PDF comes with the welcome email, plus each new experiment.

One email to confirm. Unsubscribe any time. What's in it · Privacy

Want the production version of this? See ShipRAG: A production RAG + MCP starter kit for FastAPI: hybrid search, validated citations, evals in CI, Docker, 32 tests.

Written by

Alby Tomy

Python/FastAPI Engineer | Data Engineer | AI Engineer building RAG and MCP systems.

X · GitHub · Contact & enquiries