Mastering EF Core Relationships: One-to-One, One-to-Many, and Many-to-Many in .NET

Introduction
EF Core will happily guess how your entities relate to each other based on navigation property names and conventions - and it guesses right often enough that people stop checking, until the day it guesses wrong and a "why does this foreign key have a weird name" ticket lands. This video walks all three relationship shapes explicitly configured with the Fluent API, so the mapping is documented in code instead of inferred by convention.
🎬 Watch the full video here:
One-to-one: who owns the foreign key
A User and a UserProfile - each user has exactly one profile. EF Core needs to be told explicitly which side holds the foreign key, because a one-to-one relationship is symmetric on its face and the convention can't always infer it correctly:
modelBuilder.Entity<User>()
.HasOne(u => u.Profile)
.WithOne(p => p.User)
.HasForeignKey<UserProfile>(p => p.UserId);
HasForeignKey<UserProfile> is the important part - it says the FK column lives on UserProfile, not User. Get this backwards (or omit it and let EF Core guess) and you can end up with a shadow FK property you never intended.
One-to-many: the most common shape
A Blog has many Posts. This is the relationship EF Core's conventions handle best on their own, but it's worth writing explicitly the moment you need control over delete behavior:
modelBuilder.Entity<Post>()
.HasOne(p => p.Blog)
.WithMany(b => b.Posts)
.HasForeignKey(p => p.BlogId)
.OnDelete(DeleteBehavior.Cascade);
OnDelete matters more than people give it credit for. Cascade (the EF Core default for required relationships) deletes every Post when its Blog is deleted. Restrict blocks the delete instead, forcing the caller to deal with the children first - the safer default in a lot of real schemas, especially anything an admin can accidentally click delete on.
Many-to-many: skip-navigations, or an explicit join entity
EF Core 5+ can map many-to-many without you writing the join table's class at all:
modelBuilder.Entity<Student>()
.HasMany(s => s.Courses)
.WithMany(c => c.Students);
This is convenient, but the moment the join table needs its own data - an EnrollmentDate on a student/course join, say - you need an explicit join entity instead:
public class Enrollment
{
public int StudentId { get; set; }
public int CourseId { get; set; }
public DateTime EnrollmentDate { get; set; }
}
modelBuilder.Entity<Enrollment>()
.HasKey(e => new { e.StudentId, e.CourseId });
Once the join entity carries its own properties, it stops being a "pure" many-to-many under the hood and becomes two one-to-many relationships pointing at Enrollment - which is exactly the shape you'd have hand-written before EF Core added skip-navigation support.
Required vs. optional changes the delete behavior
A nullable foreign key (int? BlogId) makes the relationship optional, and EF Core defaults optional relationships to DeleteBehavior.ClientSetNull rather than Cascade - deleting the Blog sets Post.BlogId to null instead of removing the post. This default frequently surprises people who expected cascade behavior everywhere; it's worth checking explicitly rather than assuming.
Common pitfalls
- Letting convention infer a one-to-one relationship instead of explicitly stating which side owns the FK.
- Not setting
OnDeletedeliberately and being surprised by cascade deletes wiping out more than expected. - Using skip-navigation many-to-many, then needing to add a column to the join table later - it forces a refactor to an explicit join entity.
- Confusing "optional" (nullable FK) with "required" (non-nullable FK) - they get different default delete behaviors.
- Not adding an index on foreign key columns the convention doesn't already cover for you.
Key Takeaways
- One-to-one relationships need an explicit
HasForeignKey<T>()call - EF Core can't reliably guess which side owns the key. - One-to-many is the well-supported default, but
OnDeleteshould still be a deliberate choice, not left to the default. - Many-to-many can skip the join entity entirely with EF Core 5+ skip-navigations - until the join needs its own data, at which point an explicit join entity is required.
- A nullable foreign key makes a relationship optional and changes the default delete behavior from cascade to set-null.
Get the Full Source Code
The complete runnable solution - all three relationship shapes with their Fluent API configuration and migrations - is available to Patreon supporters. If you want the mappings ready to adapt to your own schema instead of rebuilding them from the walkthrough above, you can find it on Patreon.