Query Dependencies — Reusable Pagination and Filtering

Many FastAPI list endpoints share identical query parameter patterns — page, page_size, sort_by, order, search. Repeating these declarations in every route handler creates maintenance overhead: when the maximum page size changes, you update 20 route handlers. Extracting these into reusable dependencies means one change updates all endpoints simultaneously. FastAPI supports both function-based dependencies (a function that takes query parameters and returns a parsed object) and class-based dependencies (a class whose __init__ takes query parameters, making the instance the dependency value).

Pagination Dependency

# app/dependencies/pagination.py
from fastapi import Query, Depends
from typing import Annotated
from dataclasses import dataclass

# ── Class-based pagination dependency ─────────────────────────────────────────
@dataclass
class PaginationParams:
    """
    Standard pagination parameters shared across all list endpoints.
    FastAPI instantiates this class from query parameters automatically.
    """
    page:      int = Query(default=1,   ge=1, description="Page number (1-indexed)")
    page_size: int = Query(default=10,  ge=1, le=100,
                           description="Results per page (max 100)")

    @property
    def offset(self) -> int:
        return (self.page - 1) * self.page_size

    @property
    def limit(self) -> int:
        return self.page_size

Pagination = Annotated[PaginationParams, Depends(PaginationParams)]

# ── Usage in route handler ─────────────────────────────────────────────────────
@router.get("/posts", response_model=Page[PostResponse])
def list_posts(
    pg:  Pagination,
    db:  DB,
):
    total = db.scalar(select(func.count()).select_from(Post))
    posts = db.scalars(
        select(Post)
        .order_by(Post.created_at.desc())
        .offset(pg.offset)
        .limit(pg.limit)
    ).all()
    return Page.create(items=posts, total=total, page=pg.page, page_size=pg.page_size)
Note: When you use a class as a dependency (e.g., Depends(PaginationParams)), FastAPI calls PaginationParams.__init__(page=..., page_size=...) with values it extracts from the request, using the same parameter resolution it applies to function dependencies. The page and page_size attributes on the class have Query() as their default value — FastAPI reads these and understands they should come from query parameters. The resulting PaginationParams instance is injected into your handler.
Tip: Use Python dataclass or Pydantic BaseModel for your dependency classes rather than plain classes — they get __repr__, comparison, and other useful methods automatically. With a dataclass, you can also add computed properties (like offset) alongside the raw parameters. However, do not use a Pydantic BaseModel as a dependency class if it has fields without Query() defaults — FastAPI will try to read those from the request body, not query parameters.
Warning: Class-based dependencies are instantiated fresh per request — they are not singletons. This is the correct behaviour for query parameter dependencies (each request has different parameter values). Only use @lru_cache on function-based dependencies that return the same result for all requests (like get_settings). Never use @lru_cache on a class-based dependency — the class would be instantiated only once and every request would share the same parameter values from the first request.

Sort Dependency

from fastapi import Query, Depends
from typing import Annotated, Literal
from dataclasses import dataclass, field

@dataclass
class SortParams:
    """Reusable sort parameters."""
    sort_by: Literal["created_at", "view_count", "title", "updated_at"] = Query(
        default="created_at",
        description="Field to sort by",
    )
    order: Literal["asc", "desc"] = Query(
        default="desc",
        description="Sort direction",
    )

    def apply(self, stmt, model):
        """Apply ORDER BY to a SQLAlchemy select statement."""
        col = getattr(model, self.sort_by)
        return stmt.order_by(col.desc() if self.order == "desc" else col.asc())

Sort = Annotated[SortParams, Depends(SortParams)]

# ── Combined pagination + sort dependency ─────────────────────────────────────
@dataclass
class ListParams:
    page:      int = Query(default=1,  ge=1)
    page_size: int = Query(default=10, ge=1, le=100)
    sort_by:   Literal["created_at", "view_count"] = Query(default="created_at")
    order:     Literal["asc", "desc"] = Query(default="desc")
    search:    str | None = Query(default=None, max_length=200)

    @property
    def offset(self) -> int:
        return (self.page - 1) * self.page_size

ListQuery = Annotated[ListParams, Depends(ListParams)]

# Usage:
@router.get("/posts", response_model=Page[PostResponse])
def list_posts(params: ListQuery, db: DB):
    stmt = select(Post).where(Post.status == "published", Post.deleted_at.is_(None))
    if params.search:
        stmt = stmt.where(Post.title.ilike(f"%{params.search}%"))
    sort_col = {"created_at": Post.created_at, "view_count": Post.view_count}[params.sort_by]
    stmt = stmt.order_by(sort_col.desc() if params.order == "desc" else sort_col.asc())
    total = db.scalar(select(func.count()).select_from(stmt.subquery()))
    posts = db.scalars(stmt.offset(params.offset).limit(params.page_size)).all()
    return Page.create(items=posts, total=total, page=params.page, page_size=params.page_size)

Common Mistakes

Mistake 1 — Repeating pagination params in every handler

❌ Wrong — duplicated validation logic:

@router.get("/posts")
def list_posts(page: int = Query(ge=1, default=1), page_size: int = Query(ge=1, le=100, default=10)): ...

@router.get("/users")
def list_users(page: int = Query(ge=1, default=1), page_size: int = Query(ge=1, le=100, default=10)): ...
# Changing the max page_size requires editing every endpoint!

✅ Correct — one PaginationParams class, used everywhere.

Mistake 2 — Using BaseModel (not dataclass) with Query() defaults

❌ Wrong — Pydantic reads undefaulted fields from the request body:

class PaginationParams(BaseModel):
    page: int = 1   # no Query() — FastAPI reads this from body, not query string!

✅ Correct — use dataclass with Query() defaults, or plain class with __init__ taking Query() defaults.

Mistake 3 — Mutable default in dataclass (list, dict)

❌ Wrong — shared mutable default between all instances:

@dataclass
class FilterParams:
    tags: list[str] = []   # shared! all instances see the same list

✅ Correct — use field(default_factory=list):

from dataclasses import dataclass, field
@dataclass
class FilterParams:
    tags: list[str] = field(default_factory=list)   # ✓

Quick Reference

Pattern Code
Class-based dep @dataclass class Params: field = Query(...)
Use class dep Annotated[Params, Depends(Params)]
Function dep def get_pagination(page: int = Query(ge=1, default=1)) -> dict:
Computed property @property def offset(self): return (self.page-1)*self.page_size
Apply sort stmt.order_by(col.desc() if params.order=="desc" else col.asc())

🧠 Test Yourself

You change the maximum page_size from 100 to 50 across your API. With individual query parameter declarations in each of 15 endpoints, how many files need to change? With a PaginationParams dependency?