Relationships and efficient queries
Model one-to-many and many-to-many relationships, load related data correctly, and avoid N+1 queries.
13 min read
You will learn
- Model one-to-many and many-to-many relationships with navigation properties
- Load related data with Include or projections
- Recognize and fix the N+1 problem
- Use AsNoTracking for read-only queries
Relationships in the model
Relationships are expressed with navigation properties and (usually) a foreign key property:
public class Customer
{
public int Id { get; set; }
public required string Name { get; set; }
public List<Order> Orders { get; set; } = []; // one customer → many orders
}
public class Order
{
public int Id { get; set; }
public int CustomerId { get; set; } // foreign key
public Customer Customer { get; set; } = null!; // navigation to the "one" side
public List<OrderLine> Lines { get; set; } = [];
public DateTime PlacedAt { get; set; }
}
public class OrderLine
{
public int Id { get; set; }
public int OrderId { get; set; }
public int ProductId { get; set; }
public Product Product { get; set; } = null!;
public int Quantity { get; set; }
public decimal UnitPrice { get; set; }
}EF Core infers one-to-many relationships from these conventions. Many-to-many works with collections on both sides — EF Core creates the join table:
public class Post { public int Id { get; set; } public List<Tag> Tags { get; set; } = []; }
public class Tag { public int Id { get; set; } public List<Post> Posts { get; set; } = []; }Configure delete behavior, required relationships and keys explicitly with the fluent API when conventions aren't enough.
Loading related data
By default, a query loads only the entity you asked for — order.Customer is null unless you load it. Options:
// 1. Eager loading with Include — a JOIN in the same query
var orders = await db.Orders
.Include(o => o.Customer)
.Include(o => o.Lines).ThenInclude(l => l.Product)
.Where(o => o.PlacedAt >= since)
.ToListAsync();
// 2. Projection — fetch exactly what you need (often the best choice for APIs)
var summaries = await db.Orders
.Where(o => o.PlacedAt >= since)
.Select(o => new OrderSummary(
o.Id,
o.Customer.Name, // navigation used inside the query → JOIN
o.Lines.Sum(l => l.Quantity * l.UnitPrice)))
.ToListAsync();Compare the queries — and the N+1 problem — in the viewer (the Include (JOIN) and N+1 problem tabs):
LINQ → SQL
What Entity Framework Core actually sends to the database (SQL Server)
C# (LINQ)
var cheap = await db.Products
.Where(p => p.Price < 20)
.OrderBy(p => p.Name)
.ToListAsync();Generated SQL1 query sent
SELECT [p].[Id], [p].[Name], [p].[Price], [p].[Stock]FROM [Products] AS [p]WHERE [p].[Price] < 20.0ORDER BY [p].[Name]
Rows returned
| Id | Name | Price | Stock |
|---|---|---|---|
| 2 | Mouse | 19.99 | 10 |
| 3 | Notebook | 4.5 | 100 |
| 4 | Pen | 1.2 | 0 |
Where becomes WHERE and OrderBy becomes ORDER BY. The filtering happens in the database — only matching rows travel over the network.The N+1 problem
With lazy loading enabled (an opt-in feature), touching a navigation property triggers a query on the spot. Inside a loop, that's one query per row:
foreach (var order in await db.Orders.ToListAsync()) // 1 query
Console.WriteLine(order.Customer.Name); // + N queriesWith 10,000 orders, that's 10,001 round trips. Fix it with Include or a projection — and watch the SQL log during development.
Cartesian explosion
Including several collections at once (Include(o => o.Lines).Include(o => o.Payments)) can multiply rows in a single JOIN. For such queries, .AsSplitQuery() tells EF Core to load each collection with a separate query.
Tracking vs no-tracking
Tracking costs memory and CPU — EF Core keeps a snapshot of every entity to detect changes. For read-only queries (most GET endpoints), turn it off:
var products = await db.Products.AsNoTracking().Where(p => p.Stock > 0).ToListAsync();Projections into DTOs are never tracked.
Saving related data
var order = new Order
{
CustomerId = 7,
PlacedAt = DateTime.UtcNow,
Lines = [new() { ProductId = 1, Quantity = 2, UnitPrice = 19.99m }],
};
db.Orders.Add(order); // the lines are inserted too
await db.SaveChangesAsync(); // order.Id and the lines' OrderId are filled inConcurrency
Two users edit the same product at once — who wins? Add a concurrency token and EF Core checks it on update:
[Timestamp] public byte[] RowVersion { get; set; } = [];
// SaveChanges throws DbUpdateConcurrencyException if someone else changed the row firstKey takeaways
- Navigation properties + foreign keys define one-to-many and many-to-many relationships.
- Related data isn't loaded automatically: use
Include/ThenInclude, or better,Selectprojections. - Lazy loading inside loops causes N+1 queries — load what you need up front.
- Use
AsNoTracking()for read-only queries and concurrency tokens for conflicting edits.
Quiz
4 questions · 3 correct to pass1.Order has `public List<OrderLine> Lines { get; set; }`. What does EF Core create by convention?
2.You load 100 orders and then access order.Customer.Name in a loop with lazy loading enabled. How many queries run?
3.When should you use AsNoTracking()?
4.What's often better than Include for an API response that needs a few fields from several related entities?
To complete this lesson
- ○ Pass the quiz (3/4 correct)