The list pipeline
GET /{base_path} runs the records through four steps, each of which a viewset can replace:
perform_list → apply_filter → apply_sort → apply_paginationget_list is the whole pipeline in one method; override it to rearrange the steps rather than replace one of them.
perform_list may be lazy
perform_list returns any iterable or async iterable — a list, a generator, an async generator, or whatever cursor object a database driver hands back:
class TrackViewSet(ListMixin[Track, TrackFilter]):
async def perform_list(self, context: Context):
async def rows():
async for row in self.db.stream("SELECT * FROM tracks"):
yield Track(**row)
return rows()A plain list is equally valid, and is what CollectionViewSet returns.
The stages
Each stage receives the records and the ListQuery describing what was asked for, and returns records. Override one and chain the rest with super():
async def apply_sort(self, context, query: ListQuery, records: ListRecords) -> ListRecords:
if query.has_sort:
records = self.db.order_by(*(column.column_name for column in query.sort))
query.mark_applied("sort")
return await super().apply_sort(context, query, records)ListRecords is generic in its item type and a stage writes it bare — see the reference for what that leaves the items as.
query.mark_applied("sort") tells the in-memory default that this stage is done, so it leaves the rows alone. Each stage is marked independently: push down the sort, leave the filter to the default, and neither needs to know about the other.
Stage names are "filter", "sort" and "pagination", plus "conversion", which land() marks once it has converted the materialised rows into the response model.
filter_list and sort_list remain the place to implement in-memory filtering and sorting; the default apply_* stages call them.
ListQuery
| field | meaning |
|---|---|
fltr | the filter model, or None when the client sent no filter |
sort | list of SortStateColumn, in priority order |
offset / limit | the requested page; limit is None when not paginated |
cursor | the opaque cursor, when the shape is cursor |
shape | which envelope the answer goes into |
applied | stages a backend has already handled |
A filter model whose every field is None counts as no filter: FastAPI builds the model whether or not the client sent anything in it, so a null check cannot answer the question.
Declaring a shape
A list endpoint answers in one of three shapes:
| shape | response | suits | cannot |
|---|---|---|---|
plain | a bare array | a lookup table | scale |
paginated | {results, offset, limit, count, has_more, has_previous} | jumping to page 40 | avoid re-reading the 39 in front, or drifting when rows are inserted |
cursor | {results, next, previous, first, last, has_more, has_previous} | walking a live list | jump to a page, or report a total |
The viewset says which:
class TrackViewSet(CollectionViewSet[int, Track], ListMixin[Track, TrackFilter]):
list_shape = "cursor"
default_page_size = 50
max_page_size = 500PaginatedListMixin and CursorListMixin are shorthands for list_shape = "paginated" and "cursor". Unset, the shape comes from settings.default_list_shape, which is "plain".
The declaration decides the endpoint's parameters and its response model:
list_shape | parameters | response |
|---|---|---|
plain | fltr, sort | ListOf_Track_ |
paginated | + offset, limit | PaginatedList_Track_ |
cursor | + cursor, limit | CursorPage_Track_ |
limit is capped at max_page_size. A limit below 1 or an offset below 0 is a 422.
Letting the client choose
Add list_shapes and a client may request any of them with an X-List-Shape header:
class TrackViewSet(CollectionViewSet[int, Track], ListMixin[Track, TrackFilter]):
list_shape = "cursor" # the default
list_shapes = ("cursor", "plain", "paginated") # what a client may requestThe endpoint gains the header and every parameter those shapes use, and its response becomes a union of exactly those models. Each is documented with an example named after the header value that produces it, so the dropdown reads plain rather than ListOf_Track_. A value not in list_shapes is a 422. A viewset whose list_shape or list_shapes names a shape other than plain, paginated and cursor raises fastapi_viewsets.list_shapes.ShapeError (a ValueError).
Declare one shape unless clients genuinely differ. A union has to be narrowed by every generated client, and OpenAPI cannot express that a request header selects between its members — that part is prose in the endpoint's description.
On the client
A ViewSet declares the mixin matching the shape its endpoint answers in - see the ViewSet factory. Each of the three reads the same GET {basePath}:
import { restViewSet, CursorListMixin } from '@dynamicforms/fastapi-viewsets';
interface Track { id: number; title: string; year: number }
class TrackViewSet extends restViewSet<Track>()('id', [CursorListMixin]) {}
const tracks = new TrackViewSet({ basePath: '/tracks' });
let next = await tracks.listCursor({ limit: 50 });list() and listPage() read the same endpoint. Each method sends X-List-Shape with its shape (plain, paginated, cursor), so against a viewset with list_shapes every declared mixin receives its own envelope. A viewset with one shape ignores the header and answers in that shape; the methods for the other two throw ListShapeMismatchError.
The envelope's snake_case fields are renamed to hasMore, hasPrevious; the records inside are untouched.
CORS
X-List-Shape is not a CORS-safelisted header. When the page and the API are on different origins, the browser sends an OPTIONS preflight before every REST list request, and sends the GET only if the preflight's Access-Control-Allow-Headers names X-List-Shape. With FastAPI's CORSMiddleware:
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
app = FastAPI()
app.add_middleware(
CORSMiddleware,
allow_origins=["https://app.example.com"],
allow_methods=["*"],
allow_headers=["Authorization", "Content-Type", "X-List-Shape"], # or ["*"]
)A preflight that does not allow the header blocks the request in the browser: the server never receives the GET, and the call rejects with an AxiosError that has no response.
A same-origin page — including one served through a dev-server proxy — sends no preflight. muxws carries the header inside the WebSocket stream, where CORS does not apply.
Paging behaviour
Both paged shapes read limit + 1 rows; the extra one answers has_more. A page out of a million-row generator costs offset + limit + 1 steps.
count is null when the source cannot be counted without draining it. A backend that can count cheaply overrides count_records(). A cursor page never reports a total.
Cursor pagination
CursorListMixin — or list_shape = "cursor" — pages by position:
class TrackViewSet(CollectionViewSet[int, Track], CursorListMixin[Track, TrackFilter]):
schema = Track # the response model, used to coerce cursor values back from JSON
pk_field_name = "id" # what makes every key tuple unique; the default
default_page_size = 50The endpoint takes cursor and limit, and answers:
{
"results": [...],
"limit": 50,
"has_more": true,
"has_previous": false,
"next": "eyJwIjp7...",
"previous": null,
"first": "eyJwIjp7...",
"last": "eyJwIjp7..."
}Follow next to walk forward. Reaching page 500 does not read the rows in front of it, and a row inserted or deleted behind the client cannot make the next page repeat or skip anything. There is no way to jump to an arbitrary page and no total.
The four cursors
| cursor | anchor | direction | includes the anchor row |
|---|---|---|---|
next | last row of the page | forward | no |
previous | first row of the page | backward | no |
last | last row of the page | forward | yes |
first | first row of the page | backward | yes |
All four anchor on rows inside the page. first and last are the same two edges read inclusively: they return their own row again — one duplicate to drop — and cannot skip a row inserted at that edge. They are present whenever the page is non-empty, including when next is null, so a client polling the head of a live list keeps its anchor.
What the cursor carries
The full ordering key tuple, with the primary key appended so every tuple is unique. Values travel as real JSON and are coerced back through the response model's field types.
A cursor is fingerprinted against the ordering and the active filters; sending one to a differently sorted or filtered query is a 400.
NULLs sit at the end the viewset's nulls attribute names — "first" (the default) or "last" — when sorting ascending. Descending reverses it: "last" means NULL is the largest value there is, so a descending sort puts it in front. That is not SQL's NULLS LAST, which stays put; a backend flips the placement it emits per key.
The in-memory sort, the cursor's comparison and the SQL a backend emits all use this one setting, which is what stops a cursor walking straight past the NULL rows.
On a database
The cursor predicate is a filter, so it goes through the same registry and the same all-or-nothing push-down. The Django backend translates it two ways:
- a row-value comparison,
WHERE (year, id) > (2003, 42), which a composite index on those columns can seek with. Used when the cursor excludes its anchor row, every key sorts the same way, and no key is nullable. - a union of segments otherwise, which spells out the NULL handling, copes with mixed directions, and adds the anchor row back for the two inclusive cursors.
Row values are supported by Postgres, SQLite 3.15+ and MySQL; not by SQL Server.
Declarative filters
Declare which fields accept which operators:
from fastapi_viewsets.filters import make_filter_model
TrackFilter = make_filter_model(Track, {
"year": ["exact", "gte", "lte", "in"],
"title": ["icontains"],
"genres": ["overlaps"],
})
class TrackViewSet(CollectionViewSet[int, Track], ListMixin[Track, TrackFilter]):
list_shape = "cursor"That produces the query parameters year, year__gte, year__lte, year__in, title__icontains, genres__overlaps — and nothing else. exact keeps the bare field name.
An unknown field or operator raises at import time.
Built in: exact, iexact, contains, icontains, startswith, gt, gte, lt, lte, in, isnull, overlaps.
in and overlaps take a comma-separated string (?year__in=1999,2000); FastAPI cannot expose a list-typed field of a Depends()-expanded model as a query parameter. Values are coerced to the field's own type, and one that will not convert is a 422 in FastAPI's validation shape, with loc["query", parameter, index] (["query", "year__in", 1] for ?year__in=1999,abc).
Hand-written filter_list keeps working: a filter model built by hand carries no declaration, and the pipeline falls through to it.
Adding an operator
One class with one method, usable on every backend immediately:
from fastapi_viewsets.filters import Filter, register_operator
@register_operator
@dataclass(frozen=True)
class Decade(Filter):
lookup: ClassVar[str] = "decade"
def matches(self, record) -> bool:
value = self.read(record)
return value is not None and value // 10 * 10 == self.valuematches() is required and is the universal implementation.
Teaching a backend to translate one
Compilers are registered against a (backend, filter) pair:
from fastapi_viewsets.filters import compiles
from fastapi_viewsets.backends.django_orm import DjangoORMViewSet
@compiles(DjangoORMViewSet, Decade)
def _(viewset, fltr, queryset):
return queryset.filter(year__gte=fltr.value, year__lt=fltr.value + 10)A new operator needs no change to any backend, and a new backend needs no change to any filter.
Push-down is all or nothing: if any filter in a request has no compiler, the backend leaves the whole stage to the in-memory pass.
Reference
fastapi_viewsets.filters exports:
| Name | Description |
|---|---|
make_filter_model(model, declaration) | The query-parameter model for a declaration; raises FilterError for an unknown field or operator |
FilterDeclaration | The declaration's type: field name → list of operator names |
filters_from(fltr) | The FilterSet a populated filter model asks for, or None for a model not built from a declaration |
Filter | Base class of an operator: field, value, the class variable lookup, read(record) and matches(record) |
FilterSet | The filters of one request, and the in-memory fallback for all of them |
FilterError | A declaration names a field the model does not have, or an unknown operator |
Exact, IExact, Contains, IContains, StartsWith, Gt, Gte, Lt, Lte, In, IsNull, Overlaps | The built-in operators |
register_operator(cls) | Registers an operator under its lookup |
operator_for(lookup), known_operators() | The operator class for a name; the set of registered names |
compiles(backend, filter_type) | Registers a compiler for a (backend, filter) pair |
compiler_for(backend, filter_type) | The most specific registered compiler, or None |
can_compile_all(backend, filters), compile_all(backend, viewset, filters, query) | Whether every filter has a compiler for backend; applies them all in turn |
registered_pairs() | Every registered (backend, filter) pair, by name |
SPEC_ATTRIBUTE | The attribute ("__filter_spec__") on a filter model that holds its declaration |
Backends
CollectionViewSet backs a viewset with anything already in memory. DjangoORMViewSet backs one with a database through the Django ORM:
from fastapi_viewsets.backends.django_orm import DjangoORMViewSet
class TrackViewSet(DjangoORMViewSet[int, Track], CursorListMixin[Track, TrackFilter]):
model = TrackModel # Django model
schema = Track # pydantic model rows are converted intoIt returns the queryset unevaluated from perform_list, translates the filter set into .filter(), the sort into .order_by() in either direction with the NULL placement nulls names, and the page into LIMIT/OFFSET, marking each stage applied. count comes from a COUNT(*). Requires the django extra; every ORM call uses Django's async API or sync_to_async.
A relation field (ForeignKey, OneToOneField) appears in schema under its name and carries the related row's key: owner: int. The backend reads and writes the owner_id column for it, filters and sorts on that column, and does not load the related row. A schema that declares owner_id raises TypeError when the viewset class is defined.
class Note(BaseModel):
id: int
owner: int # NoteModel.owner = models.ForeignKey(OwnerModel, ...)
title: strA create writes every concrete field the body carries except a primary key that the database or Django assigns (an auto-incrementing key, the parent link of an inherited model); a natural key or a relation primary key is written. An update never writes the primary key.
pk_field_name needs no declaration here: the backend takes it from the model's primary key, so a model keyed on uuid or code pages correctly without being told. The cursor orders by that name in SQL and reads its position off the converted record, so the name has to be one both the model and schema carry — artist for a one-to-one primary key, and the id the schema carries rather than the parent link for an inherited model. Where schema names the key something else again, assigning pk_field_name on the class wins. A composite primary key raises: no single name expresses it, so name a field that is unique per row on the class instead.
The derivation is a property on DjangoORMViewSet, which is why the backend comes first in the base list, as above. The other order finds ListMixin's plain "id" first and the model is never asked.
The pk_field_name argument to route_viewset is a different thing — it names the primary key a POST body does not carry — and is passed explicitly.
What it declines and leaves to the in-memory pass: a filter or a sort naming anything but a concrete model field, and any operator with no registered compiler — overlaps, of the built-in ones, whose Django equivalent is Postgres-only.
A declined stage is handled by the default, never an error.
Updates under a row lock
perform_update and perform_bulk_update run in one transaction, in one sync_to_async call:
- Each row is read with
SELECT ... FOR UPDATEthrough the model's_base_manager, in key order. A row that does not exist, or thatget_queryset(context)excludes, raisesNotFoundError. - The fields of the record are set on the row: for
PATCHthe fields the request stated, forPUTevery field except the primary key. - The row is validated as
schema,@model_validators included. - Once every row is valid, each is saved with
save().
A concurrent update of the same row waits for the lock and then reads this one's result, so it does not revert fields it did not state, and a rule over several fields is checked against the values that end up stored. Two updates of the same field end with the later one's value.
A row that fails validation answers 422 with the errors of schema and loc ["body", field], or ["body", pk, field] for PATCH /bulk and PUT /bulk, which report every failing row. A bulk update with a missing or invalid row writes none of its rows. A row already stored in breach of a @model_validator rejects every update that leaves it in breach, including one that changes an unrelated field.
save() and the model's signals run inside the transaction, before the commit. Work they start that reads the row over another connection, such as a Celery task, is started with transaction.on_commit; started directly, it can read the values from before the update.
The transaction uses the database router.db_for_write(model) names. SQLite has no row locks; its write transaction locks the whole database.
Writing your own
Override the stages your store can answer and chain the rest:
| hook | override when |
|---|---|
perform_list | always — return your lazy source |
apply_filter / apply_sort | the store can narrow or order |
take_page | the store has LIMIT/OFFSET or a cursor |
count_records | the store can count cheaply |
to_record | your source yields rows rather than the response model |
A stage receives whatever the stage before it passed on — your own lazy object for as long as nothing has materialised it — and returns records. Four library calls make up the contract:
| call | what it is for |
|---|---|
filter_set_for(query) (fastapi_viewsets.mixins) | every filter the request implies: the client's declared ones plus what the pipeline added, the cursor predicate among them. Each filter reports the model fields it reads through fields() |
filters_from(query.fltr) (fastapi_viewsets.filters) | None when the filter model was not built from a declaration — the viewset is on the hand-written filter_list path, and there is nothing to translate |
can_compile_all(type(self), filter_set) / compile_all(type(self), self, filter_set, queryset) | whether every filter in the set has a compiler registered for your backend, and applying them in turn to your query object. The last parameter is your backend's query — the signature calls it query, and it is not the ListQuery the stage was handed |
query.mark_applied("filter") / query.needs("filter") | reporting a stage as absorbed, and asking whether it still wants doing |
Chain to super() whichever way the decision went. DjangoORMViewSet.apply_filter, minus the check that every filtered name is a concrete model field:
async def apply_filter(self, context, query, records):
queryset = _as_queryset(records)
if queryset is None or not query.has_filter or not query.needs("filter"):
return await super().apply_filter(context, query, records)
filter_set = filter_set_for(query)
if not filter_set and filters_from(query.fltr) is None:
return await super().apply_filter(context, query, records)
if not can_compile_all(type(self), filter_set):
return await super().apply_filter(context, query, records)
queryset = compile_all(type(self), self, filter_set, queryset)
query.mark_applied("filter")
return await super().apply_filter(context, query, queryset)_as_queryset returning None is how the backend notices that an earlier stage already fell back: what flows on from there is a list rather than a lazy source, and every push-down below it has to decline. apply_sort and count_records open the same way; take_page returns (page, has_more) and marks "pagination".
land(query, records) is where the lazy part ends, and an override decides whether it gets that far: a stage you push down never calls it, the lazy object being the only thing a filter or an ordering goes into; a filter or a sort you decline is landed by the default that picks it up. take_page and count_records never land anything — their defaults walk the source only as far as the page.
to_record runs on the page alone for as long as no stage has landed the source, which means the filter and the sort each either pushed down or had nothing to do. Pagination is not part of that condition: decline it and the default take_page still cuts its page out of your lazy object, and the conversion still happens on that page. What declining it costs is the walk to the offset — the default reads the source row by row until the page is full, and against a database every skipped row crosses the wire before being discarded. That is cheap at the head of the list and grows with the offset, which is why take_page is worth overriding wherever the store has a real LIMIT/OFFSET.
Conversion sits at the end of the pipeline so the earlier stages carry your query object, and to_record must tolerate an already-converted record: an in-memory stage converts when it materialises the source, because a filter declaration names the response model's fields while the source yields whatever the backend stores. That is what makes a declined filter or sort expensive rather than merely slower — land() reads and converts everything that reached it, the whole table where the filter declined and the filtered set where only the sort did, before the page is cut. The answer is right either way and the response looks identical, so the only place the difference shows is query.applied, in process: assert on it from your own tests, or a stage you meant to push down can stop pushing down without anything saying so. An unpaginated request lands the whole source in any case, there being no page to cut.
