Advanced Track
EF Core Relationships & Migrations
By the end of this lesson you'll be able to model the three core relationship types — one-to-many, many-to-many, and one-to-one — plus owned entities, choose the right loading strategy with Include, and evolve your schema safely with migrations. These are the skills that turn a toy app into a real database-backed system.
What You'll Learn
- Model one-to-many, many-to-many and one-to-one with navigation properties
- Configure relationships and owned entities with the Fluent API
- Load related data eagerly with Include and ThenInclude
- Compare eager, lazy and explicit loading — and their tradeoffs
- Evolve the schema safely with Add-Migration and Update-Database
- Spot and fix cartesian explosion, N+1 queries and migration drift
DbContext is, how the change tracker works, and how a basic LINQ query becomes SQL. This lesson builds on all three.Include queries and migration commands are shown as read-only worked examples with ✅ Expected comments describing what EF produces. The 🎯 Your Turn exercises and the Mini-Challenge are plain C# over in-memory List<T> collections, so they actually run and print right here. You'll model the same relationships and queries — just without the database round-trip.💡 Real-World Analogy
Think of your database as a library. An author has many books — that's one-to-many. A book wears many tags ("Fiction", "Classic") and a tag labels many books — that's many-to-many, and you need an index card (a join table) listing which book has which tag. An author has exactly one membership card — that's one-to-one. An author's address isn't a thing the library catalogues on its own; it's just extra lines printed on the author's record — that's an owned entity. And migrations are the library's renovation log: every time you add a shelf or move a room, you write down the change so you can rebuild — or undo — the layout later.
📊 The Three Relationship Types
| Relationship | Example | Navigation shape | Fluent API |
|---|---|---|---|
| One-to-Many | Author → Books | List<Book> + AuthorId | HasOne/WithMany |
| Many-to-Many | Book ↔ Tags | List on both sides | HasMany/WithMany |
| One-to-One | Author → Profile | single ref + unique FK | HasOne/WithOne |
| Owned entity | Author owns Address | no key of its own | OwnsOne |
A navigation property is just a property that points to the related entity (or list of them). A foreign key (FK) is the plain column — like AuthorId — that physically links the rows.
1. One-to-Many — the Workhorse
One-to-many is by far the most common relationship: one Author writes many Books. You model it with two navigation properties — a collection (List<Book> Books) on the "one" side, and a reference plus a foreign key (Author and int AuthorId) on the "many" side. EF can often infer the relationship from those names, but spelling it out with the Fluent API lets you set the delete behaviour. Read this worked example first.
Worked example: one-to-many with Fluent API
Read-only — this is the EF entity config and what it maps to (won't run in-browser).
using System.Collections.Generic;
using Microsoft.EntityFrameworkCore;
// === ONE-TO-MANY: one Author has many Books ===
// The "one" side. It carries a COLLECTION navigation: List<Book>.
public class Author
{
public int Id { get; set; } // primary key (by convention)
public string Name { get; set; } = "";
public List<Book> Books { get; set; } = new(); // navigation: the many side
}
// The "many" side. It carries the FOREIGN KEY + a reference navigation.
p
...Now you try — but in plain, runnable C#. The program below models the same one-to-many shape (an Author with a List<Book>) as in-memory objects, so it executes here. Fill in the three ___ blanks, then run it.
🎯 Your turn: model a one-to-many & count
Fill in the blanks so the author ends up with two books, then check the count.
using System;
using System.Collections.Generic;
// 🎯 YOUR TURN — model a one-to-many the same shape EF uses,
// but as plain in-memory objects so it RUNS here.
class Book
{
public string Title { get; set; } = "";
}
class Author
{
public string Name { get; set; } = "";
// 1) An Author has MANY Books. Give it a collection navigation.
public List<Book> Books { get; set; } = ___; // 👉 a new empty list: new()
}
class Program
{
static void Main()
{
...2. Many-to-Many — Skip Navigations
In a many-to-many, both sides carry a collection: a Book has many Tags and a Tag labels many Books. Since EF Core 5 you no longer write a join class by hand — you put a List on each side and EF generates the hidden join table for you (this is called a skip navigation because you "skip over" the join table when you query). Physically there are still three tables; you just never have to mention the middle one.
Worked example: many-to-many (auto join table)
Read-only — EF builds the join table from collections on both sides.
using System.Collections.Generic;
using Microsoft.EntityFrameworkCore;
// === MANY-TO-MANY: a Book has many Tags, a Tag labels many Books ===
// EF Core 5+ creates the join table for you (a "skip navigation").
public class Book
{
public int Id { get; set; }
public string Title { get; set; } = "";
public List<Tag> Tags { get; set; } = new(); // collection on BOTH sides
}
public class Tag
{
public int Id { get; set; }
public string Name { get; set; } = "";
public List<
...3. One-to-One & Owned Entities
A one-to-one ties two entities so each maps to at most one of the other — an Author and their Profile. You configure it with HasOne/WithOne, and the foreign key lives on the dependent side (Profile.AuthorId) and is made unique. An owned entity is different: it has no identity of its own and isn't a row in its own table — it's a value object (like an Address or a money amount) whose columns are folded into the owner's table with OwnsOne. Reach for owned types when a thing only makes sense as part of its parent.
Worked example: one-to-one + owned Address
Read-only — unique FK for one-to-one; OwnsOne folds Address into the parent table.
using Microsoft.EntityFrameworkCore;
// === ONE-TO-ONE: each Author has exactly one Profile ===
public class Author
{
public int Id { get; set; }
public string Name { get; set; } = "";
public Profile Profile { get; set; } = null!; // reference navigation, ONE
}
public class Profile
{
public int Id { get; set; }
public string Bio { get; set; } = "";
public int AuthorId { get; set; } // FK that is also UNIQUE
public Author Author { get; set; } = null!;
...4. Loading Related Data — Eager, Lazy & Explicit
By default EF loads an entity without its relationships — db.Authors.ToList() gives you authors with empty Books lists. You then choose how related data arrives. Eager loading with Include pulls it in the same query (one round-trip). Explicit loading fetches it later, by hand, only when you decide you need it. Lazy loading fetches it automatically the first time you touch a navigation — convenient, but the classic source of accidental extra queries. Use ThenInclude to reach a second level (an author's books' tags).
Worked example: Include, explicit & lazy loading
Read-only — three loading strategies and when each hits the database.
using System.Linq;
using Microsoft.EntityFrameworkCore;
// Three ways to pull related data. Pick deliberately — it decides
// how many round-trips hit the database.
// 1) EAGER loading with Include — fetch related data in the SAME query.
var authors = db.Authors
.Include(a => a.Books) // bring each author's Books along
.ThenInclude(b => b.Tags) // ...and each book's Tags too (multi-level)
.ToList();
// ✅ One query (a JOIN) returns authors + books + tags together.
...🔎 Deep Dive: which loading strategy, and when?
Eager (Include) is the default choice when you know you'll need the related data — it's one round-trip and predictable. The catch: chaining several Includes onto collection navigations multiplies rows (a "cartesian explosion"). If you load an author's 10 books and 5 tags, a single joined query returns 50 rows of duplicated author data. AsSplitQuery() fixes this by issuing one query per collection instead.
Explicit loading shines when related data is only sometimes needed — you load the parent cheaply, then call .Load() on the rare path that needs the children.
Lazy loading is the most convenient and the most dangerous: a navigation touched inside a loop fires one query per iteration (the N+1 problem). It's off by default for a reason — prefer Include unless you have a specific reason not to.
// Cartesian-explosion fix when you must Include several collections: db.Authors .Include(a => a.Books) .Include(a => a.Awards) .AsSplitQuery() // one query per collection, no row multiplication .ToList();
Time to query related data yourself. Once an Include has loaded everything, you work with it using ordinary LINQ. The exercise below uses in-memory lists in place of the loaded tables, so it runs — join books to authors and group to count. Fill in the two ___ blanks.
🎯 Your turn: query related data with LINQ
Group books by their author foreign key and count — then run it.
using System;
using System.Collections.Generic;
using System.Linq;
// 🎯 YOUR TURN — this is the LINQ you'd run AFTER an Include loads
// the data. Here the "related data" is plain in-memory
// lists, so it RUNS and prints.
class Book { public string Title = ""; public int AuthorId; }
class Author { public int Id; public string Name = ""; }
class Program
{
static void Main()
{
var authors = new List<Author>
{
new Author { Id
...5. Migrations — Versioning Your Schema
Your entity classes are the source of truth for your schema, but the database doesn't update itself. Migrations bridge that gap: each one is a generated class with an Up() (apply the change) and a Down() (reverse it), like a commit you can roll forward or back. You run Add-Migration (or dotnet ef migrations add) after changing your model, then Update-Database to apply it. EF tracks which migrations have run in a __EFMigrationsHistory table, so it only ever applies the new ones.
Worked example: the migrations workflow
Read-only — the CLI/PMC commands and the generated Up/Down methods.
// === EF Core MIGRATIONS: version control for your database schema ===
// Each migration is like a git commit: it can be applied (Up) or
// reverted (Down). You run these in the terminal or the Package
// Manager Console — NOT in C# code.
// ---- Package Manager Console (Visual Studio) ----
// Add-Migration InitialCreate // generate a migration from model changes
// Update-Database // apply pending migrations to the DB
// ---- dotnet CLI (cross-platform) ----
// dotnet ef
...Putting It Together: an Order Total
Here's a small but real relational graph — an Order with many OrderItems, each pointing at a Product by id. It's modelled exactly like EF entities (one-to-many + a foreign-key lookup) but as plain in-memory objects, so it runs. You join each item to its product and total the order — the same query you'd run after an Include.
Worked example: order items joined to products
Runs in-browser — join order items to products and total the order.
using System;
using System.Collections.Generic;
using System.Linq;
// === An order system modelled the way EF entities relate, but as plain
// objects so it runs. Order 1---* OrderItem, each item -> a Product. ===
class Product { public int Id; public string Name = ""; public decimal Price; }
class OrderItem { public int ProductId; public int Quantity; }
class Order { public string Customer = ""; public List<OrderItem> Items = new(); }
class Program
{
static void Main()
{
...With real EF you'd write db.Orders.Include(o => o.Items).ThenInclude(i => i.Product) and the join would happen in SQL — the LINQ over the loaded objects would be identical.
Pro Tips
- 💡 Prefer eager
Includeover lazy loading in web apps — it makes the database round-trips visible and predictable instead of hidden inside a loop. - 💡 Reach for
AsSplitQuery()the moment youIncludetwo or more collections, to avoid cartesian row multiplication. - 💡 Add
AsNoTracking()to read-only queries — skipping change tracking is a free speed and memory win for data you won't save. - 💡 Never run
EnsureCreated()alongside migrations — they're mutually exclusive. Usedb.Database.Migrate()to apply pending migrations at startup instead. - 💡 Always review the generated migration before applying it; for production, generate a SQL script with
dotnet ef migrations scriptand run it through CI/CD. - 💡 Set
OnDeletedeliberately —Cascade,Restrict, orSetNull— so deleting a parent does exactly what you intend.
Common Errors (and the fix)
- Cartesian explosion (slow query, duplicated rows): chaining several
Includes on collections makes EF join everything into one giant result. Add.AsSplitQuery()so each collection is fetched in its own query. - N+1 from lazy loading: reading a navigation inside a
foreachfires one query per item — 100 authors becomes 101 queries. Load the data up front with.Include(a => a.Books)instead of relying on lazy proxies. - "Unable to determine the relationship represented by navigation property": EF can't work out the foreign key. Add the FK property (
int AuthorId) or configure it explicitly withHasForeignKey(...)inOnModelCreating. - Migration drift ("model differs from the database"): the model changed but no migration was added, or someone hand-edited the DB. Run
Add-Migrationto capture the diff, thenUpdate-Database— never edit the schema by hand. - "The entity type requires a primary key to be defined": your class has no
Id(or<Class>Id) property and you didn't callHasKey(...). Add a key, or mark it owned withOwnsOneif it has no identity of its own.
📋 Quick Reference
| Task | Code | Notes |
|---|---|---|
| One-to-many | HasOne(...).WithMany(...) | FK on the "many" side |
| Many-to-many | HasMany(...).WithMany(...) | Auto join table |
| One-to-one | HasOne(...).WithOne(...) | Unique FK on dependent |
| Owned entity | OwnsOne(...) | Columns in parent table |
| Eager load | .Include(a => a.Books) | Same query (JOIN) |
| Multi-level load | .ThenInclude(b => b.Tags) | Reach the next level |
| Split query | .AsSplitQuery() | Avoid cartesian blow-up |
| Explicit load | .Entry(a).Collection(...).Load() | Load on demand |
| Add migration | Add-Migration Name | dotnet ef migrations add |
| Apply migration | Update-Database | dotnet ef database update |
Frequently Asked Questions
Q: Do I always need a foreign-key property like AuthorId?
No — EF can create a shadow FK behind the scenes from the navigation alone. But adding the explicit AuthorId property is usually better: you can set and query it directly without loading the whole related entity, and it makes the relationship obvious in your code.
Q: When should I use a many-to-many vs a join entity?
If the link itself carries no data, use the automatic many-to-many (a list on each side). If the link needs its own fields — say a BookTag with an AddedDate — model it as a real entity with two one-to-many relationships instead.
Q: Eager, lazy or explicit — which should I default to?
Default to eager (Include). It makes every database round-trip explicit in your code, which is exactly what you want in a web app. Reach for explicit loading when related data is only occasionally needed, and avoid lazy loading unless you fully understand the N+1 risk.
Q: What's the difference between EnsureCreated() and migrations?
EnsureCreated() builds the whole schema once from the current model and can't evolve it — fine for quick demos and tests. Migrations track each change over time and can upgrade an existing database without losing data. Use migrations for anything real, and never mix the two.
Q: My query got slow after adding an Include — why?
You probably hit a cartesian explosion: including multiple collections joins them all into one result, duplicating parent rows. Add .AsSplitQuery() so EF runs a separate query per collection, or only Include what you actually use.
Mini-Challenge: Total an Order
No blanks this time — just a brief and a starter with the data set up for you. Model and query a small relational graph in memory: a list of OrderItems (each with a ProductId and Quantity) joined to Products. Total the units ordered and find the most expensive line. Run it and check your output against the expected lines in the comments.
🎯 Mini-Challenge: sum units & find the top line
Use Sum and Max over the items, looking up each product's price by id.
using System;
using System.Collections.Generic;
using System.Linq;
// 🎯 MINI-CHALLENGE: model & query a small relational graph (in-memory)
//
// 1. You're given Products, and an Order with a list of OrderItems
// (each item has a ProductId + Quantity). This is a one-to-many
// (Order ---* OrderItem) joined to Products on ProductId.
// 2. Work out the TOTAL number of UNITS ordered (sum of all Quantity).
// 3. Work out the most EXPENSIVE single line (Price * Quantity).
// 4. Print: "Units
...🎉 Lesson Complete
- ✅ One-to-many: collection on the "one" side, reference + FK on the "many" side
- ✅ Many-to-many: a list on both sides; EF builds the join table for you
- ✅ One-to-one: unique FK on the dependent; owned entities fold into the parent table
- ✅ Loading: eager (
Include/ThenInclude), explicit (.Load()), lazy (auto, risky) - ✅ Tradeoffs: cartesian explosion →
AsSplitQuery(); N+1 → eager load up front - ✅ Migrations:
Add-MigrationthenUpdate-Database;Up()/Down()roll forward and back - ✅ Next lesson: Repository Pattern — wrapping the DbContext behind a clean, testable abstraction
Sign up for free to track which lessons you've completed and get learning reminders.