Las relaciones muchos-a-muchos modelan escenarios donde los registros de ambos lados pueden relacionarse con múltiples registros del otro lado: las publicaciones tienen muchas etiquetas, las etiquetas aparecen en muchas publicaciones. EF Core 5 introdujo las relaciones muchos-a-muchos implícitas, eliminando la necesidad de una clase de entidad de unión. EF Core 8 perfecciona esto aún más.
Cada consulta y escritura de este artículo tiene un conteo medido de sentencias SQL, producido por
samples/ef-core-many-to-many
— un proyecto de consola ejecutable que usa exactamente estas entidades y cubre ambas formas, la implícita y la explícita. La tabla completa está en Comprueba los costes tú mismo.
Muchos-a-Muchos Implícito (EF Core 5+)
El enfoque más sencillo — simplemente agrega propiedades de navegación de colección en ambos lados:
public class Post
{
public int Id { get; set; }
public required string Title { get; set; }
public required string Content { get; set; }
public DateTime PublishedAt { get; set; }
// Muchos-a-muchos: una publicación tiene muchas etiquetas
public List<Tag> Tags { get; set; } = [];
}
public class Tag
{
public int Id { get; set; }
public required string Name { get; set; }
public required string Slug { get; set; }
public bool IsActive { get; set; } = true;
// Muchos-a-muchos: una etiqueta aparece en muchas publicaciones
public List<Post> Posts { get; set; } = [];
}EF Core automáticamente:
- Crea una tabla de unión
PostTagcon columnasPostsIdyTagsId - Gestiona las inserciones/eliminaciones en la tabla de unión al modificar las colecciones
- No se necesita clase de entidad adicional ni
DbSetpara la tabla de unión
Opcional: Personalizar el Nombre de la Tabla de Unión
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<Post>()
.HasMany(p => p.Tags)
.WithMany(t => t.Posts)
.UsingEntity(j => j.ToTable("PostTags")); // Nombre de tabla personalizado
}Entidad de Unión Explícita
Cuando necesitas columnas adicionales en la tabla de unión (por ejemplo, marcas de tiempo, número ordinal), usa una entidad de unión explícita:
// La entidad de unión — tiene sus propias propiedades más allá de las FK
public class StudentCourse
{
public int StudentId { get; set; }
public Student Student { get; set; } = null!;
public int CourseId { get; set; }
public Course Course { get; set; } = null!;
// Datos adicionales
public DateTime EnrolledAt { get; set; }
public Grade? FinalGrade { get; set; }
}
public class Student
{
public int Id { get; set; }
public required string Name { get; set; }
public required string Email { get; set; }
// Se puede navegar directamente a Courses (omitiendo la entidad de unión)
public List<Course> Courses { get; set; } = [];
// O navegar a través de la entidad de unión (cuando necesitas los datos adicionales)
public List<StudentCourse> StudentCourses { get; set; } = [];
}
public class Course
{
public int Id { get; set; }
public required string Title { get; set; }
public int Credits { get; set; }
public List<Student> Students { get; set; } = [];
public List<StudentCourse> StudentCourses { get; set; } = [];
}
public enum Grade { A, B, C, D, F }// Fluent API para entidad de unión explícita
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<StudentCourse>(entity =>
{
entity.HasKey(sc => new { sc.StudentId, sc.CourseId }); // PK compuesta
entity.HasOne(sc => sc.Student)
.WithMany(s => s.StudentCourses)
.HasForeignKey(sc => sc.StudentId);
entity.HasOne(sc => sc.Course)
.WithMany(c => c.StudentCourses)
.HasForeignKey(sc => sc.CourseId);
entity.Property(sc => sc.EnrolledAt)
.HasDefaultValueSql("GETUTCDATE()");
});
// Configurar navegaciones de salto (acceso directo a Student.Courses)
modelBuilder.Entity<Student>()
.HasMany(s => s.Courses)
.WithMany(c => c.Students)
.UsingEntity<StudentCourse>();
}Consultas a Través de la Relación
Include Básico
// Cargar publicaciones con sus etiquetas
var posts = await _db.Posts
.Include(p => p.Tags)
.OrderBy(p => p.PublishedAt)
.ToListAsync();
// Cargar etiquetas con sus publicaciones
var tag = await _db.Tags
.Include(t => t.Posts)
.FirstOrDefaultAsync(t => t.Slug == "entity-framework");Include Filtrado (EF Core 5+)
// Cargar publicaciones solo con etiquetas publicadas y no en borrador
var posts = await _db.Posts
.Include(p => p.Tags.Where(t => t.IsActive))
.ToListAsync();Consultas con Condiciones en el Lado Relacionado
// Encontrar todas las publicaciones que tienen la etiqueta "dotnet"
var dotnetPosts = await _db.Posts
.Where(p => p.Tags.Any(t => t.Slug == "dotnet"))
.Include(p => p.Tags)
.ToListAsync();
// Encontrar todas las etiquetas usadas en publicaciones del año actual
var recentTags = await _db.Tags
.Where(t => t.Posts.Any(p => p.PublishedAt.Year == 2025))
.OrderBy(t => t.Name)
.ToListAsync();Consultas a Través de la Entidad de Unión Explícita
// Encontrar todos los cursos en los que un estudiante está inscrito junto con su calificación
var studentCourses = await _db.StudentCourses // Requiere DbSet<StudentCourse>
.Where(sc => sc.StudentId == studentId)
.Include(sc => sc.Course)
.OrderByDescending(sc => sc.EnrolledAt)
.ToListAsync();
// Estudiantes con calificación A en el curso 5
var topStudents = await _db.StudentCourses
.Where(sc => sc.CourseId == 5 && sc.FinalGrade == Grade.A)
.Include(sc => sc.Student)
.Select(sc => sc.Student)
.ToListAsync();
// O mediante navegación de salto — mismo resultado, sintaxis más limpia
var topStudents = await _db.Courses
.Where(c => c.Id == 5)
.SelectMany(c => c.StudentCourses
.Where(sc => sc.FinalGrade == Grade.A)
.Select(sc => sc.Student))
.ToListAsync();Agregar Elementos a la Colección
Muchos-a-Muchos Implícito
// Agregar una etiqueta a una publicación — EF Core gestiona la entrada en la tabla de unión
public async Task AddTagToPostAsync(int postId, int tagId)
{
var post = await _db.Posts
.Include(p => p.Tags)
.FirstOrDefaultAsync(p => p.Id == postId)
?? throw new KeyNotFoundException($"Post {postId} not found");
var tag = await _db.Tags.FindAsync(tagId)
?? throw new KeyNotFoundException($"Tag {tagId} not found");
if (!post.Tags.Contains(tag))
{
post.Tags.Add(tag);
await _db.SaveChangesAsync();
}
}Sin Cargar la Colección Completa
Evita cargar todas las etiquetas cuando solo quieres agregar una:
// Eficiente — no es necesario cargar la colección Tags
public async Task AddTagToPostAsync(int postId, int tagId)
{
// Adjuntar entidades stub (no se necesita consulta a la BD). Los miembros required
// siguen exigiendo un valor al construir; null! es la forma honesta de decir
// "nunca se lee — aquí solo importa la clave".
var post = new Post { Id = postId, Title = null!, Content = null! };
var tag = new Tag { Id = tagId, Name = null!, Slug = null! };
_db.Attach(post);
_db.Attach(tag);
post.Tags.Add(tag);
await _db.SaveChangesAsync(); // exactamente un INSERT en la tabla de unión
}Usa Attach() + agregar a la colección cuando solo quieras insertar una fila en la tabla de unión sin consultar las entidades completas. Esto evita lecturas innecesarias a la base de datos.
Entidad de Unión Explícita — Inscribir un Estudiante
public async Task EnrollStudentAsync(int studentId, int courseId)
{
// Verificar si ya está inscrito
var existing = await _db.StudentCourses
.AnyAsync(sc => sc.StudentId == studentId && sc.CourseId == courseId);
if (existing)
throw new InvalidOperationException("Student already enrolled in this course");
var enrollment = new StudentCourse
{
StudentId = studentId,
CourseId = courseId,
EnrolledAt = DateTime.UtcNow
};
_db.StudentCourses.Add(enrollment);
await _db.SaveChangesAsync();
}Eliminar Elementos de la Colección
Muchos-a-Muchos Implícito
// Eliminar una etiqueta de una publicación
public async Task RemoveTagFromPostAsync(int postId, int tagId)
{
var post = await _db.Posts
.Include(p => p.Tags)
.FirstOrDefaultAsync(p => p.Id == postId)
?? throw new KeyNotFoundException();
var tag = post.Tags.FirstOrDefault(t => t.Id == tagId);
if (tag is not null)
{
post.Tags.Remove(tag);
await _db.SaveChangesAsync();
}
}Entidad de Unión Explícita — Dar de Baja a un Estudiante
public async Task UnenrollStudentAsync(int studentId, int courseId)
{
var enrollment = await _db.StudentCourses
.FirstOrDefaultAsync(sc => sc.StudentId == studentId && sc.CourseId == courseId)
?? throw new KeyNotFoundException("Enrollment not found");
_db.StudentCourses.Remove(enrollment);
await _db.SaveChangesAsync();
}Operaciones en Lote
// Eliminar todas las etiquetas de una publicación (EF Core 7+ ExecuteDeleteAsync)
await _db.Set<Dictionary<string, object>>("PostTag")
.Where(pt => (int)pt["PostsId"] == postId)
.ExecuteDeleteAsync();
// Enfoque más directo — limpiar y volver a agregar
var post = await _db.Posts
.Include(p => p.Tags)
.FirstAsync(p => p.Id == postId);
post.Tags.Clear();
post.Tags.AddRange(newTags);
await _db.SaveChangesAsync();Conteo y Agregaciones
// Contar etiquetas por publicación
var postTagCounts = await _db.Posts
.Select(p => new { p.Title, TagCount = p.Tags.Count })
.OrderByDescending(x => x.TagCount)
.ToListAsync();
// Encontrar las etiquetas más populares (más publicaciones)
var popularTags = await _db.Tags
.Select(t => new { t.Name, PostCount = t.Posts.Count })
.OrderByDescending(t => t.PostCount)
.Take(10)
.ToListAsync();
// Calificación promedio por curso (usando entidad de unión explícita)
var courseAverages = await _db.Courses
.Select(c => new
{
c.Title,
AverageGrade = c.StudentCourses
.Where(sc => sc.FinalGrade.HasValue)
.Average(sc => (double?)sc.FinalGrade)
})
.ToListAsync();Errores Comunes
Reemplazar la instancia de la colección — post.Tags = newTagList — sí se rastrea, al contrario de una afirmación muy repetida (y de lo que decía una versión anterior de este artículo): DetectChanges compara el contenido de la colección contra su snapshot, así que en un post cargado con Include(p => p.Tags) la asignación produce exactamente los DELETE e INSERT esperados en la tabla de unión. El sample de abajo lo demuestra. La trampa real es reemplazar una colección que nunca cargaste: EF Core no tiene snapshot, ve cada elemento de la lista nueva como una adición, deja las filas de unión existentes en su sitio — y volver a agregar una de ellas falla con error de clave duplicada. Lo que lleva a la regla de verdad:
Incluye siempre la propiedad de navegación antes de modificarla: Include(p => p.Tags). Modificar una colección no cargada sin contexto de seguimiento genera fallos silenciosos o errores de clave duplicada. La única excepción deliberada es el patrón de stubs con Attach() de arriba, donde nada se carga por diseño y la colección empieza vacía — las adiciones son exactamente lo que quieres decir.
Comprueba los costes tú mismo
Todo lo anterior, medido como conteo de sentencias SQL contra una base de datos con 5
posts, 4 etiquetas, 3 estudiantes y 3 cursos (EF Core 10, SQL Server LocalDB). La
ejecución empieza leyendo la tabla de unión convencional desde INFORMATION_SCHEMA —
PostTag (PostsId, TagsId), creada por EF Core sin clase de entidad y sin
configuración:
| Operación | Sentencias SQL |
|---|---|
Include de etiquetas (unión implícita) | 1 |
Include filtrado (solo etiquetas activas) | 1 |
Where(p => p.Tags.Any(...)) + Include | 1 |
Entidad de unión con payload (notas + Include) | 1 |
| Agregación: nota media por curso | 1 |
Agregar etiqueta: Include + Find + guardar | 3 |
Agregar etiqueta: stubs con Attach(), sin cargar nada | 1 |
Quitar etiqueta (Include + guardar) | 2 |
En lote: ExecuteDelete sobre las filas de unión | 1 |
| Inscribir: comprobación de existencia + insertar | 2 |
El patrón que conviene interiorizar: las lecturas cuestan una sentencia en ambas
formas — la tabla de unión desaparece dentro del JOIN tenga o no clase de entidad —
mientras que el coste de las escrituras lo dominan las lecturas previas. El insert con
stubs de Attach() es una sentencia; la versión con colección cargada del mismo insert
son tres, dos de las cuales traen datos que la escritura nunca necesitó.
La ejecución termina con el experimento de reemplazo de colección del aviso de arriba: el
post 2 empieza con tres etiquetas, se guarda post.Tags = [efTag], y la tabla de unión
queda después con exactamente una fila — tres DELETE y un INSERT, todo rastreado.

El programa que produjo estos números es
samples/ef-core-many-to-many.
Ejecuta primero su seed.sql
y luego dotnet run. Los conteos de sentencias no dependen del proveedor ni del hardware,
así que tus números deberían coincidir exactamente.
Patrones de Relación Relacionados
Muchos-a-muchos es la más intrincada de las dos formas de relación habituales; si aún estás diseñando el esquema, las relaciones uno-a-muchos cubren el caso más simple y las convenciones que EF Core aplica por defecto.
Ten presente que las tablas de unión facilitan disparar
el problema de consultas N+1: cargar una lista de posts y
luego tocar post.Tags en un bucle emite una consulta por post, e Include con una
segunda colección es justamente el caso de producto cartesiano para el que existe
AsSplitQuery().