Atlas

ORM en Python: trade-offs, N+1, migraciones

Cuándo confiar en el ORM y cuándo bajar a Core/SQL, cómo mapea filas a objetos, cómo evolucionan los schemas con migraciones, y por qué la corrección bajo concurrencia la da el motor, no el ORM.

Teoría

Un ORM (Object-Relational Mapper) traduce entre dos mundos con formas incompatibles: filas y columnas en una base relacional, y objetos con referencias en tu programa. Ese desajuste tiene nombre —object-relational impedance mismatch— y el ORM es la capa que lo resuelve por vos: hidrata filas en instancias, sigue relaciones como atributos, y traduce mutaciones de objetos en INSERT/UPDATE/DELETE. La conveniencia tiene un costo: hidden queries, el clásico N+1, materialización cara de objetos, y una capa que a veces te impide expresar el SQL que necesitás. El nivel senior no es “sé usar el ORM” sino saber en qué capa estás pagando y cuándo cambiar de capa a conciencia.

Active Record vs Data Mapper

Son los dos patrones arquitectónicos que definen dónde vive la lógica de persistencia respecto de tu objeto de dominio.

Active Record (Martin Fowler, PoEAA): el objeto de dominio es la fila y sabe persistirse a sí mismo. El registro y su comportamiento de base de datos viven en la misma clase: user.save(), user.delete(), User.objects.filter(...). Django ORM es el ejemplo canónico en Python.

# Active Record (Django ORM): el modelo sabe hablar con la DB
class User(models.Model):
    name = models.CharField(max_length=255)
    email = models.EmailField(unique=True)

user = User.objects.get(pk=1)   # query colgada del modelo
user.name = "Ada"
user.save()                      # el objeto se persiste a sí mismo

Data Mapper (también PoEAA): una capa separada —el mapper/Session— traduce entre objetos de dominio ignorantes de la persistencia y la base. El objeto no sabe que existe una DB; el mapper decide qué y cuándo escribir. SQLAlchemy ORM implementa este patrón vía su Session (que además es una Unit of Work: acumula cambios y los sincroniza en el flush/commit).

# Data Mapper (SQLAlchemy 2.x): el objeto no sabe de la DB; la Session media
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column

class Base(DeclarativeBase):
    pass

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

# La persistencia vive en la Session, no en User
user = session.get(User, 1)
user.name = "Ada"
session.commit()          # la Session (Unit of Work) decide el UPDATE y lo emite

El trade-off en una frase: Active Record optimiza time-to-first-CRUD; Data Mapper optimiza separación y testabilidad del dominio a cambio de más aparato. En entrevista, la señal senior es no decir “cuál es mejor” sino ubicar cada uno: Django/Active Record encaja en apps CRUD-céntricas donde el modelo ≈ la tabla; SQLAlchemy/Data Mapper encaja cuando el dominio tiene reglas ricas, querés testear sin DB, o el mapeo objeto↔tabla no es 1:1 (un agregado sobre varias tablas, o una vista).

Para qué sirve un ORM y por qué no siempre raw SQL

Raw SQL es correcto y a veces superior. El ORM no existe para “evitar SQL” sino para resolver problemas concretos de un codebase de aplicación:

Cuándo NO conviene el ORM (bajar a Core o raw SQL):

La regla senior: usá el ORM para lógica de dominio transaccional (CRUD, escrituras con reglas de negocio) y bajá a Core/SQL cuando la forma del query importa más que el objeto. No es “ORM vs SQL”, es saber en qué capa pagás.

Connecting & querying (SQLAlchemy 2.x)

El estilo 2.0 unificó la API alrededor de select() y Session.execute()/scalars(). El viejo session.query(...) es legacy: evitalo en código nuevo.

from sqlalchemy import create_engine, select
from sqlalchemy.orm import Session, sessionmaker

# Engine: pool de conexiones + dialecto. Se crea UNA vez por proceso.
engine = create_engine(
    "postgresql+psycopg://user:pass@localhost:5432/app",
    pool_size=5,            # conexiones persistentes en el pool
    max_overflow=10,        # extra temporales bajo carga
    pool_pre_ping=True,     # valida la conexión antes de usarla (evita "stale connection")
    echo=False,             # True => loguea el SQL emitido (útil para cazar N+1)
)

SessionLocal = sessionmaker(engine)

# Patrón recomendado: la Session vive lo que dura una unidad de trabajo (un request)
with SessionLocal() as session:
    # scalars() => devuelve entidades (objetos User), no filas Row
    stmt = select(User).where(User.email == "ada@example.com")
    user = session.scalars(stmt).one()          # .one() / .one_or_none() / .first() / .all()

    # execute() => devuelve Rows (tuplas) cuando pedís columnas o varias entidades
    rows = session.execute(
        select(User.id, User.name).where(User.name.like("A%"))
    ).all()                                       # [(1, "Ada"), ...]

    session.add(User(name="Grace", email="grace@example.com"))
    session.commit()                              # flush + COMMIT

Puntos que un senior no confunde:

Relaciones: Foreign Key, one-to-many, many-to-many

from __future__ import annotations
from typing import List
from sqlalchemy import ForeignKey, Table, Column
from sqlalchemy.orm import Mapped, mapped_column, relationship

class Customer(Base):
    __tablename__ = "customers"
    id: Mapped[int] = mapped_column(primary_key=True)
    name: Mapped[str]
    # one-to-many: un customer tiene muchos orders
    orders: Mapped[List["Order"]] = relationship(back_populates="customer")

class Order(Base):
    __tablename__ = "orders"
    id: Mapped[int] = mapped_column(primary_key=True)
    customer_id: Mapped[int] = mapped_column(ForeignKey("customers.id"))
    # many-to-one: el lado "dueño" de la FK
    customer: Mapped["Customer"] = relationship(back_populates="orders")

# many-to-many: tabla de asociación (Core Table, no un modelo)
post_tags = Table(
    "post_tags", Base.metadata,
    Column("post_id", ForeignKey("posts.id"), primary_key=True),
    Column("tag_id", ForeignKey("tags.id"), primary_key=True),
)

class Post(Base):
    __tablename__ = "posts"
    id: Mapped[int] = mapped_column(primary_key=True)
    tags: Mapped[List["Tag"]] = relationship(secondary=post_tags, back_populates="posts")

class Tag(Base):
    __tablename__ = "tags"
    id: Mapped[int] = mapped_column(primary_key=True)
    posts: Mapped[List["Post"]] = relationship(secondary=post_tags, back_populates="tags")

Lazy vs eager loading — el eje del N+1

Cuando accedés a una relación (order.customer, user.orders), el ORM tiene que decidir cuándo emitir el SQL para traerla. Esa estrategia de carga es la raíz del N+1.

Estrategias eager de SQLAlchemy 2.x:

EstrategiaCómo cargaÚsala paraOjo con
selectinload2ª query: ... WHERE fk IN (ids)colecciones (to-many): user.ordersninguno serio; escala bien, no infla filas
joinedload1 query con JOINescalares (to-one): order.customeren to-many multiplica filas → exige .unique()
subqueryload2ª query con subquery del padreto-many (legacy, previo a selectinload)peor con LIMIT; casi siempre selectinload gana
contains_eagerreutiliza un JOIN que vos ya escribistecuando filtrás por la relación y querés hidratarlatenés que escribir el .join() a mano
raiseloadprohíbe la carga (lanza si la tocás)blindar contra lazy loads accidentales en prodrequiere que cargues explícito lo que necesitás
from sqlalchemy.orm import selectinload, joinedload, contains_eager, raiseload

# selectinload => colecciones. 2 queries: users, luego orders WHERE user_id IN (...)
users = session.scalars(
    select(User).options(selectinload(User.orders))
).all()

# joinedload => escalar to-one. 1 query con JOIN
orders = session.scalars(
    select(Order).options(joinedload(Order.customer))
).all()

# joinedload sobre colección => filas duplicadas: OBLIGА .unique() en Python
orders = session.scalars(
    select(Order).options(joinedload(Order.items))
).unique().all()

# Anidado: carga en cadena por nivel (customer de cada order, y orders de cada user)
stmt = select(User).options(
    selectinload(User.orders).joinedload(Order.customer)
)

# contains_eager: yo escribo el JOIN (para filtrar) y le digo que hidrate con él
stmt = (
    select(Order)
    .join(Order.customer)
    .where(Customer.tier == "premium")
    .options(contains_eager(Order.customer))
)

# raiseload: defensa. Cualquier lazy load no previsto explota en vez de N+1 silencioso
stmt = select(User).options(raiseload("*"))

Regla de pulgar: selectinload para to-many, joinedload para to-one. La razón profunda es cardinalidad: joinedload sobre una colección produce un producto cartesiano de filas (padre duplicado por cada hijo), infla el payload y rompe la semántica de LIMIT/paginación (el LIMIT corta filas del JOIN, no entidades). selectinload deja intacta la query principal, así que compone con LIMIT/OFFSET y window functions. Para árboles anidados profundos, encadená por nivel y medí: a veces dos selectinload baten a un JOIN gigante, a veces un joinedload sobre una cadena to-one gana. Decisión perfilada, no dogmática.

N+1: qué es, cómo se detecta, cómo se arregla

El N+1 es el anti-patrón más común del ORM: 1 query para traer N padres + N queries (una por padre) para traer sus hijos vía lazy loading. 200 usuarios con sus órdenes → 201 round-trips a la DB. El cuello de botella no es el costo de escaneo por query sino el número de round-trips y la latencia de red acumulada — por eso “agregar un índice” no lo arregla.

# --- N+1: el ORM te traiciona (lazy loading dentro de un loop) ---
users = session.scalars(select(User)).all()   # 1 query
for u in users:
    print(u.orders)                            # +1 query POR usuario => 1 + N

# --- Fix: eager loading, 1+N pasa a 2 queries ---
users = session.scalars(
    select(User).options(selectinload(User.orders))
).all()
for u in users:
    print(u.orders)                            # 0 queries extra: ya está cargado

Detección (en orden de fiabilidad):

from sqlalchemy import event

def install_query_counter(engine):
    stats = {"count": 0}
    @event.listens_for(engine, "before_cursor_execute")
    def _count(conn, cursor, statement, params, context, executemany):
        stats["count"] += 1
    return stats

# en un test:
stats = install_query_counter(engine)
get_users_with_orders(session)
assert stats["count"] <= 2, f"posible N+1: {stats['count']} queries"

ORM vs Core — no es todo-o-nada

SQLAlchemy tiene dos capas y podés mezclarlas en el mismo proyecto (y hasta en la misma Session):

from sqlalchemy import func, insert, update

# Core-style: agregación de reporting, tuplas en vez de objetos ORM
stmt = (
    select(User.country, func.count(Order.id).label("n"))
    .join(Order, Order.customer_id == User.id)
    .group_by(User.country)
    .order_by(func.count(Order.id).desc())
)
rows = session.execute(stmt).all()   # [("CO", 1240), ("MX", 980), ...]

# Bulk insert con executemany (NO session.add en loop)
session.execute(
    insert(Order),
    [{"customer_id": 1, "total": 10}, {"customer_id": 2, "total": 20}],  # miles de dicts
)

# Set-based update: una sentencia, sin traer objetos ni aritmética en Python
session.execute(
    update(Order).where(Order.status == "pending").values(status="expired")
)
session.commit()

Core sigue dándote binding de parámetros y portabilidad de dialecto, así que rara vez necesitás string SQL crudo; cuando sí, va parametrizado (text("... WHERE id = :id"), nunca f-strings) y fijado a un dialecto a propósito. El tell de que estás en la capa equivocada: si estás peleando contra el ORM para expresar el query, bajá a Core.

Migraciones — evolucionar el schema con Alembic

El schema de una DB en producción cambia con el tiempo (nuevas columnas, tablas, índices, backfills). Una migración es un cambio de schema versionado, ordenado y reversible, checkeado en el repo junto al código, de modo que cualquier entorno (dev, CI, prod) converja al mismo estado aplicando la misma secuencia. Sin migraciones tenés drift: la DB de prod no coincide con la de nadie.

Alembic es la herramienta estándar para SQLAlchemy (Django trae su propio sistema de migraciones equivalente). Cada migración es un archivo con revision, down_revision (forman una cadena/DAG) y dos funciones: upgrade() y downgrade().

alembic init migrations                              # scaffold inicial
alembic revision --autogenerate -m "add orders table"   # genera diff schema->modelos
alembic upgrade head                                 # aplica hasta la última revisión
alembic downgrade -1                                 # revierte la última
alembic current                                      # en qué revisión está la DB
alembic history                                      # cadena de revisiones

--autogenerate compara el target_metadata (tus modelos, Base.metadata) contra el estado real de la DB y escribe el diff. No es mágico: detecta bien tablas/columnas/índices, pero no cambios de tipo sutiles, renombres (los ve como drop+add), ni cambios de datos. Un senior revisa siempre el archivo generado antes de commitear.

# Un archivo de migración típico (migrations/versions/ab12_add_orders.py)
from alembic import op
import sqlalchemy as sa

revision = "ab12"
down_revision = "9f01"

def upgrade() -> None:
    op.create_table(
        "orders",
        sa.Column("id", sa.Integer, primary_key=True),
        sa.Column("customer_id", sa.Integer, sa.ForeignKey("customers.id"), nullable=False),
        sa.Column("total", sa.Numeric(12, 2), nullable=False),
    )
    op.create_index("ix_orders_customer_id", "orders", ["customer_id"])

def downgrade() -> None:
    op.drop_index("ix_orders_customer_id", table_name="orders")
    op.drop_table("orders")

Práctica senior sobre migraciones:

Isolation levels — la corrección la da el motor, no el ORM

Ninguna estrategia de loading ni patrón de repositorio te salva de una anomalía de concurrencia: eso lo definen las transacciones ACID y el isolation level. El nivel de aislamiento decide qué anomalías son posibles cuando transacciones concurrentes tocan los mismos datos.

NivelPermiteCuándo
READ UNCOMMITTEDdirty readcasi nunca; analytics donde no importa precisión (en Postgres se comporta como READ COMMITTED)
READ COMMITTEDnon-repeatable read, phantomdefault en Postgres; ok para la mayoría de apps
REPEATABLE READphantom (en Postgres, snapshot: también los bloquea)leer un conjunto consistente durante toda la transacción
SERIALIZABLEnada (máximo aislamiento)invariantes críticos; costo: locks / retries por serialization failure

Anomalías, de menos a más grave: dirty read (leés algo no commiteado que luego hace rollback), non-repeatable read (releés una fila y cambió), phantom read (repetís un WHERE y aparecen/desaparecen filas), lost update (dos read-modify-write se pisan). El orden READ UNCOMMITTED → READ COMMITTED → REPEATABLE READ → SERIALIZABLE va quitando anomalías a cambio de throughput.

El bug clásico es el lost update sobre READ COMMITTED: dos requests leen balance = 100, cada uno computa 100 - 10 en código de app, ambos escriben 90; un débito desaparece. Subir el isolation level no lo arregla por sí solo si la lectura-modificación-escritura no es atómica — y en motores MVCC (Postgres) REPEATABLE READ/SERIALIZABLE no bloquean, sino que fallan la transacción con un error de serialización que vos tenés que reintentar (código que probablemente no escribiste). Fixes reales, en orden de preferencia:

  1. Update atómico set-based (sin aritmética en Python): la DB hace el cálculo bajo su propio lock de fila.
  2. Lock pesimista con SELECT ... FOR UPDATE cuando necesitás leer, decidir y escribir con la fila bloqueada.
  3. Concurrencia optimista con columna version y retry.
from sqlalchemy import update

# 1) Atómico set-based: no hay ventana entre leer y escribir. Preferido para dinero/stock.
session.execute(
    update(Account).where(Account.id == acc_id).values(balance=Account.balance - 10)
)
session.commit()

# 2) Lock pesimista: leo la fila BLOQUEADA, decido en app, escribo. Serializa a los concurrentes.
acc = session.scalars(
    select(Account).where(Account.id == acc_id).with_for_update()
).one()
if acc.balance >= 10:
    acc.balance -= 10
session.commit()          # el lock se libera al COMMIT

# Setear isolation level por-transacción (execution option sobre la conexión/engine)
engine_serializable = engine.execution_options(isolation_level="SERIALIZABLE")

Nota de portabilidad: el mismo nombre de nivel se comporta distinto entre motores. El REPEATABLE READ de Postgres es snapshot isolation y bloquea phantoms que el de MySQL trata diferente; el SERIALIZABLE de Postgres es SSI (Serializable Snapshot Isolation) y aborta con serialization failures en vez de bloquear. Nunca asumas semántica cross-engine sin verificar.

Cierre de mentalidad: el ORM es la capa de productividad y dominio; Core/SQL es la capa de forma del query; el motor y sus transacciones son la capa de corrección bajo concurrencia. Seniority es moverse entre las tres a conciencia — no clavar una etiqueta global (“usamos el ORM para todo” / “SERIALIZABLE por las dudas”) sino decidir por operación.

Ejercicios

1. Diagnosticar y arreglar un N+1. Tenés un endpoint que devuelve 500 posts, cada uno con su autor y sus tags, y tarda ~3s. Escribí el select() que lo baje a un número acotado de queries y justificá la elección de estrategia por relación.

Solución

author es to-one → joinedload. tags es to-many (many-to-many) → selectinload (un joinedload sobre tags duplicaría cada post por cada tag y rompería cualquier LIMIT).

stmt = (
    select(Post)
    .options(
        joinedload(Post.author),      # to-one: 1 JOIN, sin duplicar
        selectinload(Post.tags),      # to-many: 2ª query IN (...), sin inflar filas
    )
    .limit(500)
)
posts = session.scalars(stmt).unique().all()   # .unique() por el joinedload

Resultado: 2 queries (posts+author en una, tags en otra) en vez de 1 + 500 + 500. Añadí un test con assertNumQueries/contador para que no regrese.

2. Active Record vs Data Mapper en decisión real. Un equipo arranca un servicio con reglas de dominio ricas (cálculo de pricing, workflows de estado) y quiere testear la lógica sin levantar una DB. ¿Qué patrón/ORM recomendás y por qué? ¿Cuándo habrías elegido el otro?

Solución

Data Mapper (SQLAlchemy ORM): el dominio queda ignorante de la persistencia, así que la lógica de pricing/workflows se testea con objetos puros sin Session. La separación paga cuando el dominio es más que CRUD. Habría elegido Active Record (Django) si fuera una app CRUD-céntrica donde el modelo ≈ la tabla, el time-to-market manda, y el ecosistema Django (admin, auth, DRF) aporta más que la pureza del dominio. La decisión es contextual: complejidad del dominio y necesidad de testear en aislamiento vs velocidad y batteries-included.

3. Elegir la capa (ORM vs Core). Tenés que (a) confirmar una orden aplicando reglas de negocio, y (b) generar un reporte mensual de ventas por país con ranking. ¿Qué capa usás en cada una?

Solución

(a) ORM: es lógica de dominio transaccional; el Unit of Work y el identity map ganan, y las reglas viven cerca del modelo. (b) Core select() con func, group_by, y window functions si hay ranking — sin hidratar objetos, devolviendo Rows. Meter el reporte por object-mode ORM sería lento y menos legible que el SQL que esconde. El criterio: ¿me importa el objeto (ORM) o la forma del SQL (Core)?

4. Lost update bajo concurrencia. Dos requests debitan $10 de la misma cuenta con balance = 100 casi simultáneamente en READ COMMITTED, leyendo el balance y escribiendo balance - 10 desde código Python. ¿Qué pasa y cómo lo arreglás sin subir a SERIALIZABLE ciegamente?

Solución

Ambos leen 100, ambos computan 90, ambos escriben 90: se pierde un débito (lost update). Subir el nivel no basta si la lectura-modificación-escritura no es atómica (y en MVCC te obliga a reintentar serialization failures). Fix preferido: update atómico set-based UPDATE accounts SET balance = balance - 10 WHERE id = ?, donde la DB calcula bajo su lock de fila. Alternativas: SELECT ... FOR UPDATE (lock pesimista) o columna version + retry (optimista). Para dinero/stock/contadores: SQL atómico sobre aritmética en app.

5. Migración zero-downtime. Necesitás renombrar orders.notes a orders.internal_notes sin downtime, con la app corriendo en varias instancias. Describí la secuencia de migraciones.

Solución

Expand/contract en pasos:

  1. Expand: migración que agrega internal_notes (nullable). Deploy de código que escribe en ambas columnas y lee de la nueva con fallback a la vieja.
  2. Backfill: copiar notes → internal_notes en batches idempotentes (fuera de la migración de DDL si es grande).
  3. Switch de lectura: deploy que lee solo internal_notes y deja de escribir notes.
  4. Contract: una vez que ninguna instancia desplegada referencia notes, migración que la elimina.

Un rename directo (ALTER ... RENAME COLUMN) rompería las instancias viejas que aún referencian el nombre anterior durante el rollout.

Preguntas tipo entrevista (EN)

Q1 — “You have an endpoint returning 200 users, each with their orders, and it’s slow. What’s happening and how do you fix it?”

Q2 — “When would you choose joinedload over selectinload, and vice versa?”

Q3 — “When do you drop the ORM and write raw SQL or use SQLAlchemy Core?”

Q4 — “Walk me through isolation levels and a real bug caused by picking the wrong one.”

Q5 — “Repository pattern with business logic in the app, or push logic into the DB with triggers/stored procedures?”

Q6 — “How does CAP theorem influence how you design a service’s data layer?”

Q7 — “Active Record vs Data Mapper — what’s the difference and when does it matter?”

Referencias