SQLModel Development Guide
SQLModel combines SQLAlchemy and Pydantic into a single library - one model class serves as both ORM model and Pydantic schema.
Quick Start
Installation
pip install sqlmodel
Minimal Example
from sqlmodel import Field, SQLModel, Session, create_engine, select
class Hero(SQLModel, table=True):
id: int | None = Field(default=None, primary_key=True)
name: str = Field(index=True)
age: int | None = None
engine = create_engine("sqlite:///database.db")
SQLModel.metadata.create_all(engine)
# Create
with Session(engine) as session:
hero = Hero(name="Spider-Boy", age=18)
session.add(hero)
session.commit()
session.refresh(hero)
# Read
with Session(engine) as session:
heroes = session.exec(select(Hero)).all()
Core Concepts
| Concept |
Description |
table=True |
Makes class a database table (without it, it's just Pydantic) |
Field() |
Define column attributes: primary_key, index, unique, foreign_key |
Session |
Database session for CRUD operations |
select() |
Type-safe query builder |
Relationship |
Define relationships between models |
Model Patterns
Base Model (API Schema Only)
class HeroBase(SQLModel):
name: str
age: int | None = None
Table Model (Database)
class Hero(HeroBase, table=True):
id: int | None = Field(default=None, primary_key=True)
Request/Response Models
class HeroCreate(HeroBase):
secret_name: str
class HeroPublic(HeroBase):
id: int
class HeroUpdate(SQLModel):
name: str | None = None
age: int | None = None
CRUD Operations
Create
def create_hero(session: Session, hero: HeroCreate) -> Hero:
db_hero = Hero.model_validate(hero)
session.add(db_hero)
session.commit()
session.refresh(db_hero)
return db_hero
Read
def get_hero(session: Session, hero_id: int) -> Hero | None:
return session.get(Hero, hero_id)
def get_heroes(session: Session, skip: int = 0, limit: int = 100) -> list[Hero]:
return session.exec(select(Hero).offset(skip).limit(limit)).all()
Update
def update_hero(session: Session, hero_id: int, hero_update: HeroUpdate) -> Hero | None:
db_hero = session.get(Hero, hero_id)
if not db_hero:
return None
hero_data = hero_update.model_dump(exclude_unset=True)
db_hero.sqlmodel_update(hero_data)
session.add(db_hero)
session.commit()
session.refresh(db_hero)
return db_hero
Delete
def delete_hero(session: Session, hero_id: int) -> bool:
hero = session.get(Hero, hero_id)
if not hero:
return False
session.delete(hero)
session.commit()
return True
FastAPI Integration
Database Setup
from sqlmodel import SQLModel, Session, create_engine
DATABASE_URL = "sqlite:///./database.db"
engine = create_engine(DATABASE_URL, echo=True)
def create_db_and_tables():
SQLModel.metadata.create_all(engine)
def get_session():
with Session(engine) as session:
yield session
Dependency Injection
from typing import Annotated
from fastapi import Depends
SessionDep = Annotated[Session, Depends(get_session)]
@app.post("/heroes/", response_model=HeroPublic)
def create_hero(hero: HeroCreate, session: SessionDep):
db_hero = Hero.model_validate(hero)
session.add(db_hero)
session.commit()
session.refresh(db_hero)
return db_hero
Lifespan Events
from contextlib import asynccontextmanager
from fastapi import FastAPI
@asynccontextmanager
async def lifespan(app: FastAPI):
create_db_and_tables()
yield
app = FastAPI(lifespan=lifespan)
Reference Files
Load these based on the task at hand:
| Topic |
File |
When to Use |
| Models |
models.md |
Field options, validators, computed fields, inheritance, mixins |
| Relationships |
relationships.md |
One-to-many, many-to-many, self-referential, lazy loading |
| Async |
async.md |
Async sessions, async engine, background tasks |
| Migrations |
migrations.md |
Alembic setup, auto-generation, migration patterns |
Querying
Basic Queries
# All heroes
heroes = session.exec(select(Hero)).all()
# Single result (first or None)
hero = session.exec(select(Hero).where(Hero.name == "Spider-Boy")).first()
# Get by primary key
hero = session.get(Hero, 1)
Filtering
from sqlmodel import select, or_, and_
# Single condition
select(Hero).where(Hero.age >= 18)
# Multiple conditions (AND)
select(Hero).where(Hero.age >= 18, Hero.name == "Spider-Boy")
# OR conditions
select(Hero).where(or_(Hero.age < 18, Hero.age > 60))
# LIKE/contains
select(Hero).where(Hero.name.contains("Spider"))
Ordering and Pagination
select(Hero).order_by(Hero.name)
select(Hero).order_by(Hero.age.desc())
select(Hero).offset(10).limit(5)
Best Practices
- Separate table models from API schemas - Use
table=True only for actual DB tables
- Use
model_validate() for conversion - Convert between schemas and table models
- Use
sqlmodel_update() for partial updates - Pass exclude_unset=True to model_dump()
- Always use
Field() for constraints - Primary keys, indexes, foreign keys, defaults
- Use
Annotated dependencies - Clean, reusable session injection
- Use lifespan for table creation - Not deprecated
@app.on_event
- Index frequently queried columns -
Field(index=True)
- Use
echo=True during development - See generated SQL queries
Common Issues
| Issue |
Solution |
Missing table=True |
Add table=True to models that need DB tables |
| Circular imports |
Use TYPE_CHECKING and string annotations for relationships |
| Session already closed |
Ensure session is still open when accessing lazy-loaded relationships |
| Migration not detecting changes |
Use compare_type=True in Alembic env.py |