Sorting & Pagination¶
Sorting and pagination ride on the same FilterDepends(...) dependency as
filtering — they're the other two hats a list endpoint wears (see
the overview), so they
share the same q object instead of being separate dependencies to wire up.
Sorting¶
sort is a comma-separated list of field names; a leading - means
descending order.
?sort=-age # age descending
?sort=age # age ascending
?sort=-age,name # age descending, then name ascending as a tiebreaker
This compiles to:
Only fields marked sortable are accepted — by default, the same set of
fields that are filterable, except array (list[T] / set[T]) fields,
which are never sortable unless opted in (sorting on a Mongo array uses
min/max element semantics, which surprises people). Sorting on a field
outside that allow-list is rejected, which prevents an endpoint from
accidentally allowing sorts on unindexed fields.
Pagination¶
The default pagination strategy is offset / limit, which maps directly
to Mongo's skip/limit:
q.skip # -> 40
q.limit # -> 20
await db.users.find(q.to_mongo()).sort(q.sort_mongo()).skip(q.skip).limit(q.limit).to_list(None)
Two guardrails apply out of the box:
default_limit— used when the client omitslimit.max_limit— the hard ceiling; an unboundedlimitis never allowed, even if a client asks for one.
The page / page_size strategy¶
If your clients think in page numbers rather than offsets, switch the generated parameters with one config knob:
@app.get("/users")
async def list_users(
q: FilterQuery[User] = FilterDepends(User, config=FilterConfig(pagination="page")),
): ...
page is 1-based (page=0 is a 422) and page_size obeys the same
guardrails as limit (default_limit when omitted, max_limit as the
ceiling). It is pure sugar over offset: q.limit, q.offset, and q.skip
resolve to the same window (offset = (page - 1) * page_size), so backends
and paginate() below don't care which strategy a route uses. Keyset/cursor
pagination is on the roadmap — see
design doc 01
for the full comparison.
Optional: a paginated response envelope¶
By default fast-pager returns just the query — you decide how to shape the
response. For the common "list + total count" shape, Page[T] and
q.paginate(collection) are the opt-in sugar:
from fast_pager import FilterDepends, FilterQuery, Page
@app.get("/users", response_model=Page[User])
async def list_users(q: FilterQuery[User] = FilterDepends(User)):
return await q.paginate(db.users) # find + count, one line
Page[T] is a generic Pydantic model, so response_model=Page[User]
keeps response validation and the OpenAPI schema exactly right.
paginate() runs the compiled filter, sort, and window (to_mongo() /
sort_mongo() / skip / limit — which remain available for full
control) against the collection you hand it.
The collection is duck-typed — no driver is imported, awaitables are detected at runtime — so the same line works across drivers:
The count is not free¶
An exact total runs count_documents with the same filter on every
page request — expensive on large collections. So Page.total is
optional and the cost is a knob:
total= |
What runs | Cost | Page.total |
|---|---|---|---|
"exact" (default) |
count_documents(filter) |
A full count matching the filter, every request | The exact count |
"estimated" |
estimated_document_count() — only when no filters are applied; otherwise falls back to "exact" |
Metadata read: effectively free (but never filter-aware) | An estimate (or the exact fallback) |
"none" |
No count at all | Free | None |
The "estimated" fallback rule is deliberate: Mongo's
estimatedDocumentCount cannot take a filter, so estimating a filtered
query would return a number unrelated to the results. When the compiled
filter is non-empty (or the collection offers no estimate method),
paginate() quietly runs the exact count instead — you always get a
truthful total. "none" is the right default for infinite-scroll UIs,
and a natural fit for the future cursor strategy.
Next¶
The Pagination reference documents the
Page[T] fields, the full paginate() contract, and the duck-typed
collection surface. See the Operator Reference
for every filter operator by type, or revisit Filtering for
the field__op convention.