DEV Community

Alexander Valenchits
Alexander Valenchits

Posted on

Stop Writing CRUD Boilerplate for FastAPI — Use ViewSets Instead

If you're building REST APIs with FastAPI, you've probably written this code dozens of times: a router, a list endpoint, a get-by-id endpoint, a create, an update, a delete… and then you do it all over again for the next resource. And the next. And the next.

There's a better way. Meet fastapi-viewsets — a library inspired by Django REST Framework's ViewSets that lets you generate all six standard CRUD endpoints from a single class with one method call.


The Problem: FastAPI CRUD Is Verbose by Design

FastAPI is fantastic for building APIs. It's fast, it's type-safe, it generates OpenAPI docs automatically. But it's deliberately low-level — it gives you routing primitives, not conventions. That means every resource you build looks roughly like this:

from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from typing import List

router = APIRouter()

@router.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()

@router.get("/items/{id}", response_model=ItemSchema)
def get_item(id: int, db: Session = Depends(get_db)):
    obj = db.query(Item).filter(Item.id == id).first()
    if not obj:
        raise HTTPException(status_code=404, detail="Not found")
    return obj

@router.post("/items", response_model=ItemSchema)
def create_item(item: ItemSchema, db: Session = Depends(get_db)):
    obj = Item(**item.dict())
    db.add(obj)
    db.commit()
    db.refresh(obj)
    return obj

@router.patch("/items/{id}", response_model=ItemSchema)
def update_item(id: int, item: ItemSchema, db: Session = Depends(get_db)):
    obj = db.query(Item).filter(Item.id == id).first()
    if not obj:
        raise HTTPException(status_code=404, detail="Not found")
    for key, value in item.dict(exclude_unset=True).items():
        setattr(obj, key, value)
    db.commit()
    db.refresh(obj)
    return obj

@router.delete("/items/{id}")
def delete_item(id: int, db: Session = Depends(get_db)):
    obj = db.query(Item).filter(Item.id == id).first()
    if not obj:
        raise HTTPException(status_code=404, detail="Not found")
    db.delete(obj)
    db.commit()
    return {"status": "ok"}
Enter fullscreen mode Exit fullscreen mode

That's ~50 lines of nearly identical logic. And you'll write it again for users, orders, products, categories... You get the idea.


The Solution: fastapi-viewsets

fastapi-viewsets brings DRF-style ergonomics to FastAPI. Instead of repeating yourself, you declare one class, call .register(), and all the endpoints are wired up automatically — complete with Pydantic validation, OpenAPI docs, and proper HTTP semantics.

Here's the same resource from above, rewritten with the library:

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: int | None = 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

That's it. ~20 lines instead of ~50, and you get the same 5 endpoints. The more resources you have, the more time you save.


What Gets Generated

A single .register() call produces these routes (using LIST, GET, POST, PATCH, DELETE):

Method Path Description
GET /items List all items (paginated)
GET /items/{id} Get single item by ID
POST /items Create a new item
PATCH /items/{id} Partial update (only provided fields)
PUT /items/{id} Full replacement
DELETE /items/{id} Delete by ID

Built-in pagination is included on the LIST endpoint via limit and offset query parameters — no extra code needed:

GET /items?limit=20&offset=40
Enter fullscreen mode Exit fullscreen mode

Multi-ORM Support

fastapi-viewsets isn't locked to one ORM. As of v1.1.0, it ships with pluggable adapters for:

  • SQLAlchemy (sync and async)
  • Tortoise ORM
  • Peewee

You can select your ORM via the ORM_TYPE environment variable, or pass an adapter instance directly:

pip install "fastapi-viewsets[tortoise]"
Enter fullscreen mode Exit fullscreen mode

The same BaseViewset / AsyncBaseViewset interface works regardless of which adapter you're using.


Async Support Out of the Box

If your FastAPI app uses async/await (and if you care about performance, it probably should), use AsyncBaseViewset — it has the exact same interface but all handlers are async:

from fastapi_viewsets import AsyncBaseViewset

items = AsyncBaseViewset(
    endpoint="/items",
    model=Item,
    response_model=ItemSchema,
    db_session=get_async_session,  # your async session factory
    tags=["items"]
)
items.register(methods=["LIST", "GET", "POST", "PATCH", "DELETE"])
app.include_router(items)
Enter fullscreen mode Exit fullscreen mode

Swap BaseViewsetAsyncBaseViewset and get_sessionget_async_session. Nothing else changes.


Authentication in One Line

Want to protect write operations behind OAuth2? Pass your OAuth2PasswordBearer and a list of methods that require a token:

from fastapi.security import OAuth2PasswordBearer

oauth2 = OAuth2PasswordBearer(tokenUrl="/token")

router = BaseViewset(
    endpoint="/items",
    model=Item,
    response_model=ItemSchema,
    db_session=get_session,
    tags=["items"]
)
router.register(
    methods=["LIST", "GET", "POST", "PATCH", "DELETE"],
    oauth_protect=oauth2,
    protected_methods=["POST", "PATCH", "DELETE"]  # read is public, writes require auth
)
Enter fullscreen mode Exit fullscreen mode

GET and LIST remain open. POST, PATCH, and DELETE require a bearer token. The Swagger UI will automatically show the lock icon on protected routes.


Custom Logic: Just Subclass

BaseViewset is an APIRouter under the hood, so you can extend it just like any Python class. Override any handler method to add custom business logic:

class ItemsWithStats(BaseViewset):
    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        # Add custom routes BEFORE register() to avoid path conflicts
        self.add_api_route(
            f"{self.endpoint}/stats",
            self.collection_stats,
            methods=["GET"],
            tags=self.tags or [],
        )

    def collection_stats(self) -> dict:
        return {"resource": self.endpoint.strip("/")}

    def list(self, limit=10, offset=0, search=None, token=None):
        # Add custom filtering logic here
        return super().list(limit=limit, offset=offset)
Enter fullscreen mode Exit fullscreen mode

You're not locked into the generated behavior — it's a starting point, not a cage.


How It Compares

fastapi-viewsets fastapi-crudrouter Hand-rolled FastAPI
Pattern DRF-style ViewSet class + .register() CRUD generator, router per resource Full manual control
Boilerplate Minimal — one class + .register() Low — one router call per resource Maximum
ORM support SQLAlchemy, Tortoise, Peewee via adapters SQLAlchemy, Tortoise, Ormar, Databases, Gino, In-Memory Any ORM you integrate
Async AsyncBaseViewset Supported Fully custom
Auth OAuth2 per-method via register() Custom dependencies Fully custom
Extensibility Subclass + override any handler Limited override options Unlimited
Pagination limit/offset built-in Supported Fully custom
Fits existing routers Yes — BaseViewset is an APIRouter subclass Partial — requires specific integration Yes

The key difference from fastapi-crudrouter is the DRF-style ViewSet pattern — the BaseViewset is a full APIRouter subclass, so it fits naturally into any existing FastAPI app without any special integration.


Installation

pip install fastapi-viewsets

# Optional ORM extras
pip install "fastapi-viewsets[sqlalchemy]"
pip install "fastapi-viewsets[tortoise]"
pip install "fastapi-viewsets[peewee]"
Enter fullscreen mode Exit fullscreen mode

What's Next

The roadmap includes:

  • Server-side search — wire the search query param to real database queries
  • Declarative ordering and advanced filters
  • Dedicated AsyncModelViewSet ergonomics on top of SQLAlchemy 2.x async sessions
  • Richer OpenAPI for nested Pydantic models

Try It

The library is open source under the MIT license. If it saves you time, a ⭐ on GitHub goes a long way!

Stop writing the same CRUD code over and over. Your future self will thank you.


Built by Alexander Valenchits — Tech Lead @ AluSoft, Minsk.

Tags: fastapi python webdev tutorial

Top comments (0)