Skip to content

Python Mixins ​

The mixin system is the core of fastapi-viewsets. Each mixin adds one or more HTTP endpoints to a viewset class. You compose viewsets by inheriting from the mixins you need.

How it works ​

Every mixin class owns a private APIRouter and registers its endpoints on it using standard FastAPI decorators. The route_viewset decorator later collects all routes from the class hierarchy, resolves generic type parameters, and registers them on your application router.

You implement the actual business logic by overriding the corresponding perform_* method from ImplMixin. Every perform_* method takes a context: Context as its first parameter after self - see Architecture for the request pipeline that builds it, and Context Processors for how to use it.

Individual operation mixins ​

Create ​

ClassEndpointDescription
CreateMixin[K, T]POST /Create a single record
BulkOnlyCreateMixin[K, T]POST /bulkCreate multiple records
BulkCreateMixin[K, T]both aboveSingle + bulk create
python
from fastapi_viewsets.context import Context
from fastapi_viewsets.mixins import BulkCreateMixin

class MyViewSet(BulkCreateMixin[int, Item]):
    async def perform_create(self, context: Context, data: Item) -> Item:
        db.append(data)
        return data

    async def perform_bulk_create(self, context: Context, data: list[Item]) -> list[Item]:
        db.extend(data)
        return data

With pk_field_name set on route_viewset, the POST / body carries no primary key, and every other field is validated as Item declares it. perform_create receives an Item without the primary key unless Item gives it a default. See pk_field_name and request body.

List & Retrieve ​

ClassEndpointDescription
ListMixin[T, TFilter]GET /Return all records, with optional filter and sort
RetrieveMixin[K, T]GET /{pk}Return a single record by PK

ListMixin is generic over an optional TFilter type (default: None). The endpoint always exposes a sort query parameter (comma-separated column:direction pairs). When a filter model is also provided, its fields appear as individual query parameters.

Filtering (active when any filter field is set): filter_list(fltr, records) filters in memory. It has no default implementation, so a viewset that declares a filter type implements it — or declares the filter with make_filter_model, which needs no code at all. See Declarative filters.

Sorting (active when the sort query parameter is given): sort_list(sort, records) sorts in memory. The default is a stable multi-key sort with nulls lowest in both directions.

To do either in the backend instead, override apply_filter or apply_sort — see the list pipeline.

python
from fastapi_viewsets.context import Context
from fastapi_viewsets.mixins import ListMixin, RetrieveMixin, make_all_optional, SortState
from fastapi_viewsets.exceptions import NotFoundError

ItemFilter = make_all_optional(Item)

class MyViewSet(ListMixin[Item, ItemFilter], RetrieveMixin[int, Item]):
    async def perform_list(self, context: Context) -> list[Item]:
        return list(db.values())

    async def filter_list(self, fltr: ItemFilter, records: list[Item]) -> list[Item]:
        # filter_list does not receive context - see Context Processors "Known limitations"
        if fltr.name is not None:
            records = [r for r in records if fltr.name.lower() in r.name.lower()]
        return records

    async def perform_retrieve(self, context: Context, pk: int) -> Item:
        if pk not in db:
            raise NotFoundError(pk)
        return db[pk]

Sorting works out of the box without any overrides:

text
GET /items?sort=name:asc,score:desc

For server-side sorting, override apply_sort, order the records in the backend and mark the stage applied so the in-memory sort leaves them alone:

python
async def apply_sort(self, context, query, records):
    if query.has_sort:
        records = records.order_by(*(column.column_name for column in query.sort))
        query.mark_applied("sort")
    return await super().apply_sort(context, query, records)

Update ​

ClassEndpointDescription
UpdateMixin[K, T]PUT /{pk}, PATCH /{pk}Full and partial update
BulkOnlyUpdateMixin[K, T]PUT /bulk, PATCH /bulkBulk full and partial update
BulkUpdateMixin[K, T]all four aboveSingle + bulk update

The partial parameter is False for PUT and True for PATCH.

A PUT body is the whole model. A PATCH body states the fields it changes: every field is optional, the primary key is not part of it, and a field it states is validated by its annotation, its constraints and the model's @field_validators. The model's @model_validators are not applied: they validate a whole model, and a partial body is not one. DjangoORMViewSet applies them to the stored row with the stated fields set on it (Backends). perform_update receives it as the model holding the stated fields only: data.model_fields_set names them, and data.model_dump(exclude_unset=True) is what the request stated; PATCH /bulk does the same for each record. A field read with getattr keeps a nested model as the model; model_dump turns it into a dict.

python
async def perform_update(self, context: Context, pk: int, data: Item, partial: bool = True) -> Item:
    if partial:
        db[pk] = db[pk].model_copy(update={name: getattr(data, name) for name in data.model_fields_set})
    else:
        db[pk] = data
    return db[pk]

Destroy ​

ClassEndpointDescription
DestroyMixin[K, T]DELETE /{pk}Delete a single record
BulkOnlyDestroyMixin[K, T]DELETE /bulkDelete multiple records
BulkDestroyMixin[K, T]both aboveSingle + bulk delete

perform_destroy should return a dict with the deleted PK as key (and any extra info as value).

Lookup ​

LookupMixin[TLookupFilter] adds a GET /lookup endpoint that returns a list of LookupItem objects — useful for populating select/autocomplete widgets.

The mixin is generic over an optional TLookupFilter type (default: LookupFilter with a single q: str | None field). When the filter is active, a two-phase pipeline runs:

  1. setup_lookup_filter(fltr) — pre-filter hook (e.g., build a DB query). No-op by default.
  2. filter_lookup(fltr, items) — post-filter hook. The default implementation filters by fltr.q (case-insensitive substring match on title), so basic search works out of the box.
python
from fastapi_viewsets.mixins import LookupMixin, LookupItem, LookupFilter

# Default behaviour: q= query param, filters by title automatically
class MyViewSet(..., LookupMixin):
    async def perform_lookup(self, context: Context) -> list[LookupItem]:
        return [LookupItem(group=None, pk=i.id, title=i.name, icon=None) for i in db.values()]

# Custom filter model with additional fields
class MyFilter(LookupFilter):
    group: str | None = None

class MyViewSet(..., LookupMixin[MyFilter]):
    async def perform_lookup(self, context: Context) -> list[LookupItem]:
        return [LookupItem(group=i.group, pk=i.id, title=i.name) for i in db.values()]

    async def filter_lookup(self, fltr: MyFilter, items: list[LookupItem]) -> list[LookupItem]:
        if fltr.q is not None:
            items = [i for i in items if fltr.q.lower() in i.title.lower()]
        if fltr.group is not None:
            items = [i for i in items if i.group == fltr.group]
        return items

Combined viewset mixins ​

For convenience, three pre-composed classes are provided:

ClassIncluded actions
ReadOnlyViewSetMixin[K, T]list, retrieve
ViewSetMixin[K, T, TFilter=None]list, retrieve, create, update, partial_update, destroy
BulkViewSetMixin[K, T, TFilter=None]all of the above + bulk_create, bulk_update, bulk_partial_update, bulk_destroy
python
from fastapi_viewsets.mixins import BulkViewSetMixin, make_all_optional

ItemFilter = make_all_optional(Item)

class ItemViewSet(BulkViewSetMixin[int, Item, ItemFilter]):
    async def perform_list(self, context): ...
    async def perform_retrieve(self, context, pk): ...
    async def perform_create(self, context, data): ...
    async def perform_bulk_create(self, context, data): ...
    async def perform_update(self, context, pk, data, partial): ...
    async def perform_bulk_update(self, context, records, partial): ...
    async def perform_destroy(self, context, pk): ...
    async def perform_bulk_destroy(self, context, pk): ...
    async def filter_list(self, fltr, records): ...  # not context-threaded, see Context Processors

Error handling ​

Raise NotFoundError when a record does not exist. It produces a proper 404 response with a JSON body.

python
from fastapi_viewsets.exceptions import NotFoundError

raise NotFoundError(pk)
# → HTTP 404: {"detail": "Item with pk 42 not found"}

Released under the MIT License.