Many-to-many relationships model scenarios where records on both sides can relate to multiple records on the other side: posts have many tags, tags appear on many posts. EF Core 5 introduced implicit many-to-many that eliminates the need for a junction entity class. EF Core 8 refines this further.
Implicit Many-to-Many (EF Core 5+)
The simplest approach — just add collection navigation properties on both sides:
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; }
// Many-to-many: a post has many 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; }
// Many-to-many: a tag appears on many posts
public List<Post> Posts { get; set; } = [];
}EF Core automatically:
- Creates a
PostTagjunction table withPostsIdandTagsIdcolumns - Manages inserts/deletes in the junction table when you modify the collections
- No extra entity class or
DbSetneeded for the junction table
Optional: Customizing the Junction Table Name
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<Post>()
.HasMany(p => p.Tags)
.WithMany(t => t.Posts)
.UsingEntity(j => j.ToTable("PostTags")); // Custom table name
}Explicit Join Entity
When you need additional columns on the junction table (e.g., timestamps, ordinal), use an explicit join entity:
// The join entity — has its own properties beyond just the 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!;
// Additional payload
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; }
// Can navigate directly to Courses (skipping the join entity)
public List<Course> Courses { get; set; } = [];
// Or navigate through the join entity (when you need the payload)
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 for explicit join entity
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<StudentCourse>(entity =>
{
entity.HasKey(sc => new { sc.StudentId, sc.CourseId }); // Composite PK
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()");
});
// Configure skip navigations (direct Student.Courses access)
modelBuilder.Entity<Student>()
.HasMany(s => s.Courses)
.WithMany(c => c.Students)
.UsingEntity<StudentCourse>();
}Querying Through the Relationship
Basic Include
// Load posts with their tags
var posts = await _db.Posts
.Include(p => p.Tags)
.OrderBy(p => p.PublishedAt)
.ToListAsync();
// Load tags with their posts
var tag = await _db.Tags
.Include(t => t.Posts)
.FirstOrDefaultAsync(t => t.Slug == "entity-framework");Filtered Include (EF Core 5+)
// Load posts with only published, non-draft tags
var posts = await _db.Posts
.Include(p => p.Tags.Where(t => t.IsActive))
.ToListAsync();Querying with Conditions on the Related Side
// Find all posts that have the "dotnet" tag
var dotnetPosts = await _db.Posts
.Where(p => p.Tags.Any(t => t.Slug == "dotnet"))
.Include(p => p.Tags)
.ToListAsync();
// Find all tags used in posts published this year
var recentTags = await _db.Tags
.Where(t => t.Posts.Any(p => p.PublishedAt.Year == 2025))
.OrderBy(t => t.Name)
.ToListAsync();Querying Through the Explicit Join Entity
// Find all courses a student is enrolled in with their grade
var studentCourses = await _db.StudentCourses // Requires DbSet<StudentCourse>
.Where(sc => sc.StudentId == studentId)
.Include(sc => sc.Course)
.OrderByDescending(sc => sc.EnrolledAt)
.ToListAsync();
// Students with an A grade in course 5
var topStudents = await _db.StudentCourses
.Where(sc => sc.CourseId == 5 && sc.FinalGrade == Grade.A)
.Include(sc => sc.Student)
.Select(sc => sc.Student)
.ToListAsync();
// Or via skip navigation — same result, cleaner syntax
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();Adding Items to the Collection
Implicit Many-to-Many
// Add a tag to a post — EF Core handles the junction table entry
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();
}
}Without Loading the Full Collection
Avoid loading all tags when you only want to add one:
// Efficient — no need to load the Tags collection
public async Task AddTagToPostAsync(int postId, int tagId)
{
// Attach stub entities (no DB query needed)
var post = new Post { Id = postId };
var tag = new Tag { Id = tagId };
_db.Attach(post);
_db.Attach(tag);
post.Tags.Add(tag);
await _db.SaveChangesAsync();
}Use Attach() + add to collection when you only want to insert a junction table row without querying the full entities. This avoids unnecessary database reads.
Explicit Join Entity — Enroll a Student
public async Task EnrollStudentAsync(int studentId, int courseId)
{
// Check if already enrolled
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();
}Removing Items from the Collection
Implicit Many-to-Many
// Remove a tag from a post
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();
}
}Explicit Join Entity — Unenroll a Student
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();
}Bulk Operations
// Remove all tags from a post (EF Core 7+ ExecuteDeleteAsync)
await _db.Set<Dictionary<string, object>>("PostTag")
.Where(pt => (int)pt["PostsId"] == postId)
.ExecuteDeleteAsync();
// More straightforward approach — clear and re-add
var post = await _db.Posts
.Include(p => p.Tags)
.FirstAsync(p => p.Id == postId);
post.Tags.Clear();
post.Tags.AddRange(newTags);
await _db.SaveChangesAsync();Counting and Aggregating
// Count tags per post
var postTagCounts = await _db.Posts
.Select(p => new { p.Title, TagCount = p.Tags.Count })
.OrderByDescending(x => x.TagCount)
.ToListAsync();
// Find most popular tags (most posts)
var popularTags = await _db.Tags
.Select(t => new { t.Name, PostCount = t.Posts.Count })
.OrderByDescending(t => t.PostCount)
.Take(10)
.ToListAsync();
// Average grade per course (using explicit join entity)
var courseAverages = await _db.Courses
.Select(c => new
{
c.Title,
AverageGrade = c.StudentCourses
.Where(sc => sc.FinalGrade.HasValue)
.Average(sc => (double?)sc.FinalGrade)
})
.ToListAsync();Common Mistakes
Don't use List<Tag> and try to replace it entirely: post.Tags = newTagList. EF Core doesn't track this assignment. Instead, use post.Tags.Clear() followed by post.Tags.AddRange(newTags), or remove/add items individually.
Always include the navigation property before modifying it: Include(p => p.Tags). Modifying an unloaded collection without tracking context leads to silent failures or duplicate key errors.