DEV Community

bigrivi
bigrivi

Posted on

Stop Writing CRUD Boilerplate: Generate a Complete FastAPI API From One Decorator

Stop Writing CRUD Boilerplate: Generate a Complete FastAPI API From One Decorator

Every FastAPI project needs the same endpoints. GET /resource, POST /resource, GET/PUT/DELETE /resource/{id} — plus filtering, pagination, and sorting. And every project writes them by hand. Over. And over.

I've seen codebases with 2,000 lines of nearly identical route handlers, where filtering is bolted on inconsistently, pagination is reinvented per endpoint, and permission checks are copy-pasted with subtle bugs.

There's a better way. BetterCRUD generates the entire CRUD layer from a single decorator — while keeping you in full control.

What you get from one decorator

from fastapi import APIRouter, Depends
from better_crud import crud

pet_router = APIRouter()

@crud(
    pet_router,
    dto={"create": PetCreate, "update": PetUpdate},
    serialize={"base": PetPublic},
)
class PetController():
    service: PetService = Depends(PetService)
Enter fullscreen mode Exit fullscreen mode

That's it. This generates 8 routes:

Route Method Description
/pet GET List with filtering, pagination, sorting
/pet/{id} GET Get one
/pet POST Create one
/pet/bulk POST Create many (atomic)
/pet/{id} PUT Update one (partial)
/pet/{ids}/bulk PUT Update many (atomic)
/pet/{ids} DELETE Delete many
/pet/{id}/recover PATCH Soft-delete recover (opt-in)

The setup

First, a standard async SQLAlchemy setup:

# db.py
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession
from sqlalchemy.orm import sessionmaker
from sqlalchemy.pool import NullPool

engine = create_async_engine("sqlite+aiosqlite:///crud.db", poolclass=NullPool)
SessionLocal = sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)

async def get_session():
    async with SessionLocal() as session:
        yield session
Enter fullscreen mode Exit fullscreen mode

Define your model:

# model.py
from sqlalchemy import String, Integer
from sqlalchemy.orm import Mapped, mapped_column
from db import Base

class Pet(Base):
    __tablename__ = "pet"
    id: Mapped[int] = mapped_column(Integer, primary_key=True)
    name: Mapped[str] = mapped_column(String(100))
    description: Mapped[str] = mapped_column(String(100))
Enter fullscreen mode Exit fullscreen mode

Your schemas:

# schema.py
from pydantic import BaseModel
from typing import Optional

class PetBase(BaseModel):
    name: Optional[str] = None
    description: Optional[str] = None

class PetPublic(PetBase):
    id: int

class PetCreate(PetBase):
    pass

class PetUpdate(PetBase):
    pass
Enter fullscreen mode Exit fullscreen mode

A thin service:

# service.py
from better_crud.service.sqlalchemy import SqlalchemyCrudService
from model import Pet

class PetService(SqlalchemyCrudService[Pet]):
    def __init__(self):
        super().__init__(Pet)
Enter fullscreen mode Exit fullscreen mode

And wire it all together:

# main.py
from fastapi import FastAPI
from better_crud import BetterCrudGlobalConfig

BetterCrudGlobalConfig.init(
    backend_config={"sqlalchemy": {"db_session": get_session}}
)

app = FastAPI()
app.include_router(pet_router, prefix="/pet")
Enter fullscreen mode Exit fullscreen mode

You now have a complete, documented (OpenAPI/Swagger) CRUD API.

The real power: everything comes free

Rich filtering

The generated GET /pet endpoint supports 27 filter operators out of the box:

# exact match
GET /pet?filter=name||$eq||Rex

# contains
GET /pet?filter=name||$cont||Re

# range
GET /pet?filter=age||$between||1,5

# in list
GET /pet?filter=species||$in||dog,cat
Enter fullscreen mode Exit fullscreen mode

And JSON search with nested logic:

GET /pet?s={"name":{"$cont":"Re"},"$or":[{"age":{"$gt":3}},{"species":{"$eq":"cat"}}]}
Enter fullscreen mode Exit fullscreen mode

Pagination — three modes

Control pagination behavior globally or per-route:

BetterCrudGlobalConfig.init(
    pagination_mode="always",   # "always" | "optional" | "disabled"
)
Enter fullscreen mode Exit fullscreen mode
  • always — always return {items, total, page, size, pages}
  • optional (default) — paginated only when page/size passed; otherwise a plain array
  • disabled — never paginate

Perfect for small reference datasets that frontends need as a full array.

Relationship queries & storage

# One-to-many, many-to-many, one-to-one — all handled automatically
class UserCreate(UserBase):
    profile: Optional[UserProfileCreate] = None
    roles: Optional[List[int]] = None
    tasks: Optional[List[UserTaskCreate]] = None
Enter fullscreen mode Exit fullscreen mode

Post a nested payload and BetterCRUD stores the relationships for you. Query them with ?load= and ?join=.

Soft delete + recover

@crud(
    router,
    query={"soft_delete": True, "allow_recover": True},
)
Enter fullscreen mode Exit fullscreen mode

Deletes become soft deletes; PATCH /pet/{id}/recover brings records back.

ACL hooks

Every generated route exposes its feature and action on the request state, so permission guards slot in naturally:

from better_crud import get_feature, get_action

async def acl(request: Request):
    feature = get_feature(request)   # e.g. "pet"
    action = get_action(request)     # e.g. "read", "create", "update"
    # your permission logic
Enter fullscreen mode Exit fullscreen mode

Lifecycle hooks

class PetService(SqlalchemyCrudService[Pet]):
    async def on_before_create(self, pet_create: PetCreate, **kwargs):
        pet_create.name = pet_create.name.title()
Enter fullscreen mode Exit fullscreen mode

Custom endpoints for business logic

CRUD doesn't cover everything. Attach business actions with @crud_action:

@crud_action(method="POST", path="/{id}/adopt", action="adopt")
async def adopt(self, id: int):
    return {"id": id, "adopted": True}
Enter fullscreen mode Exit fullscreen mode

This registers POST /pet/{id}/adopt inside the CRUD ecosystem — with service injection, ACL, and response schema wrapping.

Migrating from fastapi-crudrouter

fastapi-crudrouter — the long-time de-facto CRUD library — has been unmaintained since November 2023. If you're on it, the routes are nearly identical, so migration is mostly drop-in:

# Before
from fastapi_crudrouter import SQLAlchemyCRUDRouter
router = SQLAlchemyCRUDRouter(
    schema=PetCreate, create_schema=PetCreate,
    update_schema=PetUpdate, db_model=Pet, db=get_session,
)

# After
from better_crud import crud
pet_router = APIRouter()

@crud(pet_router,
      dto={"create": PetCreate, "update": PetUpdate},
      serialize={"base": PetPublic})
class PetController():
    service: PetService = Depends(PetService)
Enter fullscreen mode Exit fullscreen mode

Same REST semantics — but you gain 27 filter operators, pagination modes, ACL, soft delete, relationship storage, and an overridable service layer.

Production-ready by default

  • 99%+ test coverage with 177 passing tests
  • Fully async (SQLAlchemy 2.0)
  • Works with SQLAlchemy and SQLModel
  • Extensible: custom backends, custom response schemas, custom pagination models
  • Class-based views and functional views (crud_generator)

Try it

pip install better-crud
Enter fullscreen mode Exit fullscreen mode

Full docs: https://bigrivi.github.io/better_crud/

Source: https://github.com/bigrivi/better_crud

Next steps

  • Star the repo on GitHub if BetterCRUD saves you time — it directly helps more developers find it
  • 📖 Read the full docs for deep dives: relationship storage, custom backends, response schemas, and more
  • 🐛 Report bugs or request features via issues
  • 💬 Migrating from fastapi-crudrouter? See the migration guide

If BetterCRUD saves you time, give it a ⭐ on GitHub — it helps more developers discover it.

Top comments (0)