BrandGhost
EF Core Relationships in C#: One-to-Many, Many-to-Many, and One-to-One

EF Core Relationships in C#: One-to-Many, Many-to-Many, and One-to-One

EF Core Relationships in C#: One-to-Many, Many-to-Many, and One-to-One

If you've spent any time building .NET applications backed by a relational database, you've had to deal with ef core relationships. They're one of the most fundamental -- and most misunderstood -- parts of working with Entity Framework Core. Get them right and your data model practically writes itself. Get them wrong and you'll spend hours debugging cryptic exceptions, unexpected cascade deletes, and queries that load far more data than you bargained for.

This guide covers the full picture: one-to-many, one-to-one, many-to-many (both with and without an explicit join entity), shadow properties, cascade delete configuration, Fluent API vs data annotations, and loading strategies. We'll use .NET 10 and EF Core 10 throughout, with runnable code examples for every major concept.

Let's get into it.

What Are EF Core Relationships?

EF Core relationships map the associations between your entity classes to foreign key constraints in your database. There are three fundamental types:

  • One-to-Many -- one entity has a collection of related entities (e.g., a Blog has many Posts)
  • One-to-One -- one entity has exactly one related entity (e.g., a User has one Profile)
  • Many-to-Many -- many entities relate to many others (e.g., Students enroll in Courses)

EF Core infers many of these relationships automatically through conventions. But for anything non-trivial, you'll want explicit Fluent API configuration to stay in control. Convention-based inference is convenient until it isn't -- and the failure modes are subtle.

One-to-Many Relationships

One-to-many is the bread and butter of relational data modeling. A blog has many posts. An order has many line items. A customer has many addresses. The pattern shows up everywhere.

Here's how to model a Blog/Post relationship with EF Core:

public sealed class Blog
{
    public int Id { get; init; }
    public string Title { get; init; } = string.Empty;
    public ICollection<Post> Posts { get; init; } = [];
}

public sealed class Post
{
    public int Id { get; init; }
    public string Title { get; init; } = string.Empty;
    public string Content { get; init; } = string.Empty;
    public int BlogId { get; init; }
    public Blog Blog { get; init; } = null!;
}

public sealed class BloggingDbContext : DbContext
{
    public DbSet<Blog> Blogs => Set<Blog>();
    public DbSet<Post> Posts => Set<Post>();

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        modelBuilder.Entity<Blog>()
            .HasMany(b => b.Posts)
            .WithOne(p => p.Blog)
            .HasForeignKey(p => p.BlogId)
            .IsRequired();
    }
}

A few things worth noting here. The BlogId property on Post is the foreign key. The Blog navigation property is the reference navigation -- the single entity pointing back to the principal. The Posts collection on Blog is the collection navigation.

EF Core would infer this relationship by convention, because BlogId matches the expected FK naming pattern. But explicit Fluent API configuration is almost always worth the extra lines. It documents intent clearly and prevents surprises when naming conventions don't line up the way EF Core expects.

One-to-One Relationships

One-to-one is trickier than it looks. Both entities can hold the foreign key, but EF Core needs you to be explicit about which side "owns" the relationship. The entity holding the FK is the dependent; the other is the principal.

A classic example: a User has one UserProfile.

public sealed class User
{
    public int Id { get; init; }
    public string Email { get; init; } = string.Empty;
    public UserProfile? Profile { get; init; }
}

public sealed class UserProfile
{
    public int Id { get; init; }
    public string DisplayName { get; init; } = string.Empty;
    public string Bio { get; init; } = string.Empty;
    public int UserId { get; init; }
    public User User { get; init; } = null!;
}

public sealed class UserDbContext : DbContext
{
    public DbSet<User> Users => Set<User>();
    public DbSet<UserProfile> UserProfiles => Set<UserProfile>();

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        modelBuilder.Entity<User>()
            .HasOne(u => u.Profile)
            .WithOne(p => p.User)
            .HasForeignKey<UserProfile>(p => p.UserId)
            .IsRequired();
    }
}

Notice HasForeignKey<UserProfile> -- you must specify the dependent type explicitly when configuring one-to-one. Without the generic type argument, EF Core doesn't know which side holds the FK and will throw at model validation.

When should you use one-to-one vs just embedding? If the related data is always needed together and the profile table won't grow independently, consider embedding the profile columns directly in the User table using owned entity types (OwnsOne). But if the profile has its own lifecycle, is optional, or you want to query it separately, a separate table with a one-to-one relationship is the right call.

EF Core 8 introduced complex types (ComplexProperty) for value objects that should never be null and have no identity of their own -- a step further than owned entities. If your embedded type is always present and represents a pure value object, explore ComplexProperty as an alternative to OwnsOne.

Many-to-Many with an Explicit Join Entity

Many-to-many relationships are where EF Core's evolution really shows. Before EF Core 5, you had to model the join table manually every time. Now EF Core can generate the join table for you -- but for most real-world scenarios, you'll want an explicit join entity so you can store payload data on the join table itself.

Take the classic example: Students enroll in Courses. The enrollment itself has data -- a grade, an enrollment date, maybe a status. You can't store that on either the Student or the Course.

public sealed class Student
{
    public int Id { get; init; }
    public string Name { get; init; } = string.Empty;
    public ICollection<Enrollment> Enrollments { get; init; } = [];
}

public sealed class Course
{
    public int Id { get; init; }
    public string Title { get; init; } = string.Empty;
    public ICollection<Enrollment> Enrollments { get; init; } = [];
}

public sealed class Enrollment
{
    public int StudentId { get; init; }
    public int CourseId { get; init; }
    public double? Grade { get; init; }
    public DateTimeOffset EnrolledAt { get; init; }
    public Student Student { get; init; } = null!;
    public Course Course { get; init; } = null!;
}

public sealed class AcademicDbContext : DbContext
{
    public DbSet<Student> Students => Set<Student>();
    public DbSet<Course> Courses => Set<Course>();
    public DbSet<Enrollment> Enrollments => Set<Enrollment>();

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        modelBuilder.Entity<Enrollment>()
            .HasKey(e => new { e.StudentId, e.CourseId });

        modelBuilder.Entity<Enrollment>()
            .HasOne(e => e.Student)
            .WithMany(s => s.Enrollments)
            .HasForeignKey(e => e.StudentId);

        modelBuilder.Entity<Enrollment>()
            .HasOne(e => e.Course)
            .WithMany(c => c.Enrollments)
            .HasForeignKey(e => e.CourseId);
    }
}

The composite primary key (StudentId, CourseId) enforces uniqueness directly in the schema. The Grade property is the payload -- extra data that lives on the relationship itself, which you simply cannot store without an explicit join entity.

If you're querying related data across these relationships, LINQ joins in C# become your best friend. Understanding how Join, GroupJoin, and SelectMany map to underlying SQL helps you write queries that are both correct and efficient.

Many-to-Many Without an Explicit Join Entity

For simpler cases where the relationship carries no payload, EF Core 5+ lets you skip the explicit join entity entirely. EF Core creates the join table automatically based on convention.

public sealed class Article
{
    public int Id { get; init; }
    public string Title { get; init; } = string.Empty;
    public ICollection<Tag> Tags { get; init; } = [];
}

public sealed class Tag
{
    public int Id { get; init; }
    public string Name { get; init; } = string.Empty;
    public ICollection<Article> Articles { get; init; } = [];
}

public sealed class ContentDbContext : DbContext
{
    public DbSet<Article> Articles => Set<Article>();
    public DbSet<Tag> Tags => Set<Tag>();

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        modelBuilder.Entity<Article>()
            .HasMany(a => a.Tags)
            .WithMany(t => t.Articles);
    }
}

EF Core generates an ArticleTag join table with ArticleId and TagId columns. Clean and simple.

When should you use each approach? Use the implicit join when the relationship truly has no data of its own and you don't need to query the join table directly. The moment you need payload data -- a timestamp, a status, an ordinal position -- switch to an explicit join entity. It's far easier to add one upfront than to migrate to it later after the implicit table already exists in production.

Shadow Properties

Shadow properties let you configure foreign keys or other columns that exist in the database but have no corresponding CLR property on your entity class. This is useful for keeping your domain models clean -- the FK lives in the database schema without polluting your entity.

public sealed class Comment
{
    public int Id { get; init; }
    public string Body { get; init; } = string.Empty;
    // No BlogId property here -- it lives as a shadow property
}

public sealed class ShadowPropertyDbContext : DbContext
{
    public DbSet<Blog> Blogs => Set<Blog>();
    public DbSet<Comment> Comments => Set<Comment>();

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        modelBuilder.Entity<Comment>()
            .Property<int>("BlogId"); // declare the shadow property

        modelBuilder.Entity<Blog>()
            .HasMany<Comment>()
            .WithOne()
            .HasForeignKey("BlogId"); // reference it as the FK
    }
}

To query using a shadow property at runtime, use EF.Property<T>:

var commentsForBlog = await context.Comments
    .Where(c => EF.Property<int>(c, "BlogId") == targetBlogId)
    .ToListAsync();

Shadow properties are powerful but carry a trade-off. You lose compile-time safety when referencing the property by string name. Use them when you genuinely want to keep infrastructure concerns off your domain entities, but document them carefully in your IEntityTypeConfiguration<T> class so the next developer knows the column exists.

Cascade Delete Options

Cascade delete defines what happens to dependent entities when the principal is deleted. EF Core gives you four options:

Behavior What happens
Cascade Dependents are automatically deleted from the database
Restrict Throws at EF Core's SaveChanges level if tracked dependents exist; the database FK is created without ON DELETE CASCADE, so direct SQL deletes will also fail with a constraint violation. Note: SQL Server does not have a RESTRICT action -- EF Core generates NO ACTION at the schema level.
SetNull FK on dependents is set to null in the database
ClientSetNull FK is set to null in memory only; not enforced in the DB schema

Here's how to configure cascade behavior explicitly with Fluent API:

protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    // Cascade: deleting a Blog automatically deletes all its Posts
    modelBuilder.Entity<Blog>()
        .HasMany(b => b.Posts)
        .WithOne(p => p.Blog)
        .HasForeignKey(p => p.BlogId)
        .OnDelete(DeleteBehavior.Cascade);

    // Restrict: cannot delete a User if they have Comments
    modelBuilder.Entity<User>()
        .HasMany<Comment>()
        .WithOne()
        .HasForeignKey("UserId")
        .OnDelete(DeleteBehavior.Restrict);
}

The default behavior depends on whether the relationship is required or optional. Required relationships default to Cascade. Optional relationships default to ClientSetNull. That's a landmine if you don't know about it.

My recommendation: be explicit. Never rely on the default cascade behavior -- always configure it intentionally for every relationship. Accidental cascades in production are painful and can cause irreversible data loss. And Restrict failures can surface as cryptic constraint violation exceptions if your application code isn't expecting them.

Fluent API vs Data Annotations

EF Core supports two approaches for configuring ef core relationships: Fluent API (in OnModelCreating) and data annotations (attributes on your entity classes).

Data annotations are quick to write and colocated with your entity:

public sealed class Post
{
    public int Id { get; init; }

    [Required]
    [ForeignKey(nameof(Blog))]
    public int BlogId { get; init; }
    public Blog Blog { get; init; } = null!;
}

Fluent API is more verbose but offers complete control. It supports configurations that have no annotation equivalent -- shadow properties, composite keys, OnDelete behavior, table splitting, and more. It also keeps your entity classes free of infrastructure concerns.

For most non-trivial applications, Fluent API provides more control and clarity. Use IEntityTypeConfiguration<T> to split your configuration into per-entity classes and keep OnModelCreating clean:

public sealed class BlogConfiguration : IEntityTypeConfiguration<Blog>
{
    public void Configure(EntityTypeBuilder<Blog> builder)
    {
        builder.HasKey(b => b.Id);

        builder.Property(b => b.Title)
            .IsRequired()
            .HasMaxLength(200);

        builder.HasMany(b => b.Posts)
            .WithOne(p => p.Blog)
            .HasForeignKey(p => p.BlogId)
            .OnDelete(DeleteBehavior.Cascade);
    }
}

// In DbContext:
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    modelBuilder.ApplyConfigurationsFromAssembly(typeof(BloggingDbContext).Assembly);
}

This pattern scales cleanly as your model grows. Adding a new entity means adding a new configuration class -- not expanding an already-large OnModelCreating method.

Loading Strategies

EF Core relationships are only as useful as the strategy you use to load them. There are three approaches, and choosing the right one matters.

Eager Loading (Include)

Load related data as part of the initial query. This is the most predictable and generally recommended approach for web applications.

var blogs = await context.Blogs
    .Include(b => b.Posts)
    .ToListAsync();

You can chain .ThenInclude() for multi-level navigation. You can also filter the included collection inline: .Include(b => b.Posts.Where(p => p.IsPublished)) (filtered includes, available since EF Core 5). Combining LINQ projection in C# with Select alongside Include is a common pattern for fetching only the columns your view actually needs -- rather than loading entire entity graphs.

Explicit Loading

Load related data on demand, after the principal entity has already been fetched:

var blog = await context.Blogs.FirstAsync(b => b.Id == blogId);

// Only load posts if we actually need them
await context.Entry(blog).Collection(b => b.Posts).LoadAsync();

Useful when you conditionally need related data and want to avoid loading it in cases where it's unnecessary.

Lazy Loading (usually avoid this in web apps)

Lazy loading triggers a separate database query the first time you access a navigation property. It requires either proxy classes or injecting ILazyLoader directly into your entities.

The problem? In a web application, lazy loading can trigger the N+1 query problem -- where iterating a list of 100 blogs each triggers a separate query to load their posts. That's 101 round-trips to the database instead of 1. Completely avoidable, and devastating at scale.

Avoid lazy loading in ASP.NET Core applications. Use eager or explicit loading and be intentional about what you fetch. If you're logging in .NET, enable EF Core query logging in development so you can spot unexpected extra queries the moment they appear. The complete LINQ guide covers the query patterns you'll use most often when loading related data.

Common Pitfalls

Relationship cycles and multiple cascade paths

SQL Server will reject migrations that create multiple cascade paths to the same table. If two foreign keys both cascade to the same dependent, you'll see a migration error. The fix is usually to set one of the conflicting relationships to DeleteBehavior.Restrict or DeleteBehavior.ClientSetNull. Review your cascade configuration carefully whenever your schema has complex hierarchies or self-referencing entities.

Missing navigation properties

EF Core can work without navigation properties, but you lose the ability to use Include and type-safe query composition. Always define both sides of a navigation for anything you'll query frequently. Patterns like LINQ filtering using Any() on navigation collections depend on those properties being properly configured and tracked by EF Core.

Incorrect FK naming conventions

EF Core's convention expects FKs to be named {NavigationPropertyName}Id or {TypeName}Id. If your FK doesn't match, EF Core won't detect it automatically and will silently create a shadow property instead -- which may or may not be what you want. Always inspect generated migrations carefully when adding new relationships.

Owned entities vs relationships

OwnsOne and OwnsMany are fundamentally different from regular ef core relationships. Owned entities are part of the aggregate owner -- they're deleted when the owner is deleted, don't have their own DbSet, and are always queried through the owner. Don't confuse them with regular one-to-one or one-to-many relationships when deciding how to model your domain.

Mutable navigation collections

If your collection navigation property has a public setter or is a mutable List<T>, EF Core may replace it during change tracking in unexpected ways. Use ICollection<T> with an empty array initializer (= []) and leave the property as init or private-setter. EF Core will populate it correctly during entity materialization without you needing to interfere.


Frequently Asked Questions

What is the difference between one-to-many and many-to-many in EF Core?

A one-to-many relationship means one entity has a collection of related entities, but each related entity points back to exactly one principal. A many-to-many means entities on both sides can each have collections pointing to the other. EF Core handles both, but many-to-many requires either a convention-based join table (when no payload data is needed) or an explicit join entity class when you need to store additional data on the relationship itself.

When should I use an explicit join entity for many-to-many?

Use an explicit join entity whenever the relationship carries its own data -- timestamps, statuses, grades, sort orders, or anything beyond the two foreign keys. If the join table truly has only FK columns and nothing else, the implicit convention-based many-to-many is simpler and perfectly correct. The key question is: does this relationship have a concept of its own, or is it just a connection?

What is a shadow property in EF Core and when should I use one?

A shadow property is a property that exists in the EF Core model and in the database schema but has no corresponding CLR property on your entity class. They're useful for keeping infrastructure concerns -- like foreign keys -- off your domain entities. Query them at runtime using EF.Property<T>(entity, "PropertyName"). Use them deliberately and document them well, because the string-based access gives up compile-time safety.

What cascade delete option should I use by default?

Be explicit and intentional with every relationship. Required relationships default to Cascade and optional ones default to ClientSetNull -- but relying on defaults is a recipe for surprises. For most production systems, starting with DeleteBehavior.Restrict is the safest choice unless you genuinely want automatic deletion of dependent records. Accidental cascades are extremely hard to undo in production databases.

Should I use Fluent API or data annotations for configuring ef core relationships?

For anything beyond a trivial project, Fluent API with IEntityTypeConfiguration<T> is the better choice. Data annotations work for simple cases, but they mix infrastructure concerns into your entity classes and don't cover the full range of EF Core configuration options. Shadow properties, composite keys, OnDelete behavior, and table splitting all require Fluent API. The annotation approach also doesn't scale well as your model grows.

Is lazy loading safe to use in ASP.NET Core?

Generally, no. Lazy loading is convenient but dangerous in web applications because it can silently trigger the N+1 query problem -- a separate database round-trip per entity in a collection. You won't notice it during development with small datasets, but it can tank performance in production. Use eager loading with Include as your default strategy, and reserve explicit loading for cases where you genuinely need to conditionally load related data after the initial query.

How do I avoid relationship cycles in EF Core migrations?

If you have multiple foreign keys that all cascade to the same table, EF Core migrations will fail with a "multiple cascade paths" error. Fix it by setting all but one of the conflicting relationships to DeleteBehavior.Restrict or DeleteBehavior.ClientSetNull. This is especially common when you have entities that reference User both directly and through other relationships. Review all cascade configurations whenever your schema has complex hierarchies.


Wrapping Up

EF Core relationships are one of those topics where a bit of upfront knowledge saves you enormous pain down the road. Understanding the difference between relationship types, knowing when to use explicit join entities, configuring cascade delete deliberately, and choosing the right loading strategy are all foundational skills for any .NET developer working with relational data.

Fluent API with IEntityTypeConfiguration<T> is your friend. Use it consistently, keep your configurations explicit, and you'll have a data model that's both correct and maintainable as your application evolves.

If you're building data access layers on top of your EF Core models, the repository pattern combined with chain of responsibility is a powerful combination worth exploring for complex querying scenarios. And when it comes to querying ef core relationships effectively, the complete LINQ guide will sharpen your skills on everything from filtering to projection to joining across related entities.

Master One Language or Diversify - A Guide For New Developers

Choosing a programming language from the many programming languages is daunting. Then beginners ask: How many languages should I learn at once? Let's find out!

One on One Evolution

Entity Framework Core in .NET: The Complete Guide

Learn Entity Framework Core in .NET 10 -- DbContext, migrations, LINQ queries, relationships, performance tips, and when to use EF Core vs Dapper in C#.

An error has occurred. This application may no longer respond until reloaded. Reload