DEV Community

Cover image for ORM tipado con migraciones automáticas: Fitz vs SQLAlchemy + Alembic + Pydantic
Martin Palopoli
Martin Palopoli

Posted on

ORM tipado con migraciones automáticas: Fitz vs SQLAlchemy + Alembic + Pydantic

Para tener un ORM tipado con migraciones automáticas en Python necesitás mantener 3 fuentes de verdad (SQLAlchemy + Pydantic + Alembic). En Fitz es UN type con decoradores. Plus 8× RPS y 5× menos memoria que SQLAlchemy en bench reproducible.

El doble (triple) tipado del stack Python

Para cada entidad de tu DB en una API FastAPI moderna tenés que mantener:

  • models.py — modelo SQLAlchemy (define el schema).
  • schemas.py — modelos Pydantic (define request/response).
  • alembic/versions/*.py — migraciones autogeneradas.
# models.py
from sqlalchemy import Column, Integer, String, DateTime, ForeignKey
from sqlalchemy.orm import relationship, declarative_base
from datetime import datetime

Base = declarative_base()

class User(Base):
    __tablename__ = "users"
    id = Column(Integer, primary_key=True)
    email = Column(String, nullable=False, unique=True)
    name = Column(String, nullable=False)
    role = Column(String, nullable=False, default="user")
    created_at = Column(DateTime, default=datetime.utcnow)
    posts = relationship("Post", back_populates="user")

class Post(Base):
    __tablename__ = "posts"
    id = Column(Integer, primary_key=True)
    user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
    title = Column(String, nullable=False)
    body = Column(String, nullable=False)
    user = relationship("User", back_populates="posts")
Enter fullscreen mode Exit fullscreen mode
# schemas.py
from pydantic import BaseModel
from datetime import datetime

class UserCreate(BaseModel):
    email: str
    name: str
    role: str = "user"

class UserOut(BaseModel):
    id: int
    email: str
    name: str
    role: str
    created_at: datetime

    class Config:
        from_attributes = True

class PostCreate(BaseModel):
    title: str
    body: str

class PostOut(BaseModel):
    id: int
    user_id: int
    title: str
    body: str

    class Config:
        from_attributes = True

class UserWithPosts(UserOut):
    posts: list[PostOut] = []
Enter fullscreen mode Exit fullscreen mode
# main.py
@app.post("/users", response_model=UserOut)
async def create_user(user: UserCreate, db: AsyncSession = Depends(get_session)):
    new_user = User(**user.model_dump())
    db.add(new_user)
    await db.commit()
    await db.refresh(new_user)
    return new_user

@app.get("/users/{id}", response_model=UserWithPosts)
async def get_user(id: int, db: AsyncSession = Depends(get_session)):
    result = await db.execute(
        select(User).options(selectinload(User.posts)).where(User.id == id)
    )
    user = result.scalar_one_or_none()
    if not user:
        raise HTTPException(404, "user not found")
    return user
Enter fullscreen mode Exit fullscreen mode

Tres archivos. Dos modelos de la misma entidad mantenidos en paralelo. Y para el schema:

$ alembic revision --autogenerate -m "add user role"
# Revisás el archivo generado — autogenerate a veces se confunde
$ alembic upgrade head
Enter fullscreen mode Exit fullscreen mode

Plus configurar Alembic (env.py, alembic.ini, target_metadata...).

Lo mismo en Fitz

@table("users")
type User {
    @primary id: Int,
    email: Str,
    name: Str,
    role: Str = "user",
    created_at: Str = "now()",
    @has_many("Post", "user_id") posts: List<Post>,
}

@table("posts")
type Post {
    @primary id: Int,
    user_id: Int,
    title: Str,
    body: Str,
    @belongs_to user: User?,
}

@post("/users")
async fn create_user(db: DbConn, user: User) -> Result<User> {
    return User.insert(db, user).await
}

@get("/users/{id}")
async fn get_user(db: DbConn, id: Int) -> Result<User> {
    return User.where(fn(u) => u.id == id).preload("posts").first(db).await
}
Enter fullscreen mode Exit fullscreen mode

UN type. UNA fuente de verdad. El compilador genera:

  • El SQL parametrizado para las queries.
  • El schema OpenAPI desde el mismo type.
  • El deserializer JSON del body desde el mismo type.
  • La validación de fields requeridos / nullables / defaults.

Y el schema de la DB:

$ fitz db diff
+ CREATE TABLE users (
+     id BIGSERIAL PRIMARY KEY,
+     email TEXT NOT NULL,
+     name TEXT NOT NULL,
+     role TEXT NOT NULL DEFAULT 'user',
+     created_at TIMESTAMPTZ NOT NULL DEFAULT now()
+ );
+ CREATE TABLE posts (
+     id BIGSERIAL PRIMARY KEY,
+     user_id BIGINT NOT NULL REFERENCES users(id),
+     title TEXT NOT NULL,
+     body TEXT NOT NULL
+ );

$ fitz db migrate
✓ aplicada migration 20260616_120300_initial.sql
Enter fullscreen mode Exit fullscreen mode

La tabla cruda

Item Python (SQLAlchemy + Alembic + Pydantic) Fitz
Fuentes de verdad para una entidad 3 (models.py, schemas.py, alembic/) 1 (@table type)
Generar schema alembic revision --autogenerate -m "..." fitz db diff
Aplicar alembic upgrade head fitz db migrate
Rollback alembic downgrade -1 fitz db rollback
Query DSL .filter(User.role == "admin") (DSL ORM) .where(fn(u) => u.role == "admin") (closure-to-SQL en compile-time)
Eager loading .options(selectinload(User.posts)) .preload("posts")
Operadores Postgres User.email.ilike(...), .contains(...), etc. Same —u.email.ilike(...), u.tags.has(...)
Transactions with session.begin(): + acordate del flush db.transaction(fn(tx) async { ... }).await
Validación request body Pydantic separado Mismo type
Schema OpenAPI Pydantic infer Auto desde @table type
Driver libpq vía psycopg2/asyncpg Puro Rust (sin libpq, sin tokio-postgres)
Performance vs SQLAlchemy baseline 8× RPS, 5× memoria menos (bench abajo)

Por partes

Closure-to-SQL en compile-time

Esto es lo que más me costó implementar y de lo que más orgulloso estoy. SQLAlchemy traduce su DSL a SQL en runtime — cada .filter(User.role == "admin") arma el AST en runtime, mide overhead, allocea, y emite SQL.

Fitz lo hace en compile-time:

let admins = User.where(fn(u) => u.role == "admin")
    .order_by(fn(u) => u.created_at, "desc")
    .limit(10)
    .all(db).await?
Enter fullscreen mode Exit fullscreen mode

El compilador analiza el closure fn(u) => u.role == "admin" y emite, una sola vez al compilar el binario, el string SQL constante:

SELECT id, email, name, role, created_at FROM users
WHERE role = $1 ORDER BY created_at DESC LIMIT 10
Enter fullscreen mode Exit fullscreen mode

Más el código que bindea $1 = "admin". Cero overhead runtime para construir SQL — el string ya existe en el binario como &'static str.

Comparable en performance con Diesel o sqlx (que también generan SQL en compile-time vía macros), pero con sintaxis natural del lenguaje en lugar de macros procedurales.

Operadores Postgres nativos

SQLAlchemy:

admins = session.query(User).filter(User.email.ilike("%@example.com")).all()
tagged = session.query(User).filter(User.tags.contains(["admin"])).all()
pro_users = session.query(User).filter(User.metadata["plan"].astext == "pro").all()
Enter fullscreen mode Exit fullscreen mode

Fitz (los operadores son métodos sobre el field):

let admins = User.where(fn(u) => u.email.ilike("%@example.com")).all(db).await?
let tagged = User.where(fn(u) => u.tags.has("admin")).all(db).await?
let pro_users = User.where(fn(u) => u.metadata.get("plan") == "pro").all(db).await?
Enter fullscreen mode Exit fullscreen mode

Operadores cubiertos:

Categoría Métodos
String .like(p), .ilike(p), .starts_with(p), .ends_with(p), .contains(p) (con escape auto de %/_)
Listas/IN .is_in([...]), .is_null(), .is_not_null()
Arrays .has(x) (?), .contains_all(xs) (@>), .contained_in(xs) (<@)
JSONB .has_key(k) (?), .has_all_keys(ks) (?&), .has_any_keys(ks) (`?\

Relations + eager loading

SQLAlchemy:
{% raw %}

class User(Base):
    posts = relationship("Post", back_populates="user", lazy="select")

class Post(Base):
    user = relationship("User", back_populates="posts")

# Eager loading
users = await db.execute(
    select(User).options(selectinload(User.posts))
)
Enter fullscreen mode Exit fullscreen mode

Plus configurar lazy= correctamente para no caer en el N+1 más viejo del mundo (queries lazy disparando una por user).

Fitz:

@table("users")
type User {
    @primary id: Int,
    name: Str,
    @has_many("Post", "user_id") posts: List<Post>,
}

@table("posts")
type Post {
    @primary id: Int,
    user_id: Int,
    @belongs_to user: User?,
}

// Eager loading explícito (la default es NO eager — sin sorpresas)
let users = User.preload("posts").all(db).await?
Enter fullscreen mode Exit fullscreen mode

.preload("posts") emite una sola query batched con WHERE user_id IN (...) y arma los structs Fitz. La default es no eager: si no preload, el field virtual posts queda vacío y vos sabés que no fuiste a la DB.

Typo en el nombre del field:

let users = User.preload("postss").all(db).await?
//                        ^^^^^^^ error de compilación: User no tiene relation "postss"
Enter fullscreen mode Exit fullscreen mode

El compilador chequea el match estáticamente. SQLAlchemy te lo dice en runtime cuando el request llega.

Transactions

SQLAlchemy:

async with db.begin():
    user = User(...)
    db.add(user)
    await db.flush()  # acordate del flush sino user.id está vacío
    post = Post(user_id=user.id, ...)
    db.add(post)
Enter fullscreen mode Exit fullscreen mode

Fitz:

let result = db.transaction(fn(tx) async {
    let user = User.insert(tx, User { id: 0, name: "Ada", role: "user" }).await?
    let post = Post.insert(tx, Post { id: 0, user_id: user.id, title: "hola", body: "..." }).await?
    return Ok(post)
}).await
Enter fullscreen mode Exit fullscreen mode

El bloque adentro de db.transaction(...) recibe un tx: DbConn que escribe contra la transaction. Si el closure retorna Err, rollback; si retorna Ok, commit. Si la closure paniquea, rollback automático.

Y la User.insert(...) te devuelve el row con el id BIGSERIAL ya asignado, sin "acordate del flush".

Migraciones declarativas

Cambio en el type:

 @table("users")
 type User {
     @primary id: Int,
     email: Str,
     name: Str,
     role: Str = "user",
+    avatar_url: Str?,
     created_at: Str = "now()",
 }
Enter fullscreen mode Exit fullscreen mode

Diff:

$ fitz db diff
+ ALTER TABLE users ADD COLUMN avatar_url TEXT;
Enter fullscreen mode Exit fullscreen mode

Aplicar:

$ fitz db migrate
✓ aplicada migration 20260616_153020_add_user_avatar_url.sql
Enter fullscreen mode Exit fullscreen mode

Rollback:

$ fitz db rollback
✓ revertida migration 20260616_153020_add_user_avatar_url.sql
Enter fullscreen mode Exit fullscreen mode

El compilador compara los @table types del código con el schema vivo de la DB y emite las diffs idempotentes. La migración se commitea (es un archivo .sql) para review en el PR. La fase OPS sigue siendo tu responsabilidad (timing del deploy, downtime planning para ALTER COLUMN type que reescribe la tabla, etc.) — pero el SQL ya está generado y revisado.

En Alembic, --autogenerate a veces genera SQL incorrecto (especialmente para constraints, índices compuestos, defaults complejos) y siempre se recomienda revisar el archivo manualmente. Fitz emite el diff sin tocar el SQL del usuario — vos lo ves y lo applicás.

¿Cuán rápido es? — bench reproducible

Promesas vacías son fáciles. El repo trae un bench reproducible entre dos boilerplates equivalentes:

Mismo Postgres, mismos endpoints, mismo shape de respuesta, mismo docker compose. Headline en v0.10.13 (Intel Core Ultra 7 155H, Docker 29.2.1, 30s sostenidos, concurrencia 10):

Métrica Fitz ORM Python + SQLAlchemy Speedup
Memory peak 9.2 MB 51 MB 5.5× más eficiente
GET /users p50 4.88 ms 37.85 ms 7.76×
GET /users RPS 1944 246 7.91×
GET /users/{id} p50 3.60 ms 31.87 ms 8.85×
GET /users/{id} RPS 2604 296 8.80×
Cold start 0.14 s 0.22 s 1.57×
Image size 131 MB 258 MB 2× más liviano

Reproducí los números con bash benchmarks/orm-vs-sqlalchemy/run.sh (~5-8 min con cache Docker caliente; requiere oha + jq). Metodología completa, output crudo y dónde la comparación es *in*justa para Fitz (ej.: SQLAlchemy hace más trabajo del lado del ORM con tracking de identity map) están en el README del bench.

Por qué Fitz es rápido: SQL constante en compile-time (cero overhead runtime de construir queries), driver Postgres puro Rust (sin libpq overhead), serialización JSON con tipos concretos (sin reflection), Arc<Mutex<>> para shared state HTTP (multi-thread sin GIL).

Por qué SQLAlchemy es lento en este test: identity map, lazy loading flags, eventos, descriptors de columnas — muchas features que valen su costo cuando las necesitás, no cuando no.

Lo que Fitz NO te da (todavía)

Honestidad sobre las deudas:

  • Solo Postgres. No MySQL, no SQLite. Postgres es la elección por el shape del ecosystem (binary protocol claro, tipos ricos, JSONB, arrays). MySQL/SQLite quedan para cuando aparezca demanda.
  • BelongsTo en .preload(...) — sólo HasMany/HasOne en eager loading por ahora. BelongsTo eager: deuda residual.
  • Composite PKs@primary solo sobre un field. Tables con PK compuesta (raro en proyectos nuevos): no soportadas.
  • JSON operators de Postgres en .where — los más usados están (.has_key, .contains_json, .get), pero jsonb_path_query y similares solo via db.query(...) raw.
  • Read replicas / sharding. La conn pool es contra una sola DB. Para read replicas hay que coordinar a mano.
  • Migraciones con cero downtime para columns con NOT NULL sin default. El diff lo emite pero la fase OPS sigue siendo manual.
  • Performance tooling (EXPLAIN ANALYZE integrado en LSP) — no en MVP.

Escape hatch: SQL crudo cuando lo necesites

Para CTEs complejas, window functions, queries que el ORM no cubre:

let result = db.query("
    WITH ranked AS (
        SELECT id, name, ROW_NUMBER() OVER (PARTITION BY role ORDER BY created_at DESC) AS rn
        FROM users
    )
    SELECT * FROM ranked WHERE rn <= 3
", []).await?
Enter fullscreen mode Exit fullscreen mode

db.query(sql, params) devuelve List<Map<Str, Any>>. Sin chequeo estático del SQL ni de los column names — vos sabés. Para 90% del CRUD usás el ORM tipado; para queries chiflados, escape hatch.

Cierre

La queja más legítima contra los ORMs es "agregan una capa de abstracción que después tenés que romper para hacer queries serias". SQLAlchemy lo resuelve con un DSL muy expresivo, a costo de runtime overhead y curva de aprendizaje. Diesel/sqlx lo resuelven generando SQL en compile-time, a costo de macros procedurales y verbosidad.

Fitz toma la ruta de Diesel/sqlx pero con sintaxis del lenguaje (no macros), un type checker que valida los closure-to-SQL, y migraciones del schema integradas como sub-comando del binario. Y porque está en el lenguaje, el mismo @table type se usa como request body de HTTP, como response, como argumento de User.insert(...), como elemento de List<User> que serializa a JSON — UNA fuente de verdad.

Si tu proyecto cabe en Postgres y no necesitás composite PKs ni read replicas en el corto plazo, el ORM de Fitz cierra el ciclo "request HTTP → DB → response" con menos partes móviles y mejor performance que el stack típico Python.

Si tu proyecto necesita uno de los items de "no está en el MVP", interop Python sigue siendo una opción válida (from python import sqlalchemy), o la deuda en cuestión vive en el roadmap.


Esta cierra la serie de 9 posts: presentación, tutorial, deployment, CLI builder, WebSockets, cron jobs, auth, observability, y ORM. El stack entero. Lo que sigue son releases del lenguaje + el cap 31 de la guía (Postgres + ORM nativo) si querés profundizar.

Repo: github.com/Thegreekman76/fitz
Capítulo 31 de la guía (Postgres + ORM nativo): docs/guide.md
Doc dedicado de DB y ORM (~2600 LoC, 30 secciones): docs/db-orm.md
Bench reproducible: benchmarks/orm-vs-sqlalchemy

Top comments (0)