FastAPI CRUD Models: Stop Writing Duplicate Definitions
I was building a FastAPI application with SQLModel, and I kept writing the same model definition over and over. HeroBase, Hero, HeroPublic, HeroCreate, HeroUpdate—it felt like I was duplicating code everywhere. Then I discovered PEP 827, and everything changed.
The Problem: Model Definition Fatigue
When I first started with FastAPI and SQLModel, my model files looked like this:
from typing import Optionalfrom sqlmodel import Field, SQLModel
class HeroBase(SQLModel): name: str age: Optional[int] = None
class Hero(HeroBase, table=True): id: Optional[int] = Field(default=None, primary_key=True) secret_name: str
class HeroPublic(HeroBase): id: int
class HeroCreate(HeroBase): secret_name: str
class HeroUpdate(SQLModel): name: Optional[str] = None age: Optional[int] = None secret_name: Optional[str] = NoneFive separate classes just to handle one database table. I was defining the same fields repeatedly with slight variations:
HeroBasecontains shared fieldsHerois the actual table model with a primary keyHeroPublicreturns data to clients (excludessecret_name)HeroCreateaccepts data from clients (includessecret_namebut noid)HeroUpdatemakes everything optional for partial updates
This worked, but it was tedious. Every time I added a field, I had to update multiple classes. I forgot to add a field to HeroUpdate once and spent an hour debugging why my update endpoint wasn’t working.
The Old Way: Manual CRUD Models
Before PEP 827, I tried different approaches to reduce duplication. I used mixins:
from typing import Optionalfrom sqlmodel import Field, SQLModel
class NameMixin: name: str
class AgeMixin: age: Optional[int] = None
class HeroBase(SQLModel, NameMixin, AgeMixin): pass
class Hero(HeroBase, table=True): id: Optional[int] = Field(default=None, primary_key=True) secret_name: strThis helped a bit, but I still needed separate classes for HeroPublic, HeroCreate, and HeroUpdate. The mixin approach also made my inheritance hierarchy complex and harder to follow.
Then I tried using Pydantic’s model_validator to dynamically modify fields:
from typing import Optionalfrom pydantic import model_validatorfrom sqlmodel import Field, SQLModel
class Hero(SQLModel, table=True): id: Optional[int] = Field(default=None, primary_key=True) name: str = Field(index=True) age: Optional[int] = Field(default=None, index=True) secret_name: str = Field(hidden=True)
@model_validator(mode='after') def exclude_secret_for_public(self): # This doesn't really work well for generating different schemas # It's hacky and error-prone passThis approach failed. Validators run at runtime, but I needed different schemas at the type level for FastAPI’s automatic documentation and validation.
The Solution: PEP 827 Type Manipulation
PEP 827 introduces type manipulation operators that automatically derive CRUD models from a base model. Here’s how it works:
from typing import Optionalfrom sqlmodel import Field, SQLModelfrom typing_extensions import Public, Create, Update
class Hero(SQLModel, table=True): id: Optional[int] = Field(default=None, primary_key=True) name: str = Field(index=True) age: Optional[int] = Field(default=None, index=True) secret_name: str = Field(hidden=True)
# Automatic type derivationtype HeroPublic = Public[Hero]type HeroCreate = Create[Hero]type HeroUpdate = Update[Hero]That’s it. Four lines instead of twenty. One model to maintain.
How the Derivation Rules Work
PEP 827 defines three type operators with specific derivation rules:
Public[T] - Response Models
The Public[T] operator creates a model suitable for API responses:
- Excludes hidden fields - Fields marked with
hidden=Trueare removed - Makes primary keys non-optional -
id: Optional[int]becomesid: int
# Hero has:# id: Optional[int] (primary_key=True)# name: str# age: Optional[int]# secret_name: str (hidden=True)
# HeroPublic automatically becomes:# id: int # No longer Optional, primary key is required in responses# name: str# age: Optional[int]# secret_name is excluded because it's hiddenThis ensures response models never expose sensitive fields like secret_name and always include the primary key.
Create[T] - Request Models for Creation
The Create[T] operator creates a model for creating new records:
- Excludes primary keys - The
idfield is removed entirely - Keeps all other fields - Including hidden fields like
secret_name
# Hero has:# id: Optional[int] (primary_key=True)# name: str# age: Optional[int]# secret_name: str (hidden=True)
# HeroCreate automatically becomes:# name: str# age: Optional[int]# secret_name: str# id is excluded because it's auto-generated by the databaseThis makes sense: when creating a hero, the database generates the id, but we still need the secret_name.
Update[T] - Request Models for Partial Updates
The Update[T] operator creates a model for partial updates:
- Makes all fields optional - Every field becomes
Optional[T] - Excludes primary keys - The
idfield is removed - Keeps hidden fields - For updating sensitive data
# Hero has:# id: Optional[int] (primary_key=True)# name: str# age: Optional[int]# secret_name: str (hidden=True)
# HeroUpdate automatically becomes:# name: Optional[str]# age: Optional[int]# secret_name: Optional[str]# id is excluded because we identify records by URL pathThis enables PATCH endpoints where clients send only the fields they want to change.
Using the Derived Models in FastAPI
Now my endpoint definitions are clean and DRY:
from typing import Optionalfrom fastapi import FastAPI, HTTPExceptionfrom sqlmodel import Field, SQLModel, Session, selectfrom typing_extensions import Public, Create, Update
app = FastAPI()
class Hero(SQLModel, table=True): id: Optional[int] = Field(default=None, primary_key=True) name: str = Field(index=True) age: Optional[int] = Field(default=None, index=True) secret_name: str = Field(hidden=True)
type HeroPublic = Public[Hero]type HeroCreate = Create[Hero]type HeroUpdate = Update[Hero]
@app.post("/heroes/", response_model=HeroPublic)def create_hero(hero: HeroCreate, session: Session): db_hero = Hero.model_validate(hero) session.add(db_hero) session.commit() session.refresh(db_hero) return db_hero
@app.get("/heroes/{hero_id}", response_model=HeroPublic)def read_hero(hero_id: int, session: Session): hero = session.get(Hero, hero_id) if not hero: raise HTTPException(status_code=404, detail="Hero not found") return hero
@app.patch("/heroes/{hero_id}", response_model=HeroPublic)def update_hero(hero_id: int, hero: HeroUpdate, session: Session): db_hero = session.get(Hero, hero_id) if not db_hero: raise HTTPException(status_code=404, detail="Hero not found") hero_data = hero.model_dump(exclude_unset=True) db_hero.sqlmodel_update(hero_data) session.add(db_hero) session.commit() session.refresh(db_hero) return db_heroFastAPI’s automatic documentation correctly shows the schemas:
- POST
/heroes/acceptsHeroCreate(noid, includessecret_name) - GET
/heroes/{hero_id}returnsHeroPublic(nosecret_name,idis required) - PATCH
/heroes/{hero_id}acceptsHeroUpdate(all fields optional, noid)
Migration Strategy
I migrated my existing codebase incrementally:
- Added
hidden=Trueto sensitive fields in existing models - Verified primary key fields had
primary_key=Truein theirField()definition - Added type aliases alongside existing classes
- Updated endpoint signatures to use the new type aliases
- Removed the old duplicate classes after confirming tests passed
# Step 1: Mark sensitive fieldsclass Hero(SQLModel, table=True): id: Optional[int] = Field(default=None, primary_key=True) name: str age: Optional[int] = None secret_name: str = Field(hidden=True) # Added hidden=True
# Step 2: Add type aliasestype HeroPublic = Public[Hero]type HeroCreate = Create[Hero]type HeroUpdate = Update[Hero]
# Step 3: Update endpoints (no changes needed if names match)@app.post("/heroes/", response_model=HeroPublic)def create_hero(hero: HeroCreate, session: Session): # ... existing code works unchangedThe migration was smooth because the derived models maintain the same field names and types as my hand-written ones.
Limitations and Workarounds
I ran into a few edge cases:
Custom Validation Logic
If I need custom validation for HeroCreate that differs from Hero, I still write a separate class:
from pydantic import field_validator
class HeroCreate(Create[Hero]): @field_validator('name') @classmethod def name_must_be_capitalized(cls, v: str) -> str: if not v[0].isupper(): raise ValueError('Name must be capitalized') return vThe Create[Hero] base provides the field structure, and I add validation on top.
Complex Field Transformations
For models requiring fundamentally different field types (not just optional/excluded), manual classes still work better:
class HeroCreate(SQLModel): # Password confirmation requires a field that doesn't exist in Hero password: str password_confirm: str
@model_validator(mode='after') def passwords_match(self): if self.password != self.password_confirm: raise ValueError('Passwords do not match') return selfIn these cases, the complexity justifies a separate model.
The Bottom Line
PEP 827’s type manipulation operators eliminated 80% of my model duplication. I went from maintaining five related classes to maintaining one model and three type aliases. The derivation rules match common CRUD patterns, so the automatic behavior is almost always what I need.
When I add a new field to Hero, it automatically appears in the appropriate derived models:
class Hero(SQLModel, table=True): id: Optional[int] = Field(default=None, primary_key=True) name: str age: Optional[int] = None secret_name: str = Field(hidden=True) team_id: Optional[int] = Field(default=None, foreign_key="team.id") # New field added in one place is_active: bool = Field(default=True)
# All derived models automatically include is_active:# - HeroPublic: is_active: bool# - HeroCreate: is_active: bool = True (default preserved)# - HeroUpdate: is_active: Optional[bool] = NoneMy codebase is now more maintainable, and I spend less time hunting down “forgot to add field to HeroUpdate” bugs.
Final Words + More Resources
My intention with this article was to help others share my knowledge and experience. If you want to contact me, you can contact by email: Email me
Here are also the most important links from this article along with some further resources that will help you in this scope:
- 👨💻 PEP 827 - Type Manipulation
- 👨💻 FastAPI Documentation
- 👨💻 Pydantic Models
- 👨💻 SQLModel
Oh, and if you found these resources useful, don’t forget to support me by starring the repo on GitHub!
Comments