Skip to content
C Codeloom
FastAPI

FastAPI SQLModel vs SQLAlchemy

Compare SQLModel and raw SQLAlchemy for FastAPI projects and learn how to pick the right one for your team.

·4 min read · By Codeloom
Intermediate 10 min read

What you'll learn

  • How SQLModel relates to SQLAlchemy
  • Schema vs model duplication
  • Type safety in queries
  • Migration paths
  • When to pick each one

Prerequisites

  • Familiar with Python type hints and basic SQL

What and Why

If you write FastAPI long enough, you will reach for a database layer. The two leading choices in the Python world are SQLAlchemy, the mature, full-featured ORM, and SQLModel, a newer library by FastAPI’s author that combines Pydantic and SQLAlchemy into a single class.

The promise of SQLModel is simple: one class for both the API schema and the database table. The promise of SQLAlchemy is power: every database feature, every advanced query pattern, with a stable surface that has been battle-tested for two decades.

Mental Model

Think of these as two layers in the same stack. SQLAlchemy provides the engine, sessions, ORM, and query language. SQLModel sits on top, layering Pydantic validation onto SQLAlchemy declarative models. SQLModel is not a competitor; it is an opinionated convenience layer.

That mental model helps with the choice. If your project benefits from one definition that drives both the API contract and the table, SQLModel pays for itself. If you have complex schemas, polymorphism, or heavy use of SQLAlchemy 2.0 features, raw SQLAlchemy with separate Pydantic schemas gives you more headroom.

Hands-on Example

The same User model, written both ways. First in SQLModel.

from typing import Optional
from sqlmodel import SQLModel, Field, Session, create_engine, select

class User(SQLModel, table=True):
    id: Optional[int] = Field(default=None, primary_key=True)
    email: str = Field(index=True, unique=True)
    name: str

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

@app.post("/users")
def create_user(user: User):
    with Session(engine) as s:
        s.add(user)
        s.commit()
        s.refresh(user)
        return user

The same thing in plain SQLAlchemy 2.0 with separate Pydantic schemas.

from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, Session
from sqlalchemy import String, create_engine
from pydantic import BaseModel, EmailStr

class Base(DeclarativeBase): pass

class User(Base):
    __tablename__ = "users"
    id: Mapped[int] = mapped_column(primary_key=True)
    email: Mapped[str] = mapped_column(String(255), unique=True, index=True)
    name: Mapped[str] = mapped_column(String(120))

class UserCreate(BaseModel):
    email: EmailStr
    name: str

class UserRead(BaseModel):
    id: int
    email: EmailStr
    name: str
    class Config: from_attributes = True

engine = create_engine("sqlite:///app.db")
Base.metadata.create_all(engine)

@app.post("/users", response_model=UserRead)
def create_user(payload: UserCreate):
    with Session(engine) as s:
        user = User(email=payload.email, name=payload.name)
        s.add(user); s.commit(); s.refresh(user)
        return user
SQLModel:  one class -> table + API schema
SQLAlchemy: model class -> table
          Pydantic class -> API schema
              |
              v
      more code, but each class has one job
Same data, two layouts

For small CRUD apps with low schema variance, SQLModel cuts roughly half the boilerplate. For larger systems where the API shape and the persistence shape diverge, SQLAlchemy’s separation is a feature, not a cost.

Common Pitfalls

  • One class for everything in SQLModel. Mixing API-only fields and database-only fields in the same class gets messy fast. Use separate UserCreate, UserRead, UserUpdate classes even with SQLModel.
  • Treating SQLModel as a wrapper for any ORM. It is SQLAlchemy-specific. If you need a different backend, the choice does not help.
  • Skipping migrations. Neither library generates migrations on its own. Pair both with Alembic from day one.
  • Mixing sync and async sessions. SQLAlchemy 2.0 supports async; SQLModel async is workable but slightly less polished. Pick one mode per project.
  • Forgetting from_attributes (formerly orm_mode) in your read schemas. Without it, Pydantic cannot pull data off ORM instances.

Practical Tips

  • Start with SQLModel for prototypes. The reduction in boilerplate is real, especially for tutorials and demos.
  • Switch to raw SQLAlchemy when you hit complex joins, hybrid properties, or custom types you cannot express in SQLModel.
  • Use Alembic with autogeneration but always read the migration file before applying it.
  • Keep one session per request via a dependency. Sessions are cheap to create but expensive to misuse across threads.
  • For read-heavy paths, lean on select plus .options(selectinload(...)) to avoid N+1 queries regardless of which library you choose.

Wrap-up

SQLModel and SQLAlchemy are not rivals; they are different layers of the same stack. SQLModel saves boilerplate when the API and the table are basically the same shape. SQLAlchemy gives you full control when they are not. Either way, the underlying engine, sessions, and queries are SQLAlchemy. Pick based on the complexity of your domain, and do not be afraid to migrate from one to the other as that complexity grows.