How DRF-style ViewSets for FastAPI grew from a “CRUD generator” into a tool I’m not ashamed to ship to production
If you’ve ever built anything more complex than a demo project with FastAPI, you probably know the feeling. A new entity appears, and once again you write the same five endpoints: list, retrieve by ID, create, update, and delete. Once again, there’s limit and offset. Once again, you raise HTTPException(404) when a record doesn’t exist. Once again, you end up debating the right way to handle PATCH. A month later, the product manager shows up and says, “Permanent deletion is off the table; I need a trash bin.” And you start all over again.
That’s exactly why I originally built fastapi-viewsets—Django REST Framework-style ViewSets for FastAPI. You describe a model and a Pydantic schema and get a ready-to-use APIRouter with CRUD. The library works with SQLAlchemy (sync and async), Tortoise ORM, and Peewee.
Over the past six months, I’ve improved it significantly. I started with declarative eager loading in 1.3.0, then released 1.7.0 in October with soft deletes and nested resources. Below, I’ll walk you through what I built, why I needed it, and how it works in code.
The starting point: the real cost of a basic CRUD
Let’s start with what the library eliminates. Here’s a typical CRUD implementation in plain FastAPI with SQLAlchemy—and it’s already heavily trimmed down:
@app.get("/items", response_model=list[ItemSchema])
def list_items(limit: int = 10, offset: int = 0, db: Session = Depends(get_db)):
return db.query(Item).offset(offset).limit(limit).all()
@app.get("/items/{id}", response_model=ItemSchema)
def get_item(id: int, db: Session = Depends(get_db)):
item = db.get(Item, id)
if item is None:
raise HTTPException(status_code=404, detail="Not found")
return item
@app.post("/items", response_model=ItemSchema)
def create_item(payload: ItemSchema, db: Session = Depends(get_db)):
item = Item(**payload.model_dump(exclude={"id"}))
db.add(item)
db.commit()
db.refresh(item)
return item
# ...plus PUT, PATCH, and DELETE. Repeat for every model.
There’s still no search, ordering, filtering, IntegrityError handling, or protection against a negative limit. Here’s the same example with fastapi-viewsets:
pip install "fastapi-viewsets[sqlalchemy]"
pip install uvicorn # to run the application
from typing import Optional
from fastapi import FastAPI
from pydantic import BaseModel, ConfigDict
from sqlalchemy import Column, Integer, String
from fastapi_viewsets import BaseViewset
from fastapi_viewsets.db_conf import Base, engine, get_session
app = FastAPI()
class Item(Base):
__tablename__ = "items"
id = Column(Integer, primary_key=True)
name = Column(String(255), nullable=False)
class ItemSchema(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: Optional[int] = None
name: str
Base.metadata.create_all(bind=engine)
items = BaseViewset(
endpoint="/items",
model=Item,
response_model=ItemSchema,
db_session=get_session,
tags=["items"],
)
items.register(methods=["LIST", "GET", "POST", "PATCH", "DELETE"])
app.include_router(items)
BaseViewset inherits from APIRouter, so you can include it with a regular include_router. The register() method creates the routes GET /items, GET /items/{id}, POST /items, PATCH /items/{id}, and DELETE /items/{id}. Call it without arguments, and it registers every method, including PUT. For asynchronous code, there’s AsyncBaseViewset with the same API. It needs an async session, such as get_async_session from fastapi_viewsets.db_conf, and a driver like aiosqlite or asyncpg.
That was the baseline. Now let’s look at what I added.
Chapter 1. Enough N+1: declarative eager loading (1.3.0)
The first pain point came from nested schemas. As soon as the response includes author: AuthorSchema, SQLAlchemy issues a separate query for every row in the list. That’s the classic N+1 problem.
I didn’t want to override list() in every viewset just to deal with it. So I decided to keep relationship-loading settings right in the schema, next to the fields that need them. That’s how the internal RelatedConfig class appeared in version 1.3.0:
class AuthorSchema(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: int
name: str
class PostSchema(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: int
title: str
author: AuthorSchema
class RelatedConfig:
select_related = ["author"] # FK / many-to-one → JOIN
prefetch_related = ["tags"] # collections / M2M → a separate SELECT IN
You don’t need to change the viewset itself: BaseViewset and AsyncBaseViewset pass these lists to the ORM adapter for LIST and GET. In SQLAlchemy, they map to joinedload and selectinload. Tortoise passes both lists to its native prefetch_related(), while Peewee turns select_related into .join().
Chapter 2. Search, ordering, and filters without a single line in the viewset (1.5.0)
The next question I kept hearing was, “How do I add ?search=?” My usual answer used to be, “Override list().” An honest answer, but a boring one.
In 1.5.0, I took the same approach as with RelatedConfig and added an internal ListConfig class:
class PostSchema(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: Optional[int] = None
title: str
status: str = "draft"
class ListConfig:
search_fields = ["title"] # ?search=fastapi
ordering_fields = ["title", "id"] # ?ordering=-title,id
ordering = ["-id"] # default ordering
filters = ["status", "id"] # ?status=published, ?id__gte=2
The list endpoint now supports:
-
?search=term— a case-insensitive substring search across allsearch_fields(combined with OR); -
?ordering=-title,id— ordering, but only by fields listed inordering_fields. An unknown field returns400instead of being sent to the database; -
filters — exact matches and the operators
__ne,__gt,__gte,__lt,__lte,__contains, and__in. Values are coerced to int/float/bool.
GET /posts?search=fastapi&ordering=title
GET /posts?status=published&id__gte=2
GET /posts?status__in=draft,published&limit=20&offset=40
GET /posts?ordering=secret → 400 Invalid ordering field: 'secret'. Allowed fields: ['id', 'title']
What mattered most to me was the allowlist: only explicitly listed fields reach the ORM. Filters on unknown fields are simply ignored, while unknown ordering fields return 400. All of this works across the four backends: SQLAlchemy sync/async, Tortoise, and Peewee. In 1.5.2, I also exposed the filters and their __op variants in OpenAPI, so you can see them in Swagger UI too.
Chapter 3. Fixing my mistakes: the changes people usually leave out of release posts (1.5.1–1.5.3)
Not every improvement makes for a flashy announcement. In September and October, I worked on the unglamorous things a library needs before you can trust it. I’ll be honest: some of these bugs were nasty.
Schema leakage between viewsets. Previously, register() modified the __annotations__ of shared handler functions at the class level. As a result, in a process with two or more viewsets, every POST/PUT/PATCH validated the request body against the schema belonging to the last registered viewset. In 1.5.2, I started cloning the handlers for each instance.
PATCH is now genuinely partial. PATCH bodies are validated by an automatically generated <Schema>Patch schema in which every field is optional. An explicit null clears a nullable column and no longer gets lost. In 1.5.3, I finished the job: the partial schema preserves field validators, Field constraints, aliases, and the model configuration. Model-level validators don’t run against incomplete payloads. If you need invariants across fields, validate them against the final record.
PUT no longer wipes data. Previously, columns absent from the schema were turned into NULL on PUT. That was actual data loss. Now only the fields actually present in the request are written, and the primary key is never overwritten.
409 instead of leaking SQL. Database constraint violations now return 409 Conflict with a neutral message. Raw SQL, parameters, and driver internals no longer make their way into the response.
Honest packaging. The base pip install fastapi-viewsets now installs only FastAPI, Pydantic, and python-dotenv. SQLAlchemy comes via the [sqlalchemy] extra, which also brings in SQLAlchemy[asyncio] and greenlet. Python 3.14 support arrived in 1.4.0, and CI now runs all ORM adapters across the entire Python version matrix.
Chapter 4. A trash bin instead of DELETE FROM: soft deletes (1.7.0)
Now for the headline feature of the latest release. Sooner or later, almost every project I worked on grew an is_deleted flag. Then the same checks spread through the codebase: don’t show deleted records in lists, return 404 on GET, and don’t allow deleted records to be edited. And there was always one place where a check got missed.
In 1.7.0, you can enable soft deletes with a single parameter—soft_delete=SoftDeleteConfig():
from sqlalchemy import Boolean, Column
from fastapi_viewsets import BaseViewset, SoftDeleteConfig
class Post(Base):
__tablename__ = "posts"
id = Column(Integer, primary_key=True)
title = Column(String, nullable=False)
is_deleted = Column(Boolean, nullable=False, default=False, server_default="false")
posts = BaseViewset(
endpoint="/posts",
model=Post,
response_model=PostSchema,
db_session=get_session,
soft_delete=SoftDeleteConfig(),
)
posts.register()
app.include_router(posts)
Here’s what changes:
-
DELETE /posts/{id}sets the marker instead of physically deleting the row; -
LISTandGEThide deleted records. Filtering happens in the database before pagination, solimit=10really does return ten active records; -
PUT/PATCHand a repeatedDELETEagainst a deleted record return404; - a new
POST /posts/{id}/restoreroute appears. If you registerDELETE,RESTOREis added automatically and inherits its OAuth2 protection; - the client cannot change the marker through POST/PUT/PATCH bodies or bypass the “active only” restriction through query filters.
If you need to know when a record was deleted, use a timestamp marker:
policy = SoftDeleteConfig(field="deleted_at", mode="timestamp")
Restoration uses the same protection as deletion:
from fastapi.security import OAuth2PasswordBearer
posts.register(
oauth_protect=OAuth2PasswordBearer(tokenUrl="/token"),
protected_methods=["DELETE"], # RESTORE inherits this protection
)
I deliberately left the database schema under your control: the library does not modify it. Add and backfill the marker column in your own migration before enabling the option. The marker does not cascade to related records. Direct ORM queries and other viewsets using the same model do not inherit this policy. By default, soft_delete=None, and everything works as before: a physical DELETE and the same routes.
Chapter 5. /posts/{post_id}/comments: nested resources (1.7.0)
The second major feature in this release is nested resources. Previously, you had to build a route like /posts/{post_id}/comments by hand. You needed to check that the post existed, filter comments by post_id, set post_id when creating a comment, and make sure a PATCH request couldn’t move a comment to someone else’s post. Every one of those steps was a potential access-control hole.
Now NestedConfig handles all of that:
from fastapi_viewsets import BaseViewset, NestedConfig
from fastapi_viewsets.orm.sqlalchemy_adapter import SQLAlchemyAdapter
adapter = SQLAlchemyAdapter(database_url="sqlite:///blog.db")
Base = adapter.get_base()
class Comment(Base):
__tablename__ = "comments"
id = Column(Integer, primary_key=True)
post_id = Column(Integer, ForeignKey("posts.id"), nullable=False)
text = Column(String, nullable=False)
class CommentSchema(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: Optional[int] = None
post_id: int
text: str
comments = BaseViewset(
endpoint="/posts/{post_id}/comments",
model=Comment,
response_model=CommentSchema,
db_session=adapter.get_session,
orm_adapter=adapter,
nested=NestedConfig(parent_model=Post, foreign_key="post_id"),
)
comments.register()
app.include_router(comments)
Now you can create a comment with the body {"text": "Hello"}, and post_id comes from the URL. Even if the client sends "post_id": 3 in the JSON, the URL wins: the value is injected before body validation and checked again at the ORM level. A missing parent or a comment belonging to a different post returns 404. Search, filters, ordering, and pagination are all scoped to the parent.
You can access the parent inside a handler through self.get_parent() or request.state.viewset_parent. The context is bound to the request and works correctly in both sync and async handlers. I’ve seen nesting confused with authorization more than once, so I added an explicit extension point—resolve_parent(request):
from fastapi import HTTPException, Request
from fastapi_viewsets import AsyncBaseViewset
class AuthorizedComments(AsyncBaseViewset):
async def resolve_parent(self, request: Request):
parent = await super().resolve_parent(request)
# your authentication dependency sets request.state.user
if not request.state.user.can_access(parent):
raise HTTPException(status_code=403, detail="Forbidden parent")
return parent
Both 1.7.0 features work together. If you pass parent_soft_delete, a deleted post blocks all operations on its comments, including restoring them:
policy = SoftDeleteConfig()
comments = BaseViewset(
endpoint="/posts/{post_id}/comments",
model=Comment,
response_model=CommentSchema,
db_session=get_session,
soft_delete=policy,
nested=NestedConfig(parent_model=Post, foreign_key="post_id", parent_soft_delete=policy),
)
comments.register()
Among the other options, path_param lets you customize the URL parameter name—for example, when the parent path is defined on an outer APIRouter(prefix="/posts/{post_pk}"). Integer, string, and UUID parent keys are supported. There are limitations too: the parent must have a single primary key named id, and each viewset supports one parent.
What this looks like in practice
I put together a small blog from the examples above: posts with ListConfig and soft deletes, plus comments via NestedConfig. Here are the actual responses from 1.7.0:
POST /posts {"title": "FastAPI tips", "status": "published"} → {"id": 1, ...}
GET /posts?search=fastapi&ordering=title → posts 1 and 3
POST /posts/1/comments {"text": "hi", "post_id": 3} → {"id": 2, "post_id": 1, "text": "hi"}
GET /posts/2/comments → []
DELETE /posts/1 → {"status": true, "text": "successfully deleted"}
GET /posts/1 → 404
GET /posts/1/comments → 404 (parent is soft-deleted)
POST /posts/1/restore → {"status": true, "text": "successfully restored"}
POST /posts/1/restore → 404 (already active)
GET /posts?limit=-1 → 422
Notice the third line: the client tried to create a comment for post #3, but the URL won.
How I approached it
In short, I followed three rules.
-
Configuration next to the data.
RelatedConfigandListConfiglive in the schema, whileSoftDeleteConfigandNestedConfiggo in the viewset constructor. You only need to subclass and overridelist()for truly custom logic. That option hasn’t gone anywhere: all CRUD handlers remain ordinary methods. -
Everything new is opt-in. All new parameters default to
None. Third-party ORM adapters keep working. An adapter explicitly enables support for the new features throughsupports_soft_delete = Trueandsupports_nested = True. - Secure by default. Field allowlists for filters and ordering, the URL taking precedence over the request body, 409 responses without SQL, and a deletion marker that clients cannot access.
By the 1.7.0 release, the test suite had grown to 496 tests. They run in two environments: Python 3.13 with current FastAPI and Pydantic, and an older stack with Python 3.11, FastAPI 0.110, Pydantic 2.5, and SQLAlchemy 2.0.0. Coverage is around 90%. A separate note on Python 3.9: it is functionally supported, but some of its dependencies have known vulnerabilities that cannot be fixed on that version. For production, I recommend 3.11+.
What’s next
The README has a roadmap. It includes transaction helpers (begin / atomic) for all adapters, date range filters (date__range), null checks (field__isnull), and search across related models. If you need any of these, open an issue: it helps me decide what to tackle first.
Try it
pip install --upgrade "fastapi-viewsets[sqlalchemy]==1.7.0"
- GitHub: https://github.com/svalench/fastapi_viewsets
- PyPI: https://pypi.org/project/fastapi-viewsets/
- Documentation: https://svalench.github.io/fastapi_viewsets/
- Release notes 1.7.0: https://github.com/svalench/fastapi_viewsets/blob/master/RELEASE_1.7.0.md
If the library has saved you even one evening of writing yet another CRUD, give it a ⭐ on GitHub—it helps others find the project. Bug reports, ideas, and pull requests are welcome. The repository has a “PRs welcome” badge, and I genuinely mean it.
Thanks for reading! Let me know in the comments which feature you miss most in out-of-the-box FastAPI.

Top comments (0)