Relationships
Here's the mental model to carry through this whole phase: a relationship is a foreign key plus navigation properties. That's it. The database side of a relationship is the same boring thing it's always been — a column in one table that points at the primary key of another. EF Core's contribution is the navigation property: a C# reference (or list) that lets you walk from one object to its related objects without writing the join yourself. EF reads the shapes of your classes — a List<Post> here, a Blog reference there, a BlogId int — and infers the foreign key and the relationship from them.
If the underlying concepts feel shaky — what a foreign key is, why a join table exists for many-to-many — pause and read Relationships & Keys first. This phase assumes you know the database side and focuses on how EF Core projects it onto C# classes.
📝 We've been building a blog schema:
Blog,Post, and now we'll addTag. The relationships are the natural ones — a blog has many posts, and posts and tags belong to each other in a many-to-many. By the end you'll be able to read a pair of entity classes and predict exactly what foreign key EF will create.
One-to-many: the FK convention
The bread-and-butter relationship. One blog, many posts. You express it with two navigation properties and one foreign key, and EF Core wires the rest by convention.
public class Blog
{
public int Id { get; set; }
public string Url { get; set; } = "";
public List<Post> Posts { get; set; } = new(); // one-to-many: a blog has many posts
}
public class Post
{
public int Id { get; set; }
public string Title { get; set; } = "";
public int BlogId { get; set; } // foreign key (convention: <Nav>Id)
public Blog Blog { get; set; } = null!; // inverse navigation
}
What just happened: EF Core saw a collection navigation (Blog.Posts) and a matching reference navigation on the other side (Post.Blog), and it concluded these two are the same relationship viewed from both ends. Then it spotted Post.BlogId — an int named <NavigationName>Id — and recognized it as the foreign key by convention. The = null! on Post.Blog is the same trick from earlier phases: it tells the C# compiler "trust me, this won't be null at runtime" so the nullable-reference warning goes away (EF populates it when you load the relationship).
When you run dotnet ef migrations add AddPostBlogRelationship, the generated migration creates the BlogId column and an index on it — relational databases index foreign keys because you almost always filter and join on them.
-- What the migration produces (SQLite dialect)
"Posts" ADD "BlogId" INTEGER NOT NULL DEFAULT 0;
ON "Posts" ("BlogId");
-- plus a FOREIGN KEY constraint linking Posts.BlogId -> Blogs.Id
What just happened: The migration added the FK column, created the index EF generates automatically for it, and declared the foreign-key constraint so the database itself enforces that every Post.BlogId points at a real Blog. You wrote two navigation properties and one int; EF turned that into a proper, indexed, constrained relationship.
💡 The convention
<NavigationName>Idis whyBlogIdworks without configuration. If you'd named itOwnerIdinstead, EF wouldn't recognize it as the FK for theBlognavigation — you'd have to point EF at it with the Fluent API (coming up). Match the convention and you write zero config.
One-to-one and many-to-many
One-to-one is the same idea with a single reference on each side instead of a collection. Think a Blog and its BlogHeader:
public class Blog
{
public int Id { get; set; }
public BlogHeader Header { get; set; } = null!; // reference, not a list
}
public class BlogHeader
{
public int Id { get; set; }
public int BlogId { get; set; } // FK lives on the dependent side
public Blog Blog { get; set; } = null!;
}
What just happened: Because both sides hold a single reference (no List<>), EF infers one-to-one. The foreign key goes on the dependent side — the entity that can't exist without the other (BlogHeader needs a Blog). EF often can't guess which side is dependent on its own here, so one-to-one is the relationship most likely to need a Fluent API hint; we'll see the shape of that next.
Many-to-many is where EF Core 5+ earns its keep. A post has many tags; a tag belongs to many posts. Put a collection on each side — these are called skip navigations — and EF creates the join table for you:
public class Post
{
public int Id { get; set; }
public string Title { get; set; } = "";
public List<Tag> Tags { get; set; } = new();
}
public class Tag
{
public int Id { get; set; }
public string Name { get; set; } = "";
public List<Post> Posts { get; set; } = new();
}
// EF creates a PostTag join table; add an explicit join entity only if it needs extra columns.
What just happened: EF saw a collection on both ends with no foreign key on either entity, and recognized a many-to-many. It silently created a hidden join table (PostTag) with two FK columns — PostsId and TagsId — to record which posts wear which tags. You never declared that table; it's invisible in your C# model, and you navigate straight from post.Tags to tag.Posts as if the join didn't exist. That's the "skip" in skip navigation: you skip over the join row.
⚠️ The auto join table works only when the join holds nothing but the two foreign keys. The moment you need an extra column on the relationship itself — say,
AddedDaterecording when a tag was applied — you must define an explicit join entity (aPostTagclass withPostId,TagId, andAddedDate) and map two one-to-many relationships through it. Reach for that only when the relationship genuinely carries data of its own.
The Fluent API: taking control
Conventions handle the common cases. When they can't guess — a non-conventional FK name, a one-to-one's dependent side, a specific delete behavior — you configure the relationship explicitly in OnModelCreating. The vocabulary reads like a sentence:
protected override void OnModelCreating(ModelBuilder b)
{
b.Entity<Post>()
.HasOne(p => p.Blog) // a Post has one Blog
.WithMany(bl => bl.Posts) // a Blog has many Posts
.HasForeignKey(p => p.BlogId) // the FK is Post.BlogId
.OnDelete(DeleteBehavior.Cascade); // delete a Blog -> delete its Posts
}
What just happened: We spelled out the exact relationship EF had already inferred from the class shapes — HasOne/WithMany name both ends, HasForeignKey pins down which property is the FK, and OnDelete declares what happens to posts when their blog is deleted. With a conventional FK name like BlogId you don't need this; you write it when conventions fall short, or when you want delete behavior to be explicit and reviewed rather than defaulted.
Required vs optional is controlled by whether the FK can be null:
public int BlogId { get; set; } // non-nullable FK = REQUIRED: a Post must have a Blog
public int? BlogId { get; set; } // nullable FK = OPTIONAL: a Post may have no Blog
What just happened: A non-nullable int BlogId means the column is NOT NULL and every post is required to belong to a blog — and deleting a blog cascades to its posts by default. Making it int? flips the relationship to optional: a post can exist with BlogId = NULL, and the default delete behavior changes to setting that FK to null rather than deleting the post. The nullability of one property quietly decides both the constraint and the cascade rule, so it's worth being deliberate about it.
💡 You don't have to choose Fluent-or-nothing. The common pattern is: let conventions do the 90% they handle for free, and add a Fluent API line only for the specific thing a convention got wrong. Every line you add is a line a reviewer has to understand — so add them for a reason.
Creating with nested relations
Here's where navigation properties pay off. You don't insert a blog, read back its id, then insert posts with that id by hand. You build the object graph and save it once:
var blog = new Blog
{
Url = "https://example.com",
Posts =
{
new Post { Title = "Hello, world" },
new Post { Title = "Second post" }
}
};
ctx.Blogs.Add(blog);
ctx.SaveChanges();
What just happened: You added one Blog whose Posts collection already held two Post objects with no BlogId set. On SaveChanges, EF inserted the blog first, got its generated Id back, then inserted both posts with their BlogId filled in to match — all in one transaction. You never touched a foreign key value; EF read it off the navigation. Adding the root of a graph pulls every reachable, untracked entity in with it. (Many-to-many works the same way: assign post.Tags = new() { tag1, tag2 } and EF writes the join rows for you.)
⚠️ Defining a navigation property does not mean it gets loaded when you read. Query
ctx.Blogs.First()andblog.Postswill be empty — not because the blog has no posts, but because you didn't ask EF to fetch them. Loading related data on read (Include, lazy loading, and the N+1 query trap that catches everyone) is the entire subject of Phase 7: Loading Strategies & the N+1 Trap. For now: a navigation describes the relationship; it doesn't auto-populate.
Recap
- A relationship is a foreign key plus navigation properties. EF Core infers it from class shapes — a collection navigation, a reference navigation, and an FK property — not from configuration.
- One-to-many: a
List<Post>on the parent, aBlogreference and aBlogIdon the child. The FK follows the<NavigationName>Idconvention, and the migration creates the column and an index on it. - One-to-one uses a single reference on each side with the FK on the dependent side; many-to-many uses a collection on each side (skip navigations) and EF auto-creates a hidden join table — add an explicit join entity only when the relationship needs extra columns.
- The Fluent API (
HasOne/WithMany/HasForeignKey/OnDelete) takes control when conventions can't guess. A non-nullable FK is a required relationship; a nullable FK (int?) is optional, which also changes the default delete behavior. - Create graphs in one shot: build the object tree,
Addthe root,SaveChanges. EF inserts in dependency order and fills the foreign keys for you. - Defining a navigation does not load it on read — that's Phase 7.
Quick check
[
{
"q": "In the blog schema, Post has `public int BlogId { get; set; }` and `public Blog Blog { get; set; }`, while Blog has `public List<Post> Posts { get; set; }`. What does EF Core infer?",
"choices": ["Nothing until you add Fluent API config", "A one-to-many relationship with BlogId as the foreign key, by convention", "A many-to-many relationship needing a join table", "A one-to-one relationship between Blog and Post"],
"answer": 1,
"explain": "A collection navigation (Blog.Posts) plus a reference navigation (Post.Blog) plus an FK named <Nav>Id (BlogId) is the convention for one-to-many. No configuration needed."
},
{
"q": "You give Post a `List<Tag> Tags` and Tag a `List<Post> Posts`, with no FK property on either. What does EF Core do in EF Core 5+?",
"choices": ["Throws an error because there's no foreign key", "Creates a join table automatically and lets you navigate post.Tags directly", "Requires you to write an explicit PostTag join entity first", "Treats it as two unrelated one-to-many relationships"],
"answer": 1,
"explain": "A collection on both sides with no FK is a many-to-many. EF creates a hidden join table automatically; you only write an explicit join entity when it needs extra columns."
},
{
"q": "You change a Post's foreign key from `public int BlogId` to `public int? BlogId`. What does that change?",
"choices": ["Nothing — nullability of the FK is ignored by EF", "It makes the relationship optional: a Post can have no Blog, and the default delete behavior changes", "It deletes the relationship entirely", "It forces you to use the Fluent API to keep it working"],
"answer": 1,
"explain": "A non-nullable FK = required relationship; a nullable FK (int?) = optional. The nullability also changes the default on-delete behavior from cascade to setting the FK null."
}
]
← Phase 5: Change Tracking & SaveChanges · Guide overview · Phase 7: Loading Strategies & the N+1 Trap →
Check your understanding
1. In the blog schema, Post has `public int BlogId { get; set; }` and `public Blog Blog { get; set; }`, while Blog has `public List<Post> Posts { get; set; }`. What does EF Core infer?
2. You give Post a `List<Tag> Tags` and Tag a `List<Post> Posts`, with no FK property on either. What does EF Core do in EF Core 5+?
3. You change a Post's foreign key from `public int BlogId` to `public int? BlogId`. What does that change?