Os relacionamentos muitos-para-muitos modelam cenários em que registros de ambos os lados podem se relacionar com múltiplos registros do outro lado: publicações têm muitas tags, tags aparecem em muitas publicações. O EF Core 5 introduziu o muitos-para-muitos implícito, eliminando a necessidade de uma classe de entidade de junção. O EF Core 8 aprimora isso ainda mais.
Cada consulta e escrita deste artigo tem uma contagem medida de instruções SQL, produzida por
samples/ef-core-many-to-many
— um projeto de console executável que usa exatamente estas entidades e cobre as duas formas, a implícita e a explícita. A tabela completa está em Verifique os custos você mesmo.
Muitos-para-Muitos Implícito (EF Core 5+)
A abordagem mais simples — basta adicionar propriedades de navegação de coleção em ambos os 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; }
// Muitos-para-muitos: uma publicação tem muitas tags
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;
// Muitos-para-muitos: uma tag aparece em muitas publicações
public List<Post> Posts { get; set; } = [];
}O EF Core automaticamente:
- Cria uma tabela de junção
PostTagcom colunasPostsIdeTagsId - Gerencia inserções/exclusões na tabela de junção ao modificar as coleções
- Nenhuma classe de entidade extra ou
DbSeté necessário para a tabela de junção
Opcional: Personalizar o Nome da Tabela de Junção
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<Post>()
.HasMany(p => p.Tags)
.WithMany(t => t.Posts)
.UsingEntity(j => j.ToTable("PostTags")); // Nome de tabela personalizado
}Entidade de Junção Explícita
Quando você precisa de colunas adicionais na tabela de junção (por exemplo, timestamps, número ordinal), use uma entidade de junção explícita:
// A entidade de junção — tem suas próprias propriedades além das FKs
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!;
// Dados adicionais
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; }
// Pode navegar diretamente para Courses (ignorando a entidade de junção)
public List<Course> Courses { get; set; } = [];
// Ou navegar pela entidade de junção (quando você precisa dos dados adicionais)
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 entidade de junção explícita
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<StudentCourse>(entity =>
{
entity.HasKey(sc => new { sc.StudentId, sc.CourseId }); // PK composta
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 navegações de salto (acesso direto a Student.Courses)
modelBuilder.Entity<Student>()
.HasMany(s => s.Courses)
.WithMany(c => c.Students)
.UsingEntity<StudentCourse>();
}Consultando Através do Relacionamento
Include Básico
// Carregar publicações com suas tags
var posts = await _db.Posts
.Include(p => p.Tags)
.OrderBy(p => p.PublishedAt)
.ToListAsync();
// Carregar tags com suas publicações
var tag = await _db.Tags
.Include(t => t.Posts)
.FirstOrDefaultAsync(t => t.Slug == "entity-framework");Include Filtrado (EF Core 5+)
// Carregar publicações apenas com tags publicadas e não em rascunho
var posts = await _db.Posts
.Include(p => p.Tags.Where(t => t.IsActive))
.ToListAsync();Consultando com Condições no Lado Relacionado
// Encontrar todas as publicações que têm a tag "dotnet"
var dotnetPosts = await _db.Posts
.Where(p => p.Tags.Any(t => t.Slug == "dotnet"))
.Include(p => p.Tags)
.ToListAsync();
// Encontrar todas as tags usadas em publicações deste ano
var recentTags = await _db.Tags
.Where(t => t.Posts.Any(p => p.PublishedAt.Year == 2025))
.OrderBy(t => t.Name)
.ToListAsync();Consultando Pela Entidade de Junção Explícita
// Encontrar todos os cursos em que um estudante está matriculado com sua nota
var studentCourses = await _db.StudentCourses // Requer DbSet<StudentCourse>
.Where(sc => sc.StudentId == studentId)
.Include(sc => sc.Course)
.OrderByDescending(sc => sc.EnrolledAt)
.ToListAsync();
// Estudantes com nota A no 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();
// Ou via navegação de salto — mesmo resultado, sintaxe mais limpa
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();Adicionando Itens à Coleção
Muitos-para-Muitos Implícito
// Adicionar uma tag a uma publicação — EF Core gerencia a entrada na tabela de junção
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();
}
}Sem Carregar a Coleção Completa
Evite carregar todas as tags quando quiser apenas adicionar uma:
// Eficiente — não é necessário carregar a coleção Tags
public async Task AddTagToPostAsync(int postId, int tagId)
{
// Anexar entidades stub (sem consulta ao BD necessária). Os membros required
// ainda exigem um valor na construção; null! é a forma honesta de dizer
// "nunca é lido — aqui só a chave importa".
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(); // exatamente um INSERT na tabela de junção
}Use Attach() + adicionar à coleção quando quiser apenas inserir uma linha na tabela de junção sem consultar as entidades completas. Isso evita leituras desnecessárias no banco de dados.
Entidade de Junção Explícita — Matricular um Estudante
public async Task EnrollStudentAsync(int studentId, int courseId)
{
// Verificar se já está matriculado
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();
}Removendo Itens da Coleção
Muitos-para-Muitos Implícito
// Remover uma tag de uma publicação
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();
}
}Entidade de Junção Explícita — Desmatricular um Estudante
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();
}Operações em Lote
// Remover todas as tags de uma publicação (EF Core 7+ ExecuteDeleteAsync)
await _db.Set<Dictionary<string, object>>("PostTag")
.Where(pt => (int)pt["PostsId"] == postId)
.ExecuteDeleteAsync();
// Abordagem mais direta — limpar e adicionar novamente
var post = await _db.Posts
.Include(p => p.Tags)
.FirstAsync(p => p.Id == postId);
post.Tags.Clear();
post.Tags.AddRange(newTags);
await _db.SaveChangesAsync();Contagem e Agregações
// Contar tags por publicação
var postTagCounts = await _db.Posts
.Select(p => new { p.Title, TagCount = p.Tags.Count })
.OrderByDescending(x => x.TagCount)
.ToListAsync();
// Encontrar as tags mais populares (mais publicações)
var popularTags = await _db.Tags
.Select(t => new { t.Name, PostCount = t.Posts.Count })
.OrderByDescending(t => t.PostCount)
.Take(10)
.ToListAsync();
// Nota média por curso (usando entidade de junção 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();Erros Comuns
Substituir a instância da coleção — post.Tags = newTagList — é rastreado, ao contrário de uma afirmação muito repetida (e do que dizia uma versão anterior deste artigo): o DetectChanges compara o conteúdo da coleção com seu snapshot, então em um post carregado com Include(p => p.Tags) a atribuição produz exatamente os DELETE e INSERT esperados na tabela de junção. O sample abaixo prova isso. A armadilha real é substituir uma coleção que você nunca carregou: o EF Core não tem snapshot, vê cada item da lista nova como uma adição, deixa as linhas de junção existentes no lugar — e readicionar uma delas falha com erro de chave duplicada. O que leva à regra de verdade:
Sempre inclua a propriedade de navegação antes de modificá-la: Include(p => p.Tags). Modificar uma coleção não carregada sem contexto de rastreamento resulta em falhas silenciosas ou erros de chave duplicada. A única exceção deliberada é o padrão de stubs com Attach() acima, em que nada é carregado por design e a coleção começa vazia — adições são exatamente o que você quer dizer.
Verifique os custos você mesmo
Tudo o que está acima, medido como contagem de instruções SQL contra um banco de dados
com 5 posts, 4 tags, 3 estudantes e 3 cursos (EF Core 10, SQL Server LocalDB). A execução
começa lendo a tabela de junção convencional do INFORMATION_SCHEMA — PostTag
(PostsId, TagsId), criada pelo EF Core sem classe de entidade e sem configuração:
| Operação | Instruções SQL |
|---|---|
Include de tags (junção implícita) | 1 |
Include filtrado (só tags ativas) | 1 |
Where(p => p.Tags.Any(...)) + Include | 1 |
Entidade de junção com payload (notas + Include) | 1 |
| Agregação: nota média por curso | 1 |
Adicionar tag: Include + Find + salvar | 3 |
Adicionar tag: stubs com Attach(), sem carregar nada | 1 |
Remover tag (Include + salvar) | 2 |
Em lote: ExecuteDelete nas linhas de junção | 1 |
| Matricular: verificação de existência + inserir | 2 |
O padrão que vale internalizar: leituras custam uma instrução nas duas formas — a
tabela de junção desaparece dentro do JOIN tendo ou não classe de entidade — enquanto o
custo das escritas é dominado pelas leituras feitas antes. O insert com stubs de
Attach() é uma instrução; a versão com coleção carregada do mesmo insert são três,
duas das quais buscam dados que a escrita nunca precisou.
A execução termina com o experimento de substituição de coleção do aviso acima: o post 2
começa com três tags, post.Tags = [efTag] é salvo, e a tabela de junção fica depois com
exatamente uma linha — três DELETE e um INSERT, tudo rastreado.

O programa que produziu esses números é
samples/ef-core-many-to-many.
Execute primeiro o seed.sql
e depois dotnet run. Contagens de instruções não dependem de provedor nem de hardware,
então seus números devem bater exatamente.
Padrões de Relacionamento Relacionados
Muitos-para-muitos é a mais intrincada das duas formas comuns de relacionamento; se você ainda está desenhando o esquema, os relacionamentos um-para-muitos cobrem o caso mais simples e as convenções que o EF Core aplica por padrão.
Vale lembrar que tabelas de junção facilitam disparar
o problema de consultas N+1: carregar uma lista de posts e
depois tocar post.Tags em um loop emite uma consulta por post, e Include com uma
segunda coleção é exatamente o caso de produto cartesiano para o qual AsSplitQuery()
existe.