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 whenpage/sizepassed; 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.
답글 남기기