Skip to content

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:

models.py
from typing import Optional
from 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] = None

Five separate classes just to handle one database table. I was defining the same fields repeatedly with slight variations:

  • HeroBase contains shared fields
  • Hero is the actual table model with a primary key
  • HeroPublic returns data to clients (excludes secret_name)
  • HeroCreate accepts data from clients (includes secret_name but no id)
  • HeroUpdate makes 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:

models_mixin.py
from typing import Optional
from 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: str

This 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:

models_dynamic.py
from typing import Optional
from pydantic import model_validator
from 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
pass

This 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:

models_pep827.py
from typing import Optional
from sqlmodel import Field, SQLModel
from 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 derivation
type 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:

  1. Excludes hidden fields - Fields marked with hidden=True are removed
  2. Makes primary keys non-optional - id: Optional[int] becomes id: int
public_derivation.py
# 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 hidden

This 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:

  1. Excludes primary keys - The id field is removed entirely
  2. Keeps all other fields - Including hidden fields like secret_name
create_derivation.py
# 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 database

This 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:

  1. Makes all fields optional - Every field becomes Optional[T]
  2. Excludes primary keys - The id field is removed
  3. Keeps hidden fields - For updating sensitive data
update_derivation.py
# 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 path

This 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:

main.py
from typing import Optional
from fastapi import FastAPI, HTTPException
from sqlmodel import Field, SQLModel, Session, select
from 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_hero

FastAPI’s automatic documentation correctly shows the schemas:

  • POST /heroes/ accepts HeroCreate (no id, includes secret_name)
  • GET /heroes/{hero_id} returns HeroPublic (no secret_name, id is required)
  • PATCH /heroes/{hero_id} accepts HeroUpdate (all fields optional, no id)

Migration Strategy

I migrated my existing codebase incrementally:

  1. Added hidden=True to sensitive fields in existing models
  2. Verified primary key fields had primary_key=True in their Field() definition
  3. Added type aliases alongside existing classes
  4. Updated endpoint signatures to use the new type aliases
  5. Removed the old duplicate classes after confirming tests passed
migration_example.py
# Step 1: Mark sensitive fields
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) # Added hidden=True
# Step 2: Add type aliases
type 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 unchanged

The 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:

custom_validation.py
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 v

The 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:

complex_types.py
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 self

In 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:

adding_fields.py
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] = None

My 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:

Oh, and if you found these resources useful, don’t forget to support me by starring the repo on GitHub!

Comments