DEV Community

Alexander Valenchits
Alexander Valenchits

Posted on

I improved fastapi-viewsets: soft deletes, nested resources, search, and filters—all in one class

How DRF-style ViewSets for FastAPI grew from a “CRUD generator” into a tool I’m not ashamed to ship to production

release info


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.
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode
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)
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

The list endpoint now supports:

  • ?search=term — a case-insensitive substring search across all search_fields (combined with OR);
  • ?ordering=-title,id — ordering, but only by fields listed in ordering_fields. An unknown field returns 400 instead 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']
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

Here’s what changes:

  • DELETE /posts/{id} sets the marker instead of physically deleting the row;
  • LIST and GET hide deleted records. Filtering happens in the database before pagination, so limit=10 really does return ten active records;
  • PUT/PATCH and a repeated DELETE against a deleted record return 404;
  • a new POST /posts/{id}/restore route appears. If you register DELETE, RESTORE is 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")
Enter fullscreen mode Exit fullscreen mode

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
)
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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()
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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.

  1. Configuration next to the data. RelatedConfig and ListConfig live in the schema, while SoftDeleteConfig and NestedConfig go in the viewset constructor. You only need to subclass and override list() for truly custom logic. That option hasn’t gone anywhere: all CRUD handlers remain ordinary methods.
  2. 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 through supports_soft_delete = True and supports_nested = True.
  3. 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"
Enter fullscreen mode Exit fullscreen mode

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)