How-To: Filter, Sort, and Paginate Lists#
List endpoints (GET /{prefix}/) support filtering, sorting, and
pagination through URL query parameters out of the box. Filter parameters
are derived from the response schema; sort and pagination use a fixed set
of names.
Pagination is opt-in. Lists return every matching row when no
page_sizeis supplied. Passpage_size(and optionallypage) to enable pagination, or setdefault_page_sizeon the view class if you want every request to be paginated by default.For public or production endpoints, set
default_page_sizeandmax_page_sizeexplicitly. The framework default of “no implicit cap” is intentionally honest about what the SQL does, but is not the safest production posture — a missingpage_sizewill scan the entire table.Unknown query keys are rejected with 422. Filters are narrowing controls — a typo or unsupported operator silently ignored could widen the result set. Generated list endpoints validate the request against the schema’s declared parameters and reject anything else. If a view consumes a custom query parameter outside the schema (e.g.
?include_deleted=true), declare it on the view:class UserView(fr.AsyncRestView): extra_query_params = ("include_deleted",)
Filtering#
Plain equality uses the bare field name:
GET /users/?name=John
Suffixes add other operators:
Suffix |
SQL equivalent |
Example |
|---|---|---|
(none) |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
__isnull accepts a boolean value (true or false), not the string
"null".
Range operators (__gte/__lte/__gt/__lt) are only generated for
orderable column types — they’re omitted for booleans and UUIDs.
__contains and __icontains are only generated for string fields.
Comma logic on bare equality#
Comma-separated values in a plain equality filter are OR-combined:
GET /users/?status=active,pending
Produces WHERE status = 'active' OR status = 'pending'. Equivalent to
SQL IN.
Use __in when you want that same SQL IN meaning to be explicit in
the URL:
GET /users/?status__in=active,pending
Comma logic on __ne (NOT IN)#
Comma-separated values in __ne are AND-combined — the row must differ
from every listed value:
GET /users/?status__ne=archived,deleted
Produces WHERE status != 'archived' AND status != 'deleted'. Equivalent
to SQL NOT IN.
AND-combining multiple contains terms#
The precise form is to repeat the parameter — each predicate becomes its
own LIKE / ILIKE clause and they are AND-combined:
GET /users/?name__contains=john&name__contains=doe
GET /users/?name__icontains=john&name__icontains=doe
The __contains form produces WHERE name LIKE '%john%' AND name LIKE '%doe%'.
The __icontains form uses ILIKE instead.
As a convenience, whitespace inside one __contains or __icontains
value is also AND-split. ?name__contains=john%20doe is equivalent to
the repeated-parameter form above.
Prefer repeated parameters when you control the URL — they are
unambiguous and survive any client/server quoting changes.
Literal %, _, and \ characters are escaped before building the SQL
LIKE / ILIKE, so contains searches behave like literal substring
matching rather than wildcard matching.
Multiple filters on the same field#
Repeat the parameter to add AND conditions:
GET /users/?created_at__gte=2024-01-01&created_at__lt=2025-01-01
Produces WHERE created_at >= '2024-01-01' AND created_at < '2025-01-01'.
Sorting#
Use the sort parameter with comma-separated field names. Prefix
with - for descending:
GET /users/?sort=-created_at,name
Default ordering: When no
sortparameter is given and the model has anidcolumn, the framework automatically appliesORDER BY id ASC. Models without anidcolumn return results in an unspecified order.
Pagination#
GET /users/?page=2&page_size=50
page is 1-based. page_size must be >= 1 and <= max_page_size
(default 1000). When page_size is omitted, the endpoint returns every
matching row (no implicit cap). To enforce a default page size, set
default_page_size on the view class:
class UserView(fr.AsyncRestView):
default_page_size = 25
max_page_size = 200
Out-of-range pagination values produce a standard 422 response from
FastAPI.
Alias support#
Query parameter keys follow the Pydantic schema field’s public name:
the alias when one is declared, the Python field name otherwise. The
public name is the only name the URL surface accepts. populate_by_name
controls how Pydantic parses request bodies; it does not extend the
list-params URL contract with extra Python-name aliases.
class UserRead(BaseModel):
user_name: Annotated[str, Field(alias="userName")]
GET /users/?userName=Alice # supported
GET /users/?user_name=Alice # rejected — Python name is not exposed
If you want a different URL key, change the alias.
Relation filtering#
Filtering on a related model’s field uses dot notation:
GET /orders/?user.name=Alice
GET /orders/?user.name__contains=ali
The relation must be defined on both the SQLAlchemy model (as a
relationship) and the Pydantic schema (as a nested schema field).
Optional nested schemas (UserRead | None) and deep nesting
(?blog.author.name=Alice) are supported. Lists of nested schemas
(list[UserRead]) are not.
Aliases apply to every segment of the dotted path — both the relation field and the nested column. The list-params keys always follow the response schema’s public names:
class AuthorRead(BaseModel):
name: str = Field(alias="authorName")
class ArticleRead(BaseModel):
author: AuthorRead = Field(alias="writer")
GET /articles/?writer.authorName=Alice # supported (aliased segments)
GET /articles/?author.name=Alice # not exposed — canonical names
# are not part of the public contract
Quick reference#
GET /users/?name=John
GET /users/?status=active,pending
GET /users/?age__gte=18&age__lt=65
GET /users/?deleted_at__isnull=true
GET /users/?email__icontains=example
GET /users/?name__contains=john doe
GET /users/?sort=-id
GET /users/?page=2&page_size=50
Overriding query logic per view#
Override build_query to inject a base query before the framework
applies the URL parameters. perform_listing and perform_get consult this
seam, and count_listing counts the query built by perform_listing, so the
filter applies to listing, the pagination total, and single-row fetches
(GET /{id}) without further plumbing:
import fastapi_restly as fr
class UserView(fr.AsyncRestView):
...
def build_query(self):
return super().build_query().where(self.model.active.is_(True))
Calling super().build_query() and chaining .where(...) composes
cleanly with any base-class or mixin filter. See
Composing views with mixins for the
multi-layer pattern.
perform_listing no longer accepts a separate query argument. Keep SQL-level
base query changes in build_query() so listing, pagination totals, and
single-row fetches all see the same visibility rules. If a list endpoint needs
a completely different query shape, override perform_listing() and construct
that query explicitly inside the method.