· 4 min de lectura · 722 palabras
SQLAlchemy: errores comunes y cómo evitarlos
Introducción
SQLAlchemy es el estándar de facto para ORM en Python. Es potente, flexible, y a veces frustrante. La mayoría de los problemas con SQLAlchemy vienen de no entender cómo funciona por debajo.
En este artículo voy a cubrir los errores más comunes que he visto (y cometido) con SQLAlchemy y cómo evitarlos.
Error 1: N+1 Queries
El problema
# models.py
class Usuario(Base):
__tablename__ = "usuarios"
id = Column(Integer, primary_key=True)
nombre = Column(String)
pedidos = relationship("Pedido", back_populates="usuario")
class Pedido(Base):
__tablename__ = "pedidos"
id = Column(Integer, primary_key=True)
usuario_id = Column(ForeignKey("usuarios.id"))
total = Column(Numeric)
usuario = relationship("Usuario", back_populates="pedidos")
# views.py
def obtener_pedidos_por_usuario():
usuarios = session.query(Usuario).all()
for usuario in usuarios:
print(len(usuario.pedidos)) # ¡Query separado para cada usuario!
Esto genera 1 query para traer usuarios + N queries para contar pedidos (una por usuario).
La solución
from sqlalchemy.orm import joinedload, subqueryload, selectinload
# eager loading con JOIN
usuarios = session.query(Usuario).options(
joinedload(Usuario.pedidos)
).all()
# O con selectinload (2 queries, mejor para colecciones grandes)
usuarios = session.query(Usuario).options(
selectinload(Usuario.pedidos)
).all()
# Para relaciones muchos-a-muchos, selectinload suele ser mejor
selectinload(Usuario.pedidos)
# Con with_expression para queries complejas
from sqlalchemy.orm import with_expression, selectinload
usuarios = session.query(Usuario).options(
selectinload(Usuario.pedidos),
with_expression(Usuario.total_pedidos, select(func.count())....)
)
Error 2: Session management incorrecto
El problema
# Sin contexto: la sesión nunca se cierra
def get_user(user_id):
session = Session()
user = session.query(User).get(user_id)
return user # session no se cierra
La solución
from contextlib import contextmanager
@contextmanager
def get_session():
session = Session()
try:
yield session
session.commit()
except Exception:
session.rollback()
raise
finally:
session.close()
# Uso
with get_session() as session:
user = session.query(User).get(user_id)
Con FastAPI/Starlette
from starlette.middleware.base import BaseHTTPMiddleware
class DBSessionMiddleware(BaseHTTPMiddleware):
async def dispatch(self, request, call_next):
session = Session()
request.state.db = session
try:
response = await call_next(request)
session.commit()
return response
except Exception:
session.rollback()
raise
finally:
session.close()
Error 3: Consultas ineficientes
SELECT * cuando necesitas 3 columnas
# Malo: trae todas las columnas (pueden ser 50+)
usuario = session.query(Usuario).filter(Usuario.id == 1).first()
# Bueno: solo las columnas necesarias
usuario = session.query(
Usuario.id, Usuario.nombre, Usuario.email
).filter(Usuario.id == 1).first()
Filtro en columna sin índice
# Malo: LOWER() impide usar índice (a menos que sea funcional)
usuarios = session.query(Usuario).filter(
func.lower(Usuario.email) == "test@ejemplo.com"
).all()
# Bueno: buscar exacto (usa índice en email)
usuarios = session.query(Usuario).filter(
Usuario.email == "test@ejemplo.com"
).all()
Ordenar por columna sin índice
# Malo: filesort en disco si fecha no tiene índice
pedidos = session.query(Pedido).order_by(Pedido.fecha).all()
# Bueno: índice en fecha (ORDER BY usa índice)
# Crear índice: CREATE INDEX idx_pedidos_fecha ON pedidos(fecha)
pedidos = session.query(Pedido).order_by(Pedido.fecha).all()
Error 4: Bulk operations una por una
El problema
# Un INSERT por cada item
for item in items:
session.add(Pedido(**item))
session.commit()
La solución
# INSERT masivo en una sola consulta
session.bulk_insert_mappings(Pedido, items)
session.commit()
# UPDATE masivo
session.query(Usuario).filter(
Usuario.ultimo_acceso < hace_un_mes
).update({"activo": False})
session.commit()
# DELETE masivo
session.query(Log).filter(
Log.fecha < hace_un_anio
).delete()
session.commit()
Error 5: Lazy loading fuera de sesión
# views.py
def get_pedidos(user_id):
session = Session()
user = session.query(Usuario).get(user_id)
session.close()
return user.pedidos # LazyLoadingError: la sesión está cerrada
Soluciones
- Eager loading con joinedload/selectinload
- Mantener sesión abierta
- Serializar los datos antes de cerrar
Error 6: Transacciones demasiado largas
El problema
def procesar_lote(items):
session = Session()
for item in items:
procesar_item(item) # puede tardar segundos por item
session.add(Procesamiento(item=item))
session.commit() # transacción abierta por minutos
La solución: commits intermedios
def procesar_lote(items):
session = Session()
try:
for i, item in enumerate(items):
procesar_item(item)
session.add(Procesamiento(item=item))
if i % 100 == 0: # commit cada 100 items
session.commit()
session.commit()
except Exception:
session.rollback()
raise
finally:
session.close()
Error 7: No usar índices compuestos
# Consulta común en la app
pedidos = session.query(Pedido).filter(
Pedido.usuario_id == 1,
Pedido.estado == "pendiente"
).order_by(Pedido.fecha.desc()).all()
# Índice compuesto para esta consulta
class Pedido(Base):
__tablename__ = "pedidos"
__table_args__ = (
Index("idx_usuario_estado_fecha", "usuario_id", "estado", "fecha"),
)
Conclusión
SQLAlchemy es una herramienta increíble cuando la entiendes. Los errores más comunes se reducen a:
- Sobrecarga de queries: Usa eager loading
- Gestión de sesión: Context managers
- Ineficiencia: SELECT específicos, filtros con índices
- Bulk operations: bulk_insert_mappings
- Lazy loading: Carga lo que necesitas antes de cerrar
La clave: entiende qué SQL genera SQLAlchemy. Usa echo=True o logging para ver las queries reales y optimiza donde haga falta.