Articles › EF Core & SQL
EF Core Relationships: One-to-One, One-to-Many and Many-to-Many
Model relationships correctly in Entity Framework Core: navigation properties, foreign keys, Fluent API configuration, delete behaviors, join entities and loading related data.
Real data is connected: customers have orders, orders have items, posts have tags. Entity Framework Core models these connections as relationships and turns them into foreign keys, joins and constraints in the database. Getting them right matters — a wrong delete behavior or a missing index can corrupt data or slow down every query.
The vocabulary
- Principal entity — the "parent" (e.g.
Blog). - Dependent entity — the "child" that holds the foreign key (e.g.
Post). - Foreign key (FK) — the property on the dependent that stores the principal's key (
BlogId). - Navigation property — a property that references the related entity or collection (
post.Blog,blog.Posts).
One-to-many
The most common relationship. One blog has many posts; each post belongs to one blog.
public class Blog
{
public int Id { get; set; }
public string Title { get; set; } = "";
public List<Post> Posts { get; set; } = [];
}
public class Post
{
public int Id { get; set; }
public string Title { get; set; } = "";
public int BlogId { get; set; } // FK
public Blog Blog { get; set; } = null!; // reference navigation
}
EF Core discovers this by convention. Being explicit with the Fluent API makes intent obvious:
modelBuilder.Entity<Post>()
.HasOne(p => p.Blog)
.WithMany(b => b.Posts)
.HasForeignKey(p => p.BlogId)
.OnDelete(DeleteBehavior.Cascade);
Required vs optional
A non-nullable FK (int BlogId) makes the relationship required: a post can't exist without a blog. A nullable FK (int? CategoryId) makes it optional.
Tip: Always declare the FK property explicitly. Without it EF Core creates a hidden "shadow" FK, which works but makes queries and DTO mapping clumsier.
One-to-one
Each user has at most one profile:
public class User
{
public int Id { get; set; }
public string Email { get; set; } = "";
public UserProfile? Profile { get; set; }
}
public class UserProfile
{
public int Id { get; set; }
public string Bio { get; set; } = "";
public int UserId { get; set; }
public User User { get; set; } = null!;
}
modelBuilder.Entity<User>()
.HasOne(u => u.Profile)
.WithOne(p => p.User)
.HasForeignKey<UserProfile>(p => p.UserId);
In one-to-one relationships you must tell EF Core which side is the dependent with HasForeignKey<T>. EF Core adds a unique index on UserId automatically.
When the related data is always loaded together and small, consider an owned type instead (OwnsOne), which stores the columns in the same table:
modelBuilder.Entity<Order>().OwnsOne(o => o.ShippingAddress);
Many-to-many
A post has many tags and a tag belongs to many posts.
Skip navigations (simple case)
public class Post
{
public int Id { get; set; }
public List<Tag> Tags { get; set; } = [];
}
public class Tag
{
public int Id { get; set; }
public string Name { get; set; } = "";
public List<Post> Posts { get; set; } = [];
}
EF Core creates a PostTag join table automatically. Adding a tag is just:
post.Tags.Add(existingTag);
await db.SaveChangesAsync();
Explicit join entity (when the link has data)
If the relationship itself carries information — when a student enrolled, their grade — model the join entity explicitly:
public class Enrollment
{
public int StudentId { get; set; }
public Student Student { get; set; } = null!;
public int CourseId { get; set; }
public Course Course { get; set; } = null!;
public DateTime EnrolledAt { get; set; }
public decimal? Grade { get; set; }
}
modelBuilder.Entity<Enrollment>()
.HasKey(e => new { e.StudentId, e.CourseId }); // composite key
Many real projects start with skip navigations and later need extra columns — modeling the join entity from the beginning saves a migration headache.
Delete behaviors
What happens to dependents when the principal is deleted?
DeleteBehavior |
Effect | Typical use |
|---|---|---|
Cascade |
dependents are deleted too | post → comments, order → order items |
Restrict / NoAction |
deletion fails if dependents exist | customer → orders (keep history) |
SetNull |
FK set to null (optional relationships) | category deleted → products uncategorized |
ClientSetNull |
default for optional FKs; sets null only for tracked entities | — |
Warning: SQL Server rejects schemas with multiple cascade paths to the same table ("may cause cycles or multiple cascade paths"). Configure one of the paths as
RestrictorNoAction.
For business data, consider soft delete instead of physical deletes:
modelBuilder.Entity<Customer>().HasQueryFilter(c => !c.IsDeleted);
Self-referencing relationships
Categories with sub-categories, or employees with managers:
public class Category
{
public int Id { get; set; }
public string Name { get; set; } = "";
public int? ParentId { get; set; }
public Category? Parent { get; set; }
public List<Category> Children { get; set; } = [];
}
modelBuilder.Entity<Category>()
.HasOne(c => c.Parent)
.WithMany(c => c.Children)
.HasForeignKey(c => c.ParentId)
.OnDelete(DeleteBehavior.Restrict);
Loading related data
// Eager loading
var blog = await db.Blogs
.Include(b => b.Posts.Where(p => p.IsPublished)) // filtered include
.ThenInclude(p => p.Tags)
.FirstAsync(b => b.Id == id);
// Projection — usually best for APIs
var dto = await db.Blogs
.Where(b => b.Id == id)
.Select(b => new BlogDto(
b.Title,
b.Posts.Count,
b.Posts.OrderByDescending(p => p.Id).Select(p => p.Title).Take(5).ToList()))
.FirstAsync();
// Explicit loading
await db.Entry(blog).Collection(b => b.Posts).LoadAsync();
When you Include several collections, the joined result can explode in size ("cartesian explosion"). Use .AsSplitQuery() to load each collection with a separate query.
Avoiding cycles in JSON
Entities with navigations in both directions (blog.Posts[0].Blog...) produce reference loops when serialized. The real fix is not to return entities from APIs — return DTOs shaped for the client. If you must, configure ReferenceHandler.IgnoreCycles.
Checklist
- Declare FK properties explicitly and index them (EF Core does this by convention).
- Choose delete behavior consciously for every relationship.
- Use explicit join entities when the relationship has data.
- Prefer projections over deep
Includechains in APIs. - Use
AsSplitQuery()for multiple collection includes.
Summary
EF Core supports one-to-one, one-to-many, many-to-many and self-referencing relationships through navigation properties and foreign keys. Conventions handle simple cases, while the Fluent API lets you define required/optional relationships, delete behaviors and join entities precisely. Model relationships deliberately and load them efficiently, and your data layer will stay both correct and fast.
FAQ
Do I need navigation properties on both sides?
No. A navigation on one side is enough. Add the inverse only if you actually use it.
Why does EF Core create a column named BlogId1?
Usually because EF Core found two relationships it couldn't pair automatically. Configure the relationship explicitly with HasOne/WithMany to fix it.
Should I use lazy loading?
In web APIs, avoid it: it hides queries and causes N+1 problems. Use projections or explicit Include instead.