Intermediate

If you’ve built a FastAPI application with a database backend, you’ve probably run into this: you define a Pydantic model for request/response validation, then a separate SQLAlchemy model for the database table, and then spend time keeping them in sync. Change one field and you have to update both. Add a validator and you need it in both places. SQLModel solves this by merging the two into a single class that works as both a Pydantic model and a SQLAlchemy ORM model.

SQLModel is built by the same developer as FastAPI (Sebastian Ramirez) and is designed to integrate seamlessly with FastAPI’s dependency injection and OpenAPI documentation. Under the hood it’s a thin layer over SQLAlchemy 2.0 and Pydantic v2, so you get the full power of both — type-checked models that also map to database tables, automatic schema generation, and Alembic migration support. You’ll need Python 3.7+ and you install it with pip install sqlmodel.

In this tutorial you’ll learn how to define SQLModel table models, create and query a database with the SQLAlchemy engine, relate tables with foreign keys, use SQLModel with FastAPI for a complete CRUD API, and handle optional fields correctly for create vs update operations. By the end you’ll have a working FastAPI + SQLite application that demonstrates the full SQLModel workflow.

Python SQLModel: Quick Example

Here’s a minimal SQLModel app that creates a table, inserts a row, and queries it back — all in under 20 lines:

# sqlmodel_quick.py
from sqlmodel import Field, Session, SQLModel, create_engine, select

class Hero(SQLModel, table=True):
    id: int | None = Field(default=None, primary_key=True)
    name: str
    power: str
    level: int = 1

engine = create_engine("sqlite:///heroes.db", echo=False)
SQLModel.metadata.create_all(engine)

# Insert
with Session(engine) as session:
    hero = Hero(name="Spider-Man", power="wall-crawling", level=5)
    session.add(hero)
    session.commit()
    session.refresh(hero)
    print(f"Created hero ID={hero.id}")

# Query
with Session(engine) as session:
    heroes = session.exec(select(Hero)).all()
    for h in heroes:
        print(f"{h.id}: {h.name} (level {h.level})")

Output:

Created hero ID=1
1: Spider-Man (level 5)

The key is table=True in the class definition — this tells SQLModel to register the class as a database table. Without it, the class is just a Pydantic model. The Field(primary_key=True) maps to SQLAlchemy’s primary key constraint, and default=None lets the database auto-assign the ID on insert. Notice how session.exec(select(Hero)) returns fully typed Hero objects — no manual type conversion needed.

The sections below cover relationships, FastAPI integration, and the create/update pattern in depth.

What Is SQLModel and Why Use It?

SQLModel is a Python ORM library that unifies SQLAlchemy and Pydantic into a single model definition. A traditional FastAPI + SQLAlchemy stack requires two parallel class hierarchies: one for database tables (SQLAlchemy declarative base) and one for API schemas (Pydantic BaseModel). SQLModel replaces both with a single SQLModel base class that is simultaneously a Pydantic model and a SQLAlchemy ORM model.

Feature SQLAlchemy alone Pydantic alone SQLModel
Database ORM Yes No Yes
Runtime type validation No Yes Yes
FastAPI schema generation No Yes Yes
Single model definition No No Yes
Alembic migration support Yes No Yes
Async support Yes (asyncio) N/A Yes (AsyncSession)

The trade-off is that SQLModel adds an abstraction layer, so some advanced SQLAlchemy patterns require dropping down to the SQLAlchemy API. For most CRUD applications, SQLModel’s higher-level API is exactly the right level of abstraction.

Debug Dee examining database relationships
Two model classes, one schema to maintain. SQLModel picked the right battle.

Defining Table and Non-Table Models

The key design choice in SQLModel is the table=True flag. Models with this flag map to database tables; models without it are pure Pydantic schemas used for API input/output validation. This lets you define separate schemas for “create” (no ID field), “read” (ID included), and “update” (all fields optional) while sharing the field definitions from the table model.

# sqlmodel_models.py
from sqlmodel import Field, SQLModel
from typing import Optional

# --- Database table model ---
class Item(SQLModel, table=True):
    id: Optional[int] = Field(default=None, primary_key=True)
    name: str = Field(index=True)          # creates DB index
    description: Optional[str] = None
    price: float
    in_stock: bool = True

# --- API schemas (no table=True) ---
class ItemCreate(SQLModel):
    """Fields required to create an item. No id -- DB assigns it."""
    name: str
    description: Optional[str] = None
    price: float
    in_stock: bool = True

class ItemRead(SQLModel):
    """Fields returned in API responses. id is always present."""
    id: int
    name: str
    description: Optional[str]
    price: float
    in_stock: bool

class ItemUpdate(SQLModel):
    """All fields optional for PATCH updates."""
    name: Optional[str] = None
    description: Optional[str] = None
    price: Optional[float] = None
    in_stock: Optional[bool] = None

# Validate that ItemCreate works as expected
item = ItemCreate(name="Widget", price=9.99)
print(item.model_dump())

# Invalid data raises a ValidationError
try:
    bad = ItemCreate(name="Widget", price="not-a-number")
except Exception as e:
    print(f"Validation error: {type(e).__name__}")

Output:

{'name': 'Widget', 'description': None, 'price': 9.99, 'in_stock': True}
Validation error: ValidationError

Using separate ItemCreate, ItemRead, and ItemUpdate schemas alongside the Item table model is the standard SQLModel pattern for FastAPI. The ItemCreate schema is what the API accepts; the ItemRead schema is what the API returns; the Item table model is what the database uses. FastAPI’s response model validation uses ItemRead to strip any fields that shouldn’t appear in the response.

CRUD Operations with Session

SQLModel uses the same Session pattern as SQLAlchemy 2.0. Every database operation runs inside a session context manager. The select() function builds type-safe queries and session.exec() executes them, returning strongly-typed results rather than raw row tuples.

# sqlmodel_crud.py
from sqlmodel import Field, Session, SQLModel, create_engine, select
from typing import Optional
import os

class Item(SQLModel, table=True):
    id: Optional[int] = Field(default=None, primary_key=True)
    name: str = Field(index=True)
    price: float
    in_stock: bool = True

DB_PATH = "items.db"
engine = create_engine(f"sqlite:///{DB_PATH}", echo=False)
SQLModel.metadata.create_all(engine)

def create_item(name: str, price: float) -> Item:
    with Session(engine) as session:
        item = Item(name=name, price=price)
        session.add(item)
        session.commit()
        session.refresh(item)   # load auto-assigned id
        return item

def get_item(item_id: int) -> Optional[Item]:
    with Session(engine) as session:
        return session.get(Item, item_id)

def list_items(min_price: float = 0.0) -> list[Item]:
    with Session(engine) as session:
        stmt = select(Item).where(Item.price >= min_price).order_by(Item.name)
        return session.exec(stmt).all()

def update_item(item_id: int, new_price: float) -> Optional[Item]:
    with Session(engine) as session:
        item = session.get(Item, item_id)
        if not item:
            return None
        item.price = new_price
        session.add(item)
        session.commit()
        session.refresh(item)
        return item

def delete_item(item_id: int) -> bool:
    with Session(engine) as session:
        item = session.get(Item, item_id)
        if not item:
            return False
        session.delete(item)
        session.commit()
        return True

# Demo
widget = create_item("Widget", 9.99)
gadget = create_item("Gadget", 24.99)
print(f"Created: {widget.id} {widget.name}, {gadget.id} {gadget.name}")

items = list_items(min_price=10.0)
print(f"Items >= $10: {[i.name for i in items]}")

updated = update_item(widget.id, 12.99)
print(f"Updated widget price: ${updated.price}")

deleted = delete_item(gadget.id)
print(f"Deleted gadget: {deleted}")
print(f"Remaining items: {len(list_items())}")

# Cleanup
os.remove(DB_PATH)

Output:

Created: 1 Widget, 2 Gadget
Items >= $10: ['Gadget']
Updated widget price: $12.99
Deleted gadget: True
Remaining items: 1

session.get(Item, item_id) is the efficient single-row lookup by primary key — it hits the identity cache first and only queries the database if the object isn’t already loaded. Always call session.refresh(item) after commit() to reload auto-generated values like id or database-side defaults. Without refresh(), accessing item.id after a commit can raise a DetachedInstanceError.

API Alice with Swagger documentation
Foreign key defined. Cascade behavior: a problem for Future You.

Defining Relationships with Foreign Keys

SQLModel supports table relationships using Relationship and standard SQLAlchemy foreign keys. Relationships let you navigate between related objects in Python without writing manual join queries.

# sqlmodel_relationships.py
from sqlmodel import Field, Relationship, Session, SQLModel, create_engine, select
from typing import Optional, List

class Team(SQLModel, table=True):
    id: Optional[int] = Field(default=None, primary_key=True)
    name: str
    heroes: List["Hero"] = Relationship(back_populates="team")

class Hero(SQLModel, table=True):
    id: Optional[int] = Field(default=None, primary_key=True)
    name: str
    team_id: Optional[int] = Field(default=None, foreign_key="team.id")
    team: Optional[Team] = Relationship(back_populates="heroes")

engine = create_engine("sqlite:///teams.db", echo=False)
SQLModel.metadata.create_all(engine)

with Session(engine) as session:
    # Create team and heroes together
    avengers = Team(name="Avengers")
    session.add(avengers)
    session.commit()
    session.refresh(avengers)

    iron_man = Hero(name="Iron Man", team_id=avengers.id)
    thor = Hero(name="Thor", team_id=avengers.id)
    session.add_all([iron_man, thor])
    session.commit()

    # Query heroes with their team
    stmt = select(Hero).where(Hero.team_id == avengers.id)
    members = session.exec(stmt).all()
    print(f"{avengers.name} has {len(members)} heroes:")
    for h in members:
        print(f"  - {h.name}")

import os; os.remove("teams.db")

Output:

Avengers has 2 heroes:
  - Iron Man
  - Thor

The back_populates parameter creates a two-way relationship: team.heroes gives you the list of heroes in a team, and hero.team gives you the hero’s team. SQLModel uses lazy loading by default — the related objects are only fetched when you actually access the relationship attribute. For performance-critical applications, use selectin loading or explicit joins to avoid N+1 query problems.

FastAPI Integration: Complete CRUD API

SQLModel’s real strength shows when combined with FastAPI. The same model classes serve as both database tables and FastAPI request/response schemas, and FastAPI automatically generates OpenAPI documentation for them.

# sqlmodel_fastapi.py
from fastapi import FastAPI, HTTPException, Depends
from sqlmodel import Field, Session, SQLModel, create_engine, select
from typing import Optional, List

# --- Models ---
class HeroBase(SQLModel):
    name: str
    power: str
    level: int = 1

class Hero(HeroBase, table=True):
    id: Optional[int] = Field(default=None, primary_key=True)

class HeroCreate(HeroBase):
    pass  # same fields as base, no id

class HeroRead(HeroBase):
    id: int

class HeroUpdate(SQLModel):
    name: Optional[str] = None
    power: Optional[str] = None
    level: Optional[int] = None

# --- Database setup ---
engine = create_engine("sqlite:///heroes.db", echo=False)
SQLModel.metadata.create_all(engine)

def get_session():
    with Session(engine) as session:
        yield session

# --- FastAPI app ---
app = FastAPI(title="Heroes API")

@app.post("/heroes/", response_model=HeroRead, status_code=201)
def create_hero(hero: HeroCreate, session: Session = Depends(get_session)):
    db_hero = Hero.model_validate(hero)
    session.add(db_hero)
    session.commit()
    session.refresh(db_hero)
    return db_hero

@app.get("/heroes/", response_model=List[HeroRead])
def read_heroes(skip: int = 0, limit: int = 10, session: Session = Depends(get_session)):
    heroes = session.exec(select(Hero).offset(skip).limit(limit)).all()
    return heroes

@app.get("/heroes/{hero_id}", response_model=HeroRead)
def read_hero(hero_id: int, session: Session = Depends(get_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=HeroRead)
def update_hero(hero_id: int, hero_update: HeroUpdate, session: Session = Depends(get_session)):
    hero = session.get(Hero, hero_id)
    if not hero:
        raise HTTPException(status_code=404, detail="Hero not found")
    update_data = hero_update.model_dump(exclude_unset=True)
    for field, value in update_data.items():
        setattr(hero, field, value)
    session.add(hero)
    session.commit()
    session.refresh(hero)
    return hero

@app.delete("/heroes/{hero_id}")
def delete_hero(hero_id: int, session: Session = Depends(get_session)):
    hero = session.get(Hero, hero_id)
    if not hero:
        raise HTTPException(status_code=404, detail="Hero not found")
    session.delete(hero)
    session.commit()
    return {"ok": True}

Run with:

pip install fastapi uvicorn sqlmodel
uvicorn sqlmodel_fastapi:app --reload
# Docs at http://127.0.0.1:8000/docs

The model_validate(hero) call converts a HeroCreate Pydantic model into a Hero table model — SQLModel inherits Pydantic’s model_validate for this. The Depends(get_session) pattern injects a fresh session per request and automatically closes it when the request completes. hero_update.model_dump(exclude_unset=True) is the key to PATCH support: it only returns fields that were actually sent in the request, not fields that defaulted to None.

Loop Larry organizing data models
response_model=HeroRead strips the internal fields. Your API won’t leak what it shouldn’t.

Real-Life Example: Task Manager API

A complete task manager with users, tasks, and status tracking — demonstrating relationships, filtering, and pagination in a realistic SQLModel + FastAPI app.

Python SQLModel task manager
offset(skip).limit(limit) — pagination that works until you have 10 million rows.

# task_manager.py
from fastapi import FastAPI, HTTPException, Depends, Query
from sqlmodel import Field, Relationship, Session, SQLModel, create_engine, select
from typing import Optional, List
from datetime import datetime
from enum import Enum

class TaskStatus(str, Enum):
    todo = "todo"
    in_progress = "in_progress"
    done = "done"

class UserBase(SQLModel):
    username: str
    email: str

class User(UserBase, table=True):
    id: Optional[int] = Field(default=None, primary_key=True)
    tasks: List["Task"] = Relationship(back_populates="owner")

class UserCreate(UserBase):
    pass

class UserRead(UserBase):
    id: int

class TaskBase(SQLModel):
    title: str
    description: Optional[str] = None
    status: TaskStatus = TaskStatus.todo

class Task(TaskBase, table=True):
    id: Optional[int] = Field(default=None, primary_key=True)
    created_at: datetime = Field(default_factory=datetime.utcnow)
    owner_id: Optional[int] = Field(default=None, foreign_key="user.id")
    owner: Optional[User] = Relationship(back_populates="tasks")

class TaskCreate(TaskBase):
    owner_id: int

class TaskRead(TaskBase):
    id: int
    created_at: datetime
    owner_id: int

class TaskUpdate(SQLModel):
    title: Optional[str] = None
    description: Optional[str] = None
    status: Optional[TaskStatus] = None

engine = create_engine("sqlite:///tasks.db", echo=False)
SQLModel.metadata.create_all(engine)

def get_session():
    with Session(engine) as session:
        yield session

app = FastAPI(title="Task Manager API")

@app.post("/users/", response_model=UserRead, status_code=201)
def create_user(user: UserCreate, session: Session = Depends(get_session)):
    db_user = User.model_validate(user)
    session.add(db_user)
    session.commit()
    session.refresh(db_user)
    return db_user

@app.post("/tasks/", response_model=TaskRead, status_code=201)
def create_task(task: TaskCreate, session: Session = Depends(get_session)):
    db_task = Task.model_validate(task)
    session.add(db_task)
    session.commit()
    session.refresh(db_task)
    return db_task

@app.get("/tasks/", response_model=List[TaskRead])
def list_tasks(
    status: Optional[TaskStatus] = None,
    owner_id: Optional[int] = None,
    skip: int = Query(default=0, ge=0),
    limit: int = Query(default=10, le=100),
    session: Session = Depends(get_session)
):
    stmt = select(Task)
    if status:
        stmt = stmt.where(Task.status == status)
    if owner_id:
        stmt = stmt.where(Task.owner_id == owner_id)
    stmt = stmt.offset(skip).limit(limit)
    return session.exec(stmt).all()

@app.patch("/tasks/{task_id}", response_model=TaskRead)
def update_task(task_id: int, update: TaskUpdate, session: Session = Depends(get_session)):
    task = session.get(Task, task_id)
    if not task:
        raise HTTPException(status_code=404, detail="Task not found")
    for field, value in update.model_dump(exclude_unset=True).items():
        setattr(task, field, value)
    session.add(task)
    session.commit()
    session.refresh(task)
    return task

Run with:

uvicorn task_manager:app --reload
# POST /users/  -> creates user
# POST /tasks/  -> creates task for that user
# GET /tasks/?status=todo&owner_id=1  -> filtered task list

The TaskStatus enum ensures the API only accepts valid status values and documents the allowed choices in the OpenAPI schema automatically. The filtering pattern in list_tasks is composable — you can add more .where()` clauses without rewriting the query. Extend this example by adding authentication with FastAPI's OAuth2 dependency, Alembic migrations for schema changes, or async sessions with AsyncSession for high-concurrency workloads.

Frequently Asked Questions

Is SQLModel a replacement for SQLAlchemy?

SQLModel is built on top of SQLAlchemy, not a replacement for it. It provides a higher-level API that integrates with Pydantic, but all SQLAlchemy features are still available. You can mix SQLModel's select() with SQLAlchemy's advanced query constructs, use Alembic for migrations (SQLModel.metadata is a standard SQLAlchemy MetaData object), and access the underlying engine and session objects directly. When SQLModel's API is insufficient, drop down to SQLAlchemy -- they're fully compatible.

Does SQLModel support async databases?

Yes. Use create_async_engine from SQLAlchemy and AsyncSession from sqlmodel.ext.asyncio (or directly from sqlalchemy.ext.asyncio). The query syntax is identical, but you await session.exec() instead of calling it synchronously. For databases, use an async driver like aiosqlite for SQLite or asyncpg for PostgreSQL. The async pattern is recommended for high-concurrency FastAPI applications where database calls should not block the event loop.

How do I handle database migrations with SQLModel?

Use Alembic -- the standard SQLAlchemy migration tool. Run alembic init alembic to create the migration environment, then set target_metadata = SQLModel.metadata in alembic/env.py. Run alembic revision --autogenerate -m "add column" to auto-generate a migration from your model changes, and alembic upgrade head to apply it. Never use SQLModel.metadata.create_all() in production -- that only creates tables that don't exist and won't handle column additions, renames, or deletions.

Why use Optional[str] = None for non-required fields?

SQLModel inherits Pydantic's field semantics: a field with Optional[str] = None is optional in both the Python model and the database column (nullable). This creates a nullable column in the database. If you want a column that has a database default but is required in Python, use Field(default=None, sa_column_kwargs={"server_default": "value"}). The exclude_unset=True pattern in PATCH endpoints relies on distinguishing "field not sent" (not in model dump) from "field sent as None" (in dump as None).

Can SQLModel work with PostgreSQL and MySQL?

Yes -- SQLModel uses SQLAlchemy's database abstraction layer, so it works with any database SQLAlchemy supports: PostgreSQL (use psycopg2 or asyncpg), MySQL/MariaDB (use mysqlclient or aiomysql), SQLite (built-in), and more. Change the connection URL in create_engine(): "postgresql://user:pass@localhost/dbname" for PostgreSQL. Field types like str and int map to the appropriate column types for each database automatically.

Conclusion

SQLModel eliminates the duplication between SQLAlchemy ORM models and Pydantic schemas by merging them into a single class. The core workflow is: define a table=True model for the database, define non-table models for API input/output, use Session.exec(select(Model))` for queries, and use model_dump(exclude_unset=True) for PATCH operations. The FastAPI integration is seamless because both libraries share the same author and design philosophy.

The task manager example above is a solid foundation for a real application. Next steps: add Alembic for migrations, switch to AsyncSession for async support, add authentication with FastAPI's Depends system, and add indexes to frequently-filtered columns with Field(index=True).

Official documentation: https://sqlmodel.tiangolo.com/