API Pagination¶
The boilerplate uses FastCRUD's PaginatedListResponse[T] and paginated_response() helpers for paginated list endpoints. This page documents the pattern.
Quick Start¶
from typing import Any
from fastapi import APIRouter
from fastcrud import PaginatedListResponse, compute_offset, paginated_response
from ...infrastructure.dependencies import AsyncSessionDep
from .dependencies import UserServiceDep
from .schemas import UserRead
@router.get("/", response_model=PaginatedListResponse[UserRead])
async def get_users(
db: AsyncSessionDep,
user_service: UserServiceDep,
page: PageDep = 1,
items_per_page: ItemsPerPageDep = 10,
) -> dict[str, Any]:
result = await user_service.get_paginated(
skip=compute_offset(page, items_per_page),
limit=items_per_page,
db=db,
)
return paginated_response(crud_data=result, page=page, items_per_page=items_per_page)
compute_offset(page, items_per_page) is the documented helper — use it instead of computing (page - 1) * items_per_page by hand.
Response Shape¶
paginated_response() returns:
{
"data": [
{ "id": 1, "name": "User Userson", "username": "userson", "email": "user@example.com" }
],
"total_count": 150,
"has_more": true,
"page": 1,
"items_per_page": 10
}
has_more is True when there are still rows past the current page (page * items_per_page < total_count). The boilerplate doesn't return total_pages — frontends can derive it as ceil(total_count / items_per_page) if they need it.
Where the Service Does the Work¶
The route stays thin. The actual get_multi call lives in the service:
# modules/user/service.py
from fastcrud.types import GetMultiResponseDict
from .crud import crud_users
from .schemas import UserRead
class UserService:
async def get_paginated(
self, db: AsyncSession, skip: int = 0, limit: int = 100,
) -> GetMultiResponseDict:
return await crud_users.get_multi(
db=db,
offset=skip,
limit=limit,
is_deleted=False,
schema_to_select=UserRead,
return_total_count=True,
)
return_total_count=True is what makes the response include total_count (and therefore makes has_more accurate).
Filtering¶
Add filter parameters to the route, pass them to the service:
@router.get("/", response_model=PaginatedListResponse[UserRead])
async def list_users(
db: Annotated[AsyncSession, Depends(async_session)],
user_service: Annotated[UserService, Depends(get_user_service)],
page: int = 1,
items_per_page: int = 10,
search: str | None = None,
tier_id: int | None = None,
) -> dict[str, Any]:
result = await user_service.get_paginated(
skip=compute_offset(page, items_per_page),
limit=items_per_page,
db=db,
search=search,
tier_id=tier_id,
)
return paginated_response(crud_data=result, page=page, items_per_page=items_per_page)
In the service, build the crud_users.get_multi filters:
async def get_paginated(
self,
db: AsyncSession,
skip: int = 0,
limit: int = 100,
search: str | None = None,
tier_id: int | None = None,
) -> GetMultiResponseDict:
filters: dict[str, Any] = {"is_deleted": False}
if tier_id is not None:
filters["tier_id"] = tier_id
if search:
filters["username__icontains"] = search
return await crud_users.get_multi(
db=db,
offset=skip,
limit=limit,
schema_to_select=UserRead,
return_total_count=True,
**filters,
)
FastCRUD's __icontains / __contains / __gt / __in operators avoid raw SQL. See CRUD Operations for the full list.
Sorting¶
FastCRUD accepts sort_columns and sort_orders:
result = await crud_users.get_multi(
db=db,
offset=skip,
limit=limit,
sort_columns="created_at",
sort_orders="desc",
return_total_count=True,
)
For multiple sort keys, pass lists:
Expose this from the route as a query parameter:
from fastapi import Query
@router.get("/", response_model=PaginatedListResponse[UserRead])
async def list_users(
db: Annotated[AsyncSession, Depends(async_session)],
user_service: Annotated[UserService, Depends(get_user_service)],
page: int = 1,
items_per_page: int = 10,
sort_by: Annotated[str, Query(pattern=r"^(created_at|username|email)$")] = "created_at",
sort_order: Annotated[str, Query(pattern=r"^(asc|desc)$")] = "desc",
) -> dict[str, Any]:
...
The pattern constraint stops clients from passing arbitrary column names that could leak fields you didn't mean to sort by.
Validation¶
Every listing takes its bounds from one place, modules/common/pagination.py, so no route can
forget them:
MAX_ITEMS_PER_PAGE = 100
MAX_PAGE = 2**31 - 1
PageDep = Annotated[int, Query(ge=1, le=MAX_PAGE, description="Page number")]
ItemsPerPageDep = Annotated[int, Query(ge=1, le=MAX_ITEMS_PER_PAGE, description=f"Items per page (max {MAX_ITEMS_PER_PAGE})")]
A route declares the two parameters with those aliases and nothing else:
@router.get("/", response_model=PaginatedListResponse[UserRead])
async def list_users(
db: AsyncSessionDep,
user_service: UserServiceDep,
page: PageDep = 1,
items_per_page: ItemsPerPageDep = 10,
) -> dict[str, Any]:
...
items_per_page above 100 answers 422, on every listing including the API-key usage history,
which used to allow 1000. page above 2147483647 answers 422 as well, rather than reaching the
database with an offset it can't hold.
Real Endpoint: List Users¶
From modules/user/routes.py:
from ...infrastructure.auth.authorization import require_permissions
from ..common.pagination import ItemsPerPageDep, PageDep
@router.get(
"/",
response_model=PaginatedListResponse[UserRead],
summary="List All Users",
responses={
401: {"description": "Not authenticated"},
403: {"description": "Not authorized - requires the user.read permission"},
},
dependencies=[require_permissions("user.read")],
)
async def get_users(
db: AsyncSessionDep,
user_service: UserServiceDep,
page: PageDep = 1,
items_per_page: ItemsPerPageDep = 10,
) -> dict[str, Any]:
"""Get paginated list of users."""
users_data = await user_service.get_paginated(
skip=compute_offset(page, items_per_page),
limit=items_per_page,
db=db,
)
return paginated_response(crud_data=users_data, page=page, items_per_page=items_per_page)
The endpoint is gated on the user.read permission, which a role grants — not on the superuser flag. Superusers always pass. See Permissions.
Real Endpoint: API Key Usage History¶
From modules/api_keys/routes.py — the same pattern, and the same bounds:
@router.get(
"/{key_id}/usage",
response_model=PaginatedListResponse[KeyUsageRead],
)
async def get_key_usage(
current_user: CurrentUserDep,
api_key_service: APIKeyServiceDep,
db: AsyncSessionDep,
key_id: int = Path(..., description="API key ID"),
page: int = Query(1, ge=1, description="Page number"),
items_per_page: ItemsPerPageDep = 100,
) -> dict[str, Any]:
result = await api_key_service.get_key_usage(
key_id=key_id,
user_id=current_user["id"] if isinstance(current_user, dict) else current_user.id,
limit=items_per_page,
offset=compute_offset(page, items_per_page),
db=db,
)
return paginated_response(crud_data=result, page=page, items_per_page=items_per_page)
Simple List Without Pagination¶
If you genuinely don't need pagination (e.g. an admin endpoint that returns a tiny enumerable like all tiers), call get_multi once and return the data list directly:
@router.get("/all", response_model=list[TierRead])
async def list_all_tiers(
db: Annotated[AsyncSession, Depends(async_session)],
tier_service: Annotated[TierService, Depends(get_tier_service)],
) -> list[dict[str, Any]]:
result = await tier_service.get_all(db=db, skip=0, limit=1000)
return result["data"]
Even here, set a generous-but-finite limit — never an unbounded query.
Performance Tips¶
Cap items_per_page¶
Already covered, but worth repeating: an Annotated[int, Query(ge=1, le=100)] is your safety net.
Use schema_to_select¶
Only return the columns the response model needs. For a UserRead schema, this avoids fetching hashed_password:
return await crud_users.get_multi(
db=db,
schema_to_select=UserRead,
return_total_count=True,
offset=skip,
limit=limit,
)
Index columns you sort or filter on¶
When you add new sort/filter parameters that target a column without an index, generate an Alembic migration that adds one:
The User model already indexes username, email, tier_id, google_id, and github_id for this reason.
Beware of large offsets¶
OFFSET 100000 still has Postgres scan and discard 100,000 rows. For very large datasets, consider keyset pagination (filtering by created_at < cursor) instead of page-based pagination. FastCRUD's __lt / __gt operators support this directly.
What's Next¶
- CRUD Operations — Filter/sort/offset/limit semantics
- Schemas — How
*Readschemas pair withschema_to_select - Authentication — Gating list endpoints behind login, a permission, or admin