| name | sqlmodel |
| description | Build SQL database integrations with SQLModel for FastAPI projects. Use when working with databases in Python, defining ORM models, creating CRUD operations, managing sessions, or integrating SQL databases with FastAPI. SQLModel combines Pydantic v2 and SQLAlchemy into a single unified API. |
SQLModel Skill
SQLModel is a Python library for interacting with SQL databases using Python objects. It combines Pydantic v2 (data validation) and SQLAlchemy (ORM) into a unified, type-safe API. It is created by the same author as FastAPI and designed to work seamlessly with it.
When to Use This Skill
- Adding a SQL database (SQLite, PostgreSQL, MySQL) to a FastAPI project
- Defining ORM models that also serve as Pydantic schemas
- Creating CRUD endpoints with proper request/response validation
- Managing database sessions and migrations
- Implementing relationships (one-to-many, many-to-many) between entities
- Replacing raw JSON file storage with a proper database layer
Installation
pip install sqlmodel
pip install sqlmodel asyncpg
pip install sqlmodel psycopg2-binary
Core Concepts
1. Model Types
SQLModel has two kinds of models:
| Type | table=True | Stored in DB | Pydantic model | SQLAlchemy model |
|---|
| Table model | ✅ Yes | ✅ Yes | ✅ Yes | ✅ Yes |
| Data model | ❌ No | ❌ No | ✅ Yes | ❌ No |
Use table models for database entities and data models for API request/response schemas.
2. Inheritance Pattern (recommended)
Avoid field duplication by using a base data model:
HeroBase (data model — shared fields)
├── Hero (table=True — adds id as primary key)
├── HeroCreate (data model — for POST requests, no id)
├── HeroPublic (data model — for GET responses, id required)
└── HeroUpdate (data model — all fields optional, for PATCH)
Complete FastAPI + SQLModel Example
Project Structure
app/
├── core/
│ └── database.py # Engine and session dependency
├── models/
│ └── hero.py # SQLModel models (table + data)
├── routers/
│ └── heroes.py # FastAPI router with CRUD endpoints
└── main.py # FastAPI app with lifespan
Pattern 1: Database Engine and Session
"""Database engine configuration and session dependency."""
from sqlmodel import SQLModel, Session, create_engine
from ..config import get_settings
settings = get_settings()
connect_args = {"check_same_thread": False}
engine = create_engine(
settings.database_url,
echo=False,
connect_args=connect_args,
)
def create_db_and_tables() -> None:
"""Create all tables defined by SQLModel models with table=True."""
SQLModel.metadata.create_all(engine)
def get_session():
"""FastAPI dependency: yields a database session for the current request."""
with Session(engine) as session:
yield session
Pattern 2: Models with Inheritance
"""Hero SQLModel models — table model + API data models."""
from sqlmodel import Field, SQLModel
class HeroBase(SQLModel):
"""Shared fields inherited by all Hero variants."""
name: str = Field(index=True, min_length=1, max_length=100)
secret_name: str
age: int | None = Field(default=None, ge=0, le=150)
class Hero(HeroBase, table=True):
"""Hero table model — stored in the database."""
id: int | None = Field(default=None, primary_key=True)
class HeroCreate(HeroBase):
"""Request schema for creating a new hero (no id — generated by the DB)."""
pass
class HeroPublic(HeroBase):
"""Response schema for returning a hero (id is always present)."""
id: int
class HeroUpdate(SQLModel):
"""Request schema for partial update (PATCH) — all fields are optional."""
name: str | None = None
secret_name: str | None = None
age: int | None = None
Pattern 3: CRUD Router
"""Hero CRUD endpoints using SQLModel and FastAPI."""
import logging
from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlmodel import Session, select
from ..core.database import get_session
from ..models.hero import Hero, HeroCreate, HeroPublic, HeroUpdate
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/heroes", tags=["Heroes"])
SessionDep = Annotated[Session, Depends(get_session)]
@router.post("", response_model=HeroPublic, status_code=status.HTTP_201_CREATED)
def create_hero(
*,
session: SessionDep,
hero: HeroCreate,
) -> HeroPublic:
"""Create a new hero and persist it to the database.
Args:
session: Database session (injected by FastAPI).
hero: Validated hero creation data.
Returns:
HeroPublic: The created hero with its database-assigned id.
Raises:
HTTPException 409: If a hero with the same name already exists.
"""
existing = session.exec(select(Hero).where(Hero.name == hero.name)).first()
if existing:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=f"A hero named '{hero.name}' already exists",
)
db_hero = Hero.model_validate(hero)
session.add(db_hero)
session.commit()
session.refresh(db_hero)
logger.info("Hero created: id=%s name=%s", db_hero.id, db_hero.name)
return db_hero
@router.get("", response_model=list[HeroPublic])
def read_heroes(
*,
session: SessionDep,
offset: int = 0,
limit: int = Query(default=20, le=100),
) -> list[HeroPublic]:
"""Return a paginated list of heroes.
Args:
session: Database session.
offset: Number of rows to skip (for pagination).
limit: Maximum rows to return (capped at 100).
Returns:
list[HeroPublic]: Heroes matching the pagination parameters.
"""
heroes = session.exec(select(Hero).offset(offset).limit(limit)).all()
return list(heroes)
@router.get("/{hero_id}", response_model=HeroPublic)
def read_hero(
*,
session: SessionDep,
hero_id: int,
) -> HeroPublic:
"""Return a single hero by its id.
Args:
session: Database session.
hero_id: Primary key of the hero to retrieve.
Returns:
HeroPublic: The requested hero.
Raises:
HTTPException 404: If no hero with the given id exists.
"""
hero = session.get(Hero, hero_id)
if not hero:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Hero with id={hero_id} not found",
)
return hero
@router.patch("/{hero_id}", response_model=HeroPublic)
def update_hero(
*,
session: SessionDep,
hero_id: int,
hero: HeroUpdate,
) -> HeroPublic:
"""Partially update a hero (only fields provided in the request are changed).
Args:
session: Database session.
hero_id: Primary key of the hero to update.
hero: Fields to update (all optional).
Returns:
HeroPublic: The updated hero.
Raises:
HTTPException 404: If no hero with the given id exists.
"""
db_hero = session.get(Hero, hero_id)
if not db_hero:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Hero with id={hero_id} 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)
logger.info("Hero updated: id=%s", db_hero.id)
return db_hero
@router.delete("/{hero_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_hero(
*,
session: SessionDep,
hero_id: int,
) -> None:
"""Delete a hero by its id.
Args:
session: Database session.
hero_id: Primary key of the hero to delete.
Raises:
HTTPException 404: If no hero with the given id exists.
"""
hero = session.get(Hero, hero_id)
if not hero:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Hero with id={hero_id} not found",
)
session.delete(hero)
session.commit()
logger.info("Hero deleted: id=%s", hero_id)
Pattern 4: FastAPI App with Lifespan
"""FastAPI application entry point with SQLModel database initialization."""
from contextlib import asynccontextmanager
from fastapi import FastAPI
from .core.database import create_db_and_tables
from .routers import heroes
@asynccontextmanager
async def lifespan(app: FastAPI):
"""Create database tables on startup."""
create_db_and_tables()
yield
app = FastAPI(title="Hero API", lifespan=lifespan)
app.include_router(heroes.router, prefix="/api")
Relationships
One-to-Many (Team → Heroes)
"""Team and Hero models with a one-to-many relationship."""
from typing import TYPE_CHECKING
from sqlmodel import Field, Relationship, SQLModel
if TYPE_CHECKING:
from .hero import Hero
class TeamBase(SQLModel):
"""Shared fields for all Team variants."""
name: str = Field(index=True)
headquarters: str
class Team(TeamBase, table=True):
"""Team table model."""
id: int | None = Field(default=None, primary_key=True)
heroes: list["Hero"] = Relationship(back_populates="team")
class HeroBase(SQLModel):
name: str = Field(index=True)
secret_name: str
age: int | None = None
team_id: int | None = Field(default=None, foreign_key="team.id")
class Hero(HeroBase, table=True):
id: int | None = Field(default=None, primary_key=True)
team: Team | None = Relationship(back_populates="heroes")
Many-to-Many (Heroes ↔ Powers via link table)
"""Many-to-many relationship between Hero and Power via HeroPowerLink."""
from sqlmodel import Field, Relationship, SQLModel
class HeroPowerLink(SQLModel, table=True):
"""Link table for the Hero <-> Power many-to-many relationship."""
hero_id: int | None = Field(
default=None, foreign_key="hero.id", primary_key=True
)
power_id: int | None = Field(
default=None, foreign_key="power.id", primary_key=True
)
class PowerBase(SQLModel):
"""Shared fields for all Power variants."""
name: str = Field(index=True)
description: str | None = None
class Power(PowerBase, table=True):
"""Power table model."""
id: int | None = Field(default=None, primary_key=True)
heroes: list["Hero"] = Relationship(
back_populates="powers", link_model=HeroPowerLink
)
Advanced Queries
Filtering, Ordering, and Pagination
from sqlmodel import Session, select, and_, or_, col
def search_heroes(
session: Session,
name_filter: str | None = None,
min_age: int | None = None,
max_age: int | None = None,
offset: int = 0,
limit: int = 20,
) -> list[Hero]:
"""Search heroes with optional filters, ordering, and pagination."""
statement = select(Hero)
conditions = []
if name_filter:
conditions.append(col(Hero.name).contains(name_filter))
if min_age is not None:
conditions.append(Hero.age >= min_age)
if max_age is not None:
conditions.append(Hero.age <= max_age)
if conditions:
statement = statement.where(and_(*conditions))
statement = statement.order_by(Hero.name, Hero.id)
statement = statement.offset(offset).limit(limit)
return list(session.exec(statement).all())
def count_heroes(session: Session) -> int:
"""Return the total number of heroes in the database."""
from sqlmodel import func
result = session.exec(select(func.count()).select_from(Hero))
return result.one()
Querying with Relationships (JOIN)
def get_heroes_for_team(session: Session, team_id: int) -> list[Hero]:
"""Return all heroes belonging to a specific team."""
statement = select(Hero).where(Hero.team_id == team_id)
return list(session.exec(statement).all())
def get_team_with_heroes(session: Session, team_id: int) -> Team | None:
"""Return a team and eagerly load its heroes."""
from sqlmodel import selectinload
statement = (
select(Team)
.where(Team.id == team_id)
.options(selectinload(Team.heroes))
)
return session.exec(statement).first()
Database Configuration (pydantic-settings)
"""Application settings loaded from environment variables."""
from pydantic_settings import BaseSettings
class Settings(BaseSettings):
"""Database and application settings."""
database_url: str = "sqlite:///./database.db"
database_echo: bool = False
class Config:
env_file = ".env"
case_sensitive = False
Environment variables:
DATABASE_URL=sqlite:///./database.db
DATABASE_ECHO=false
DATABASE_URL=postgresql+psycopg2://portalcrane:secret@db:5432/portalcrane
DATABASE_ECHO=false
Testing with SQLModel
"""Pytest fixtures for SQLModel + FastAPI integration tests."""
import pytest
from fastapi.testclient import TestClient
from sqlmodel import SQLModel, Session, StaticPool, create_engine
from app.core.database import get_session
from app.main import app
@pytest.fixture(name="session")
def session_fixture():
"""Create an in-memory SQLite engine for each test."""
engine = create_engine(
"sqlite://",
connect_args={"check_same_thread": False},
poolclass=StaticPool,
)
SQLModel.metadata.create_all(engine)
with Session(engine) as session:
yield session
@pytest.fixture(name="client")
def client_fixture(session: Session):
"""Override the get_session dependency to use the test database."""
def get_session_override():
return session
app.dependency_overrides[get_session] = get_session_override
client = TestClient(app)
yield client
app.dependency_overrides.clear()
def test_create_hero(client: TestClient) -> None:
"""Creating a hero returns 201 and the hero with an id."""
response = client.post(
"/api/heroes",
json={"name": "Deadpond", "secret_name": "Dive Wilson"},
)
assert response.status_code == 201
data = response.json()
assert data["name"] == "Deadpond"
assert data["id"] is not None
def test_read_hero_not_found(client: TestClient) -> None:
"""Reading a non-existent hero returns 404."""
response = client.get("/api/heroes/9999")
assert response.status_code == 404
def test_update_hero(client: TestClient) -> None:
"""Partial update only changes the specified fields."""
create_resp = client.post(
"/api/heroes",
json={"name": "Spider-Boy", "secret_name": "Pedro"},
)
hero_id = create_resp.json()["id"]
update_resp = client.patch(f"/api/heroes/{hero_id}", json={"age": 25})
assert update_resp.status_code == 200
assert update_resp.json()["age"] == 25
assert update_resp.json()["name"] == "Spider-Boy"
Common Patterns and Best Practices
1. Always Use model_dump(exclude_unset=True) for PATCH
hero_data = hero_update.model_dump(exclude_unset=True)
db_hero.sqlmodel_update(hero_data)
hero_data = hero_update.model_dump()
2. Always refresh() After commit() to Get Generated Values
session.add(db_hero)
session.commit()
session.refresh(db_hero)
return db_hero
3. Use session.get() for Primary Key Lookups
hero = session.get(Hero, hero_id)
hero = session.exec(select(Hero).where(Hero.id == hero_id)).first()
4. Use model_validate() to Convert Between Model Types
db_hero = Hero.model_validate(hero_create)
hero_public = HeroPublic.model_validate(db_hero)
5. Never Inherit from Table Models
class HeroBase(SQLModel): ...
class Hero(HeroBase, table=True): ...
class HeroCreate(HeroBase): ...
class HeroAdmin(Hero, table=True): ...
6. Handle Session Errors with try/except
from sqlalchemy.exc import IntegrityError
def create_hero_safe(session: Session, hero: HeroCreate) -> Hero:
"""Create a hero with database-level duplicate detection."""
db_hero = Hero.model_validate(hero)
session.add(db_hero)
try:
session.commit()
except IntegrityError:
session.rollback()
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="Hero already exists (database constraint violation)",
)
session.refresh(db_hero)
return db_hero
Quick Reference
| Operation | SQLModel Code |
|---|
| Create table model | class Item(SQLModel, table=True): ... |
| Create data model | class ItemCreate(SQLModel): ... |
| Primary key | id: int | None = Field(default=None, primary_key=True) |
| Foreign key | team_id: int | None = Field(default=None, foreign_key="team.id") |
| Indexed field | name: str = Field(index=True) |
| Unique field | email: str = Field(unique=True) |
| Session dependency | def get_session(): yield Session(engine) |
| Insert | session.add(obj); session.commit(); session.refresh(obj) |
| Select all | session.exec(select(Model)).all() |
| Select by PK | session.get(Model, id) |
| Filter | select(Model).where(Model.field == value) |
| Partial update | obj.sqlmodel_update(data.model_dump(exclude_unset=True)) |
| Delete | session.delete(obj); session.commit() |
| Count | session.exec(select(func.count()).select_from(Model)).one() |
Resources