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")
# 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] = []
# 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
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
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
}
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
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?
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
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()
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?
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))
)
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?
.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"
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)
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
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()",
}
Diff:
$ fitz db diff
+ ALTER TABLE users ADD COLUMN avatar_url TEXT;
Aplicar:
$ fitz db migrate
✓ aplicada migration 20260616_153020_add_user_avatar_url.sql
Rollback:
$ fitz db rollback
✓ revertida migration 20260616_153020_add_user_avatar_url.sql
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:
-
api-postgres-fitz— Fitz + ORM nativo. -
api-postgres-python— FastAPI + SQLAlchemy 2.x async.
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 —
@primarysolo 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), perojsonb_path_queryy similares solo viadb.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 NULLsin 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?
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)