SQLAlchemy permite definir relaciones entre tablas de forma flexible y potente, haciendo que trabajar con objetos relacionados sea muy intuitivo.

Definición de Relaciones

Las relaciones en SQLAlchemy se definen en los modelos utilizando las funciones relationship y ForeignKey.

Relación uno a muchos

En una relación uno a muchos, una fila de una tabla está relacionada con múltiples filas en otra. Un ejemplo clásico es la relación entre usuarios y direcciones de correo electrónico:

from sqlalchemy import ForeignKey
from sqlalchemy.orm import relationship, Mapped, mapped_column
 
 
class Usuario(Base):
    __tablename__ = "usuarios"
 
    id: Mapped[int] = mapped_column(primary_key=True)
    nombre: Mapped[str]
 
    direcciones: Mapped[list["Direccion"]] = relationship(back_populates="usuario")
 
 
class Direccion(Base):
    __tablename__ = "direcciones"
 
    id: Mapped[int] = mapped_column(primary_key=True)
    email: Mapped[str]
    usuario_id: Mapped[int] = mapped_column(ForeignKey("usuarios.id"))
 
    usuario: Mapped["Usuario"] = relationship(back_populates="direcciones")

En este ejemplo, la tabla direcciones tiene una clave foránea usuario_id que referencia la tabla usuarios. Este vínculo es bidireccional: desde Usuario, puedes acceder a todas las direcciones asociadas mediante el atributo direcciones, y desde Direccion, puedes acceder al usuario mediante el atributo usuario. El parámetro back_populates es obligatorio y garantiza que la relación esté correctamente conectada en ambas direcciones.

Relación muchos a muchos

En una relación muchos a muchos, varias filas de una tabla pueden estar relacionadas con varias filas de otra. SQLAlchemy utiliza una tabla intermedia para manejar este tipo de relaciones.

# Tabla de asociación
class UsuarioGrupo(Base):
    __tablename__ = "usuarios_grupos"
 
    usuario_id: Mapped[int] = mapped_column(ForeignKey("usuarios.id"), primary_key=True)
    grupo_id: Mapped[int] = mapped_column(ForeignKey("grupos.id"), primary_key=True)
 
 
class Grupo(Base):
    __tablename__ = "grupos"
 
    id: Mapped[int] = mapped_column(primary_key=True)
    nombre: Mapped[str]
 
    usuarios: Mapped[list["Usuario"]] = relationship(
        secondary="usuarios_grupos", back_populates="grupos"
    )
 
 
class Usuario(Base):
    __tablename__ = "usuarios"
 
    id: Mapped[int] = mapped_column(primary_key=True)
    nombre: Mapped[str]
 
    grupos: Mapped[list["Grupo"]] = relationship(
        secondary="usuarios_grupos", back_populates="usuarios"
    )

La tabla UsuarioGrupo actúa como intermediaria entre Usuario y Grupo, y el parámetro secondary de relationship() se utiliza para especificar la tabla que conecta ambas entidades.

El parámetro secondary

Utiliza el parámetro secondary en la función relationship para especificar la tabla intermedia en una relación muchos a muchos. En el ejemplo, secondary="usuarios_grupos" indica que la tabla usuarios_grupos actúa como intermediaria entre las tablas usuarios y grupos. Este parámetro es fundamental para que SQLAlchemy sepa cómo unir las tablas relacionadas a través de la tabla intermedia.

Relación Uno a Uno

Una relación uno a uno se define cuando una fila en una tabla está relacionada con una y solo una fila en otra tabla. Este tipo de relación es menos común, pero puede ser útil para dividir datos entre tablas cuando se desea mantener una relación estrictamente única.

from sqlalchemy import UniqueConstraint
 
 
class Usuario(Base):
    __tablename__ = "usuarios"
 
    id: Mapped[int] = mapped_column(primary_key=True)
    nombre: Mapped[str]
 
    perfil: Mapped["Perfil"] = relationship(back_populates="usuario", uselist=False)
 
 
class Perfil(Base):
    __tablename__ = "perfiles"
 
    id: Mapped[int] = mapped_column(primary_key=True)
    bio: Mapped[Optional[str]]
    usuario_id: Mapped[int] = mapped_column(ForeignKey("usuarios.id"), unique=True)
 
    usuario: Mapped["Usuario"] = relationship(back_populates="perfil")

En este ejemplo, se define una relación uno a uno entre Usuario y Perfil. La opción uselist=False en relationship indica que se espera una única instancia relacionada, no una lista. Además, en la tabla Perfil se utiliza unique=True en la clave foránea usuario_id, lo que asegura que cada perfil esté vinculado a un solo usuario y viceversa.

Manejo de Datos Relacionados

Una vez definidas, SQLAlchemy te permite manipular los objetos relacionados de forma sumamente sencilla.

Crear y Relacionar Objetos

SQLAlchemy facilita la creación y relación de objetos sin tener que gestionar manualmente las claves foráneas:

nuevo_usuario = Usuario(nombre="Juan Pérez")
nueva_direccion = Direccion(email="juan@example.com", usuario=nuevo_usuario)
 
session.add(nuevo_usuario)
session.commit()

Al asociar nueva_direccion con nuevo_usuario, SQLAlchemy gestiona automáticamente la clave foránea usuario_id.

Consultar Relaciones

Para consultar objetos relacionados, los atributos que defines como relationship actúan como cualquier otro atributo. SQLAlchemy se encargará de generar las consultas SQL necesarias para obtener los datos relacionados:

...
usuario = session.get(Usuario, 1)
for direccion in usuario.direcciones:
    print(direccion.email)

Aquí, SQLAlchemy obtiene automáticamente las direcciones relacionadas con el usuario.

Eliminar Relaciones

Puedes manejar la eliminación de objetos relacionados de diferentes maneras dependiendo de cómo configures la relación. SQLAlchemy soporta eliminaciones en cascada, lo que significa que, si eliminas un objeto padre, los objetos hijos relacionados también pueden ser eliminados automáticamente.

...
usuario = session.get(Usuario, 1)
session.delete(usuario)
session.commit()

En este caso, si has configurado la eliminación en cascada (utilizando cascade="all, delete" en la relación), las direcciones asociadas al usuario también se eliminarán automáticamente cuando elimines el usuario. Sin embargo, si no está configurada, las direcciones seguirán existiendo en la base de datos, aunque ya no estarán asociadas a un usuario.

Carga de Datos Relacionados

SQLAlchemy utiliza diferentes estrategias para cargar los datos relacionados entre objetos. Por defecto usa lazy loading (carga perezosa), donde los datos relacionados se obtienen desde la base de datos solo cuando intentas acceder a ellos por primera vez. Esto es eficiente cuando no siempre necesitas los datos relacionados, pero puede generar múltiples consultas SQL si accedes a relaciones en bucles.

Cuando sabes que vas a necesitar los datos relacionados, puedes usar eager loading (carga inmediata) para obtener todo de una vez y evitar consultas adicionales:

selectinload()

Ejecuta una consulta adicional optimizada para obtener todos los datos relacionados:

from sqlalchemy.orm import selectinload
 
# Carga usuarios y sus direcciones en 2 consultas optimizadas
stmt = select(Usuario).options(selectinload(Usuario.direcciones))
usuarios = session.execute(stmt).scalars().all()
 
# Ahora podemos acceder a las direcciones sin consultas adicionales
for usuario in usuarios:
    for direccion in usuario.direcciones:
        print(direccion.email)

joinedload()

Utiliza un JOIN para cargar todo en una sola consulta SQL:

from sqlalchemy.orm import joinedload
 
# Carga usuarios y direcciones en una sola consulta con JOIN
stmt = select(Usuario).options(joinedload(Usuario.direcciones))
usuarios = session.execute(stmt).unique().scalars().all()

Cuándo usar cada estrategia

  • selectinload(): Mejor para relaciones uno-a-muchos con muchos elementos
  • joinedload(): Mejor para relaciones uno-a-uno o con pocos elementos relacionados

Para casos más complejos y detalles sobre optimización de consultas, consulta la documentación oficial sobre relationships loading techniques.