#¿Qué es una relación Many-to-Many?
En una base de datos, las tablas raramente viven solas. Casi siempre están relacionadas entre sí. Existen tres tipos principales de relaciones:
| Tipo | Significado | Ejemplo |
|---|---|---|
One-to-One |
Un registro se relaciona con exactamente uno del otro | Una persona ↔ un DNI |
One-to-Many |
Un registro se relaciona con muchos del otro lado | Un autor → muchos libros |
Many-to-Many |
Muchos se relacionan con muchos | Estudiantes ↔ Cursos |
Una relación Many-to-Many (M2M) ocurre cuando múltiples registros de una tabla pueden estar relacionados con múltiples registros de otra tabla, y viceversa.
En Django, las relaciones Many-to-Many se definen con el campo ManyToManyField en los modelos. Django se encarga automáticamente de crear la tabla intermedia necesaria en la base de datos.
#La analogía del mundo real
Antes de ver código, pensemos en la vida real. Imagina una universidad:
- Un estudiante puede estar matriculado en varios cursos (Matemáticas, Historia, Programación…)
- Un curso puede tener varios estudiantes matriculados.
Esto es exactamente una relación Many-to-Many: muchos estudiantes con muchos cursos.
tabla intermedia
Otro ejemplo muy común: Artículos y Etiquetas en un blog. Un artículo puede tener varias etiquetas (Python, Django, Web…) y una etiqueta puede pertenecer a varios artículos.
La clave para identificar una relación M2M es hacerse las preguntas: "¿Puede un X tener muchos Y?" y "¿Puede un Y tener muchos X?". Si ambas respuestas son SÍ, es Many-to-Many.
#Tu primer Many-to-Many en Django
Vamos a construir el modelo de la universidad paso a paso. Para ello, creamos una aplicación Django llamada academia.
Definir los modelos
El campo clave es ManyToManyField. Solo necesitas ponerlo en uno de los dos modelos (Django crea la relación en ambas direcciones automáticamente).
from django.db import models
class Curso(models.Model):
nombre = models.CharField(max_length=200)
creditos = models.PositiveIntegerField(default=6)
def __str__(self):
return self.nombre
class Estudiante(models.Model):
nombre = models.CharField(max_length=200)
email = models.EmailField(unique=True)
# 👇 Aquí está la magia: ManyToManyField
cursos = models.ManyToManyField(
Curso,
blank=True, # permite que un estudiante no tenga cursos
related_name='estudiantes' # acceso inverso desde Curso
)
def __str__(self):
return self.nombre
related_name es muy útil: te permite consultar desde Curso quiénes son sus estudiantes con curso.estudiantes.all(). Sin él, Django usaría curso.estudiante_set.all().
Crear y aplicar las migraciones
Ahora le decimos a Django que cree las tablas en la base de datos:
# 1. Generamos el fichero de migración
python manage.py makemigrations academia
# 2. Aplicamos la migración a la base de datos
python manage.py migrate
¿Qué tablas crea Django?
Django crea tres tablas en la base de datos:
nombre
(tabla pivot automática)
estudiante_id ← FK
curso_id ← FK
nombre
creditos
La tabla pivot (o tabla intermedia) academia_estudiante_cursos es creada automáticamente por Django. Cada fila en ella representa una matrícula: un estudiante en un curso específico. Tú no la tocas directamente — Django lo gestiona por ti.
#Operaciones básicas con M2M
El ORM de Django proporciona una API muy completa para gestionar relaciones M2M a través del Manager de la relación.
Crear objetos de ejemplo
# Creamos algunos cursos
mate = Curso.objects.create(nombre="Matemáticas", creditos=6)
prog = Curso.objects.create(nombre="Programación", creditos=9)
hist = Curso.objects.create(nombre="Historia", creditos=4)
# Creamos estudiantes
ana = Estudiante.objects.create(nombre="Ana García", email="ana@uni.es")
luis = Estudiante.objects.create(nombre="Luis Martín", email="luis@uni.es")
sofia = Estudiante.objects.create(nombre="Sofía Ruiz", email="sofia@uni.es")
Métodos del Manager M2M
| Método | ¿Qué hace? |
|---|---|
.add(*objs) | Añade una o varias relaciones |
.remove(*objs) | Elimina relaciones específicas |
.set([objs]) | Reemplaza todas las relaciones por la nueva lista |
.clear() | Elimina todas las relaciones |
.all() | Devuelve un QuerySet con todos los relacionados |
.filter(...) | Filtra entre los relacionados |
# ── ADD: matricular a Ana en Matemáticas y Programación ──
ana.cursos.add(mate, prog)
# También puedes añadir por ID:
ana.cursos.add(3) # añade el curso con id=3
# ── ALL: ver los cursos de Ana ──
ana.cursos.all()
# <QuerySet [<Curso: Matemáticas>, <Curso: Programación>]>
# ── RELACIÓN INVERSA: ver los estudiantes de Programación ──
prog.estudiantes.all()
# <QuerySet [<Estudiante: Ana García>]>
# ── REMOVE: dar de baja a Ana de Matemáticas ──
ana.cursos.remove(mate)
# ── SET: definir exactamente los cursos de Luis (reemplaza todo) ──
luis.cursos.set([mate, hist])
# ── CLEAR: quitar a Sofía de todos sus cursos ──
sofia.cursos.clear()
# ── COUNT: cuántos cursos tiene Luis ──
luis.cursos.count() # → 2
# ── EXISTS: ¿está Luis matriculado en Historia? ──
luis.cursos.filter(id=hist.id).exists() # → True
Importante: .add() nunca crea duplicados. Si intentas añadir una relación que ya existe, Django la ignora silenciosamente. ¡No hace falta que compruebes si ya existe!
#Consultas avanzadas con M2M
Las relaciones M2M son completamente compatibles con el potente sistema de consultas (QuerySet API) de Django. Podemos filtrar, anotar y ordenar cruzando tablas sin escribir SQL.
from django.db.models import Count, Q
# ── 1. Estudiantes matriculados en Programación ──
Estudiante.objects.filter(cursos__nombre="Programación")
# ── 2. Cursos que tiene Ana García ──
Curso.objects.filter(estudiantes__nombre="Ana García")
# ── 3. Cursos con MÁS de 5 estudiantes ──
Curso.objects\
.annotate(num_estudiantes=Count('estudiantes'))\
.filter(num_estudiantes__gt=5)
# ── 4. Estudiantes con AL MENOS 2 cursos, ordenados por nombre ──
Estudiante.objects\
.annotate(num_cursos=Count('cursos'))\
.filter(num_cursos__gte=2)\
.order_by('nombre')
# ── 5. Estudiantes matriculados en Mates O en Programación ──
Estudiante.objects.filter(
Q(cursos__nombre="Matemáticas") |
Q(cursos__nombre="Programación")
).distinct() # distinct() evita duplicados en uniones
# ── 6. Cursos que NO tienen ningún estudiante ──
Curso.objects.filter(estudiantes__isnull=True)
# ── 7. Prefetch para evitar el problema N+1 ──
estudiantes = Estudiante.objects.prefetch_related('cursos')
for est in estudiantes:
# Este acceso NO lanza una nueva query gracias a prefetch_related
print(est.nombre, est.cursos.all())
prefetch_related es tu mejor amigo con M2M. Cuando vayas a recorrer todos los estudiantes y sus cursos, úsalo siempre para evitar una query adicional por cada estudiante (el problema "N+1 queries").
El doble guión bajo __ en los filtros (cursos__nombre) es la notación de Django para navegar por las relaciones de un modelo a otro. Puedes encadenarlos tanto como necesites.
#Tabla intermedia personalizada (through)
La tabla pivot automática que crea Django es perfecta para muchos casos. Pero ¿qué pasa si necesitas guardar información extra sobre la relación misma?
En nuestro ejemplo universitario: ¿y si queremos saber la fecha de matrícula o la nota final de cada estudiante en cada curso? La tabla pivot automática no puede guardar eso.
Para esto usamos la opción through, que nos permite crear nuestra propia tabla intermedia con los campos adicionales que necesitemos.
from django.db import models
from django.utils import timezone
class Curso(models.Model):
nombre = models.CharField(max_length=200)
creditos = models.PositiveIntegerField(default=6)
def __str__(self):
return self.nombre
class Matricula(models.Model):
"""Tabla intermedia personalizada con datos extra."""
estudiante = models.ForeignKey('Estudiante', on_delete=models.CASCADE)
curso = models.ForeignKey(Curso, on_delete=models.CASCADE)
# 👇 Campos extra que necesitamos
fecha_matricula = models.DateField(default=timezone.now)
nota_final = models.FloatField(null=True, blank=True) # puede estar vacía
class Meta:
# Evita que un estudiante se matricule dos veces en el mismo curso
unique_together = [('estudiante', 'curso')]
verbose_name = "Matrícula"
ordering = ['-fecha_matricula']
def __str__(self):
return f"{self.estudiante} → {self.curso}"
class Estudiante(models.Model):
nombre = models.CharField(max_length=200)
email = models.EmailField(unique=True)
# 👇 Usamos through= para indicar nuestra tabla personalizada
cursos = models.ManyToManyField(
Curso,
through=Matricula,
related_name='estudiantes'
)
def __str__(self):
return self.nombre
Trabajar con la tabla through
Cuando usas through, ya no puedes usar .add(), .remove() o .set() directamente. En su lugar, creas y eliminas instancias del modelo intermedio (Matricula) directamente:
from datetime import date
# ── CREAR una matrícula (en lugar de .add()) ──
m = Matricula.objects.create(
estudiante = ana,
curso = prog,
fecha_matricula = date.today(),
)
# ── ACTUALIZAR la nota de Ana en Programación ──
m.nota_final = 8.5
m.save()
# ── LEER los cursos de Ana (funciona igual) ──
ana.cursos.all()
# ── LEER la matrícula para ver los datos extra ──
matricula = Matricula.objects.get(estudiante=ana, curso=prog)
print(matricula.nota_final) # → 8.5
# ── ELIMINAR una matrícula (en lugar de .remove()) ──
Matricula.objects.filter(estudiante=ana, curso=prog).delete()
# ── CONSULTAS avanzadas cruzando through ──
# Estudiantes que han aprobado (nota >= 5) Programación
Estudiante.objects.filter(
matricula__curso=prog,
matricula__nota_final__gte=5.0
)
# Cursos en los que Ana tiene nota superior a 7
ana.cursos.filter(matricula__nota_final__gt=7.0)
Usa through siempre que necesites guardar datos adicionales sobre la relación (fecha, estado, cantidad, etc.). Si solo necesitas saber qué está relacionado con qué, la tabla pivot automática es suficiente.
#Configurar el panel de Admin
Django Admin entiende las relaciones M2M de forma nativa. Con un par de líneas puedes hacer que sea muy cómodo de gestionar.
Admin básico con M2M simple
from django.contrib import admin
from .models import Estudiante, Curso, Matricula
@admin.register(Estudiante)
class EstudianteAdmin(admin.ModelAdmin):
list_display = ['nombre', 'email']
search_fields = ['nombre', 'email']
# filter_horizontal muestra un selector doble (izquierda/derecha)
filter_horizontal = ['cursos']
@admin.register(Curso)
class CursoAdmin(admin.ModelAdmin):
list_display = ['nombre', 'creditos']
search_fields = ['nombre']
Admin con tabla through (Inline)
Cuando usas through, puedes mostrar las matrículas como filas directamente dentro del formulario del estudiante, usando un TabularInline:
class MatriculaInline(admin.TabularInline):
model = Matricula
extra = 1 # cuántos formularios en blanco mostrar
fields = ['curso', 'fecha_matricula', 'nota_final']
@admin.register(Estudiante)
class EstudianteAdmin(admin.ModelAdmin):
list_display = ['nombre', 'email']
search_fields = ['nombre']
inlines = [MatriculaInline] # las matrículas aparecen dentro del estudiante
@admin.register(Matricula)
class MatriculaAdmin(admin.ModelAdmin):
list_display = ['estudiante', 'curso', 'fecha_matricula', 'nota_final']
list_filter = ['curso']
search_fields = ['estudiante__nombre', 'curso__nombre']
filter_horizontal vs filter_vertical: ambos muestran un selector de doble panel para elegir opciones en relaciones M2M. filter_horizontal tiene los paneles en horizontal y es el más popular.
#Buenas prácticas
Usa siempre related_name
Define un related_name descriptivo en todos tus ManyToManyField. Hace el código mucho más legible y evita la notación fea _set por defecto.
Usa prefetch_related al recorrer M2M
Siempre que vayas a iterar sobre objetos y acceder a su relación M2M dentro del bucle, usa prefetch_related. Sin él, Django hará una consulta SQL por cada objeto del bucle (problema N+1).
Usa through si la relación tiene datos propios
Si necesitas guardar cuándo, cómo, o cualquier otra información sobre el hecho de que dos objetos están relacionados, usa una tabla intermedia personalizada desde el principio. Es mucho más difícil añadirla después.
Añade unique_together en las tablas through
Si usas una tabla intermedia, asegúrate de que no puedas tener dos registros para la misma par de objetos relacionados. Usa unique_together o UniqueConstraint en la clase Meta.
Usa .distinct() cuando combines filtros con OR
Al filtrar M2M con condiciones OR (usando Q), los JOINs pueden generar filas duplicadas en el resultado. Añade .distinct() para eliminarlos.
Coloca el campo en el modelo más "natural"
Puedes poner el ManyToManyField en cualquiera de los dos modelos. Ponlo en el que tenga más sentido semánticamente: "Un estudiante tiene cursos" → el campo va en Estudiante.
#Mini Quiz de Comprobación
¿Cuánto has aprendido? Pon a prueba tus conocimientos con estas preguntas:
1. ¿Cuántas tablas crea Django cuando defines un ManyToManyField entre dos modelos sin usar through?
2. ¿Cuál de estos métodos reemplaza todas las relaciones existentes de un objeto con las nuevas?
3. Si usas through=Matricula, ¿cómo añades una nueva relación?
4. ¿Qué herramienta del ORM deberías usar para evitar el problema N+1 al acceder a relaciones M2M?
#Resumen
Has recorrido toda la teoría y práctica de las relaciones Many-to-Many en Django. Aquí tienes un resumen visual de los conceptos clave:
ManyToManyField
El campo que define la relación. Solo va en uno de los dos modelos. Django gestiona todo lo demás.
Tabla pivot
Tabla intermedia automática que Django crea para guardar los pares de IDs relacionados.
related_name
Permite navegar la relación en sentido inverso de forma clara y legible.
add / remove / set / clear
Los cuatro métodos principales para gestionar los objetos relacionados.
through
Tabla intermedia personalizada cuando necesitas guardar datos extra en la relación.
prefetch_related
Optimización esencial para evitar consultas N+1 al iterar sobre objetos con M2M.
¿Cuál es el siguiente paso? Practica creando tu propia app con un modelo M2M real: un sistema de recetas con ingredientes, un blog con etiquetas, o una red social con seguidores. La práctica es la clave para dominar el ORM de Django.