SQLModel: SQLAlchemy Meets Pydantic

· 6 min read · Updated March 21, 2026 · beginner
python sqlalchemy pydantic fastapi orm database

What is SQLModel and Why It Exists

SQLModel is a library created by Samuel Colvin — the same person behind Pydantic. It takes the best parts of SQLAlchemy’s ORM and Pydantic’s data validation and smashes them together into a single class.

Before SQLModel, you had to maintain two separate classes for the same database table: one SQLAlchemy model for the database and one Pydantic model for validation. That meant duplicate field definitions, duplicate validation rules, and twice the maintenance work.

SQLModel solves this by giving you one class that does both jobs. Define a field once, and you get database persistence and request validation in one go.

Installation

pip install sqlmodel

That’s it. SQLModel depends on both SQLAlchemy and Pydantic, so they’ll install automatically.

Defining Models

A SQLModel class looks like a Pydantic model, but you add table=True when you want database persistence.

from sqlmodel import Field, SQLModel

class Hero(SQLModel, table=True):
    id: int | None = Field(default=None, primary_key=True)
    name: str
    age: int | None = None
    secret_name: str

table=True tells SQLModel this is a database table. Without it, you get a Pydantic-only model — useful for request/response schemas that don’t need a database backing.

The Field() function gives you control over individual columns:

class Hero(SQLModel, table=True):
    id: int | None = Field(default=None, primary_key=True)
    name: str = Field(index=True)  # creates a database index
    age: int | None = Field(default=None, ge=0)  # age must be >= 0
    secret_name: str

ge=0 is a Pydantic validator, so you get type coercion and validation at the same time as column configuration.

Creating Tables and the Engine

Now you need a database to put those tables in.

from sqlmodel import create_engine, SQLModel

engine = create_engine("sqlite:///heroes.db")

This creates an SQLite database called heroes.db in your current directory. The create_engine URL format follows SQLAlchemy conventions, so you can swap in PostgreSQL, MySQL, or other databases just by changing the connection string.

With the engine in place, you create all your tables in one call:

SQLModel.metadata.create_all(engine)

This reads all SQLModel subclasses with table=True and creates the corresponding tables if they don’t exist. It’s idempotent — running it again is safe.

CRUD Operations

CRUD stands for Create, Read, Update, Delete. SQLModel uses SQLAlchemy’s Session for all of these.

Create

from sqlmodel import Session

with Session(engine) as session:
    hero = Hero(name="Deadpond", secret_name="Dive Wilson")
    session.add(hero)
    session.commit()
    session.refresh(hero)  # fetches DB-generated fields like id

    print(hero.id)  # output: 1

session.add() stages the object. session.commit() writes it to the database. session.refresh() pulls in any database-generated values (like auto-increment IDs).

Read

from sqlmodel import select

with Session(engine) as session:
    statement = select(Hero).where(Hero.name == "Deadpond")
    hero = session.exec(statement).first()
    print(hero.name)  # output: Deadpond
    print(hero.secret_name)  # output: Dive Wilson

select() builds a query. session.exec() runs it. .first() returns the first result or None.

To fetch heroes with no age set (where age IS NULL in SQL):

with Session(engine) as session:
    statement = select(Hero).where(Hero.age.is_(None))
    heroes = session.exec(statement).all()
    for hero in heroes:
        print(hero.name)

To fetch all heroes:

with Session(engine) as session:
    statement = select(Hero)
    heroes = session.exec(statement).all()
    for hero in heroes:
        print(hero.name)

Update

with Session(engine) as session:
    statement = select(Hero).where(Hero.name == "Deadpond")
    hero = session.exec(statement).one()

    hero.age = 42
    session.add(hero)
    session.commit()
    session.refresh(hero)

    print(hero.age)  # output: 42

SQLModel tracks changes to objects. When you modify a field and call commit(), SQLAlchemy writes the update automatically.

Delete

with Session(engine) as session:
    statement = select(Hero).where(Hero.name == "Deadpond")
    hero = session.exec(statement).one()

    session.delete(hero)
    session.commit()

To delete heroes with no age:

with Session(engine) as session:
    statement = select(Hero).where(Hero.age.is_(None))
    heroes = session.exec(statement).all()

    for hero in heroes:
        session.delete(hero)
    session.commit()

Relationships Between Models

You define relationships the SQLAlchemy way, using Relationship from SQLModel.

from sqlmodel import Field, Relationship, SQLModel

class Team(SQLModel, table=True):
    id: int | None = Field(default=None, primary_key=True)
    name: str
    headquarters: str

    heroes: list["Hero"] = Relationship(back_populates="team")


class Hero(SQLModel, table=True):
    id: int | None = Field(default=None, primary_key=True)
    name: str
    age: int | None = None
    secret_name: str
    team_id: int | None = Field(default=None, foreign_key="team.id")

    team: Team | None = Relationship(back_populates="heroes")

back_populates links the two sides of the relationship. foreign_key on the child side specifies which column holds the parent’s ID.

With this in place, SQLAlchemy loads related objects automatically:

with Session(engine) as session:
    team = session.get(Team, 1)
    print(team.name)  # output: Z-Force
    print(team.heroes[0].name)  # output: Deadpond

The reverse works too:

with Session(engine) as session:
    hero = session.get(Hero, 1)
    print(hero.team.name)  # output: Z-Force

Watch out for circular imports — when two models reference each other, use string annotations ("Team" instead of Team) and put the import inside the function.

Using with FastAPI

FastAPI pairs naturally with SQLModel because both Pydantic and SQLModel use the same validation underpinnings.

from fastapi import FastAPI, HTTPException
from sqlmodel import Field, Relationship, Session, SQLModel, select, create_engine

app = FastAPI()

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


class Hero(SQLModel, table=True):
    id: int | None = Field(default=None, primary_key=True)
    name: str
    age: int | None = None
    secret_name: str


class HeroRead(SQLModel):
    id: int | None
    name: str
    age: int | None
    secret_name: str


@app.post("/heroes/", response_model=HeroRead)
def create_hero(hero: Hero):
    with Session(engine) as session:
        session.add(hero)
        session.commit()
        session.refresh(hero)
    return hero


@app.get("/heroes/{hero_id}", response_model=HeroRead)
def read_hero(hero_id: int):
    with Session(engine) as session:
        hero = session.get(Hero, hero_id)
        if hero is None:
            raise HTTPException(status_code=404, detail="Hero not found")
    return hero

The Hero model from earlier becomes the request schema. FastAPI validates the incoming JSON, SQLModel writes it to the database — no extra code needed.

For path parameters and query strings, use httpie to test:

# output
# {"id":1,"name":"Tournament","age":30,"secret_name":"Dove Wilson","team_id":null}

SQLModel also handles response models automatically. FastAPI will serialize the Hero object to JSON using its field definitions. The id, name, age, and other fields appear in the response exactly as defined.

Common Pitfalls

Forgetting to Commit

# Wrong — changes stay in the session, never hit the database
with Session(engine) as session:
    session.add(hero)

# Right
with Session(engine) as session:
    session.add(hero)
    session.commit()

Without commit(), your changes vanish when the context manager exits. This catches beginners regularly.

Field Order and Primary Keys

SQLModel uses field order for column ordering in some databases. If you’re seeing odd errors, put your primary key first:

# Good — PK defined first
class Hero(SQLModel, table=True):
    id: int | None = Field(default=None, primary_key=True)
    name: str

# Risky — PK after other fields may cause issues
class RiskyHero(SQLModel, table=True):
    name: str
    id: int | None = Field(default=None, primary_key=True)

Stick to defining the primary key as the first field to avoid surprises.

Relationship Recursion and Lazy Loading

SQLModel relationships are lazy by default, which means accessing hero.team inside a loop triggers a query for each access — the classic N+1 problem.

# This runs one query per hero
with Session(engine) as session:
    heroes = session.exec(select(Hero)).all()
    for hero in heroes:
        print(hero.team.name)  # separate query each iteration

Fix this with eager loading using selectinload:

from sqlmodel import select
from sqlalchemy.ext import selectinload

with Session(engine) as session:
    statement = select(Hero).options(selectinload(Hero.team))
    heroes = session.exec(statement).all()
    for hero in heroes:
        print(hero.team.name)  # single query with JOIN

Mixing Pydantic-Only and Table Models

If a class doesn’t have table=True, it’s Pydantic-only. It won’t appear in metadata, and metadata.create_all() won’t create a table for it. This trips people up when they copy a model definition from a schema file into a migration script.

Keep your database models and your API schemas separate if you can — it makes metadata.create_all() behave predictably.


SQLModel won’t replace SQLAlchemy for advanced use cases, but for the vast majority of applications it removes a whole category of boilerplate. One class, validated, persisted, and serialised — that’s the core idea.

See Also