//JorgenHoc
← All articles
EF Core7 min read

EF Core Many-to-Many Relationships in .NET 8

Complete guide to EF Core many-to-many relationships: implicit junction tables, explicit join entities, querying through collections, and adding or removing items with EF Core 8.

#entity-framework#dotnet#database

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 PostTag junction table with PostsId and TagsId columns
  • Manages inserts/deletes in the junction table when you modify the collections
  • No extra entity class or DbSet needed 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();
// 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.