Lesson 33 • Advanced Track
Entity Framework Core Internals
By the end of this lesson you'll understand how EF Core turns your C# objects into SQL — how the change tracker watches your edits, how LINQ becomes a query, why execution is deferred, and how to dodge the performance traps (N+1, over-tracking) that bite real apps. You'll be able to reason about exactly what database work your code triggers.
What You'll Learn
- How DbContext and DbSet<T> map your classes to database tables
- How the change tracker assigns an EntityState to every entity
- How EF Core translates LINQ queries into SQL behind the scenes
- Why queries are deferred and exactly when they hit the database
- How SaveChanges batches every change into one efficient round-trip
- When to use AsNoTracking — and how to spot and fix the N+1 problem
Where, Select, OrderBy), and generics like List<T>, because EF Core is built entirely on those ideas.// ✅ Expected output comments show the SQL EF Core would generate or the row counts. The 🎯 Your Turn exercises and the Mini-Challenge are plain, runnable C# that model the same concepts (a List<T> standing in for a DbSet<T>), so you can press "Try it Yourself" and see them work.💡 Real-World Analogy
Picture the EF Core change tracker as a smart notepad sitting on your desk, watching what you edit. When you fetch a row from the database, the assistant jots it down and notes "unchanged". The moment you scribble a new price on a record, it quietly writes "Modified — Price: 999 → 1099" in the margin. Add a fresh record and it writes "Added"; cross one out and it writes "Deleted". You don't tell it any of this — it's watching. Then when you say "save", it doesn't run to the database for every note: it reads back through the whole notepad once and sends a single batch of instructions — one UPDATE, one INSERT, one DELETE. That notepad is the change tracker, and each margin note is an EntityState.
📊 EntityState Values
| EntityState | Meaning | SQL at SaveChanges |
|---|---|---|
| Added | New entity, not yet in the database | INSERT |
| Unchanged | Loaded and not modified since | (none) |
| Modified | One or more properties changed | UPDATE (changed columns only) |
| Deleted | Marked for removal | DELETE |
| Detached | Not tracked by the context at all | (none) |
Every tracked entity is in exactly one of these states at any moment. SaveChanges() walks the tracker, emits the SQL for each non-Unchanged entity, and then resets everything back to Unchanged.
1. DbContext & DbSet<T>
A DbContext is your session with the database — one short-lived object that opens a connection, holds your data, and tracks changes. Inside it you expose one DbSet<T> per table; a DbSet<Product> behaves like a queryable collection of Product objects backed by the Products table. Each entity is just a plain C# class whose properties map to columns. Read this worked example, then you'll model the same shape in runnable C#.
Worked example: DbContext, DbSet & a first INSERT
Read the comments — this is real EF Core. The ✅ comments show the SQL it generates.
using System;
using System.Collections.Generic;
using Microsoft.EntityFrameworkCore;
// ⚠️ This is REAL EF Core code. EF Core needs a database engine, so it does
// NOT run in this in-browser editor — read it and study the comments instead.
// Every "Expected output" below is the SQL EF Core would generate, or the rows.
// An ENTITY is a plain C# class that maps to one table row.
public class Product
{
public int Id { get; set; } // convention: 'Id' = primary key
public
...Your turn — and this one runs. A DbSet<T> is, at heart, just a collection you add to and read from, so here you'll model it with a List<Product>. Fill in the two ___ blanks, then run it.
🎯 Your turn: model a DbContext with a List<T>
Create the list, add a product, and check the saved count is 2.
using System;
using System.Collections.Generic;
// 🎯 YOUR TURN — this is PLAIN C# that MODELS how a DbContext works:
// a DbSet<T> is just a collection of entities, and SaveChanges writes them.
// Fill in the ___ blanks, then press "Try it Yourself".
// An entity = a plain class (like an EF Core entity).
class Product
{
public int Id { get; set; }
public string Name { get; set; } = "";
}
// A tiny "context": a DbSet is modelled here as a List<Product>.
class FakeContext
{
// 1) M
...2. Change Tracking & EntityState
When you load an entity, EF Core's change tracker takes a snapshot and labels it Unchanged. As you edit, add, or remove entities, it updates each one's EntityState — the margin note from our analogy. You never set these states by hand for normal edits; the tracker watches your property assignments and infers them. You can inspect any entity's state with db.Entry(entity).State. This is what lets SaveChanges() emit the minimal SQL — an UPDATE touches only the columns you actually changed.
Worked example: states change as you edit (real EF Core)
Watch an entity move Unchanged → Modified, plus Added and Deleted, then back to Unchanged.
using System;
using Microsoft.EntityFrameworkCore;
// Real EF Core — read only. The change TRACKER watches every entity you load
// and remembers its EntityState so SaveChanges knows what SQL to emit.
class Program
{
static void Main()
{
using var db = new ShopContext();
// 1) Load an entity. EF starts tracking it as Unchanged.
var product = db.Products.First();
Console.WriteLine(db.Entry(product).State); // Unchanged
// 2) Change a property.
...Now you model it. Real EF Core stores an EntityState per entity; here you'll define that enum and flip a state yourself. Fill in the two ___ blanks, then run it.
🎯 Your turn: simulate change tracking with an enum
Define EntityState, flip an entity to Modified, and check the three printed states.
using System;
// 🎯 YOUR TURN — model change tracking yourself in plain C#.
// EF Core stores an EntityState per entity; here YOU flip the state by hand.
// 1) Define an enum named EntityState with three states:
// Added, Modified, Unchanged.
enum EntityState { ___ } // 👉 list them comma-separated: Added, Modified, Unchanged
// A tracked entity = the entity plus its current state.
class TrackedProduct
{
public string Name { get; set; } = "";
public EntityState State { get; set;
...3. Query Translation & Deferred Execution
When you chain LINQ methods on a DbSet<T>, EF Core doesn't run anything yet — it builds an expression tree, a description of the query. This is deferred execution: the SQL is generated and sent only when you actually enumerate the result, with ToList(), a foreach, First(), Count(), and so on. EF then translates your Where into a SQL WHERE, your OrderBy into ORDER BY, and your Select into a column list — running the filtering on the database server, not in your app's memory.
Worked example: LINQ → SQL, deferral, projections & AsNoTracking
See where the SQL actually runs, and read the generated SQL in the ✅ comments.
using System;
using System.Linq;
using Microsoft.EntityFrameworkCore;
// Real EF Core — read only. EF TRANSLATES your LINQ into SQL, and the query
// is DEFERRED: nothing hits the database until you enumerate it.
class Program
{
static void Main()
{
using var db = new ShopContext();
// Building a query does NOT run it — no SQL yet. 'query' is a recipe.
var query = db.Products
.Where(p => p.Price > 100) // -> WHERE "Price" > 100
.Or
...🔎 Deep Dive: when to reach for AsNoTracking
By default, every entity a query returns is tracked — EF keeps a snapshot so it can detect later edits. That snapshot costs memory and CPU. If a query is purely read-only (you're rendering a list, returning JSON from an API, building a report), you never plan to edit those objects, so tracking is wasted work.
AsNoTracking() tells EF to skip the snapshot. The entities come back as plain detached objects — faster and lighter — but EF won't notice if you change them, so SaveChanges() will ignore those edits. Use it for reads; drop it the moment you intend to update.
// Read-only list for a web page — no edits coming, so don't track: var list = db.Products.AsNoTracking().ToList(); // Editing flow — DO track, so SaveChanges() sees the change: var p = db.Products.First(); // tracked p.Price = 12.50m; // tracker notes "Modified" db.SaveChanges(); // emits the UPDATE
Rule of thumb: track when you'll write, no-track when you'll only read.
4. SaveChanges Batching
You can add, edit, and delete dozens of entities, and nothing touches the database until you call SaveChanges(). At that point EF walks the change tracker, collects every non-Unchanged entity, and sends the work as one batched round-trip inside a transaction — so it's all-or-nothing. Batching matters: a single trip across the network is dramatically faster than one trip per row. The change-tracking worked example above shows three different operations (UPDATE, INSERT, DELETE) all flushed by a single SaveChanges() call.
🔎 Deep Dive: the N+1 Problem
The most common EF Core performance bug is the N+1 problem. You run one query to fetch a list (that's the "1"), then loop over it touching a related navigation property on each item — and EF quietly fires one more query per item to load that relation (that's the "N"). Fetch 100 products and read each one's reviews, and you've made 101 database round-trips instead of 1.
The fix is eager loading with Include(): ask for the related data up front so EF pulls it all in a single query (a JOIN). Study the difference below.
Worked example: N+1 vs Include (real EF Core)
Compare 1+N round-trips against a single Include query.
using System;
using System.Linq;
using Microsoft.EntityFrameworkCore;
// The N+1 PROBLEM — the single most common EF Core performance bug.
class Program
{
static void Main()
{
using var db = new ShopContext();
// ❌ BAD: 1 query for the products, then 1 MORE query per product to
// load its Reviews (lazy loaded on access). 100 products = 101
// round-trips to the database. That is the "N+1".
var products = db.Products.ToList();
...Lazy loading (auto-loading a relation on first access) is convenient but is exactly what makes N+1 sneak up on you — the extra queries are invisible in the C# code. Prefer explicit Include() so the cost is visible.
Pro Tips
- 💡 Keep a
DbContextshort-lived: create one per request/unit of work and dispose it. A long-lived context accumulates tracked entities and leaks memory. - 💡 Use
AsNoTracking()for read-only queries: it skips the change-tracker snapshot — measurably faster for lists and reports. - 💡 Project with
Selectwhen you only need a few columns:Select(p => p.Name)fetches one column, not whole rows. - 💡 Always
Include()what you'll touch in a loop to kill N+1 before it starts. - 💡 Bulk ops bypass tracking:
ExecuteUpdate/ExecuteDelete(EF Core 7+) translate straight to SQL without loading entities into memory.
Common Errors (and the fix)
- N+1 queries (silent, not an exception): a list page suddenly fires hundreds of queries. You looped over results and touched a navigation property per item. Fix: eager-load with
.Include(p => p.Reviews)so it's one query. - Edits don't save after
AsNoTracking(): you queried withAsNoTracking(), changed a property, then calledSaveChanges()and nothing happened. No-tracking entities aren't watched. Fix: dropAsNoTracking()for anything you intend to update. - "Nothing changed in the database": you edited entities but forgot to call
SaveChanges(). The tracker holds your changes in memory until you flush them — adddb.SaveChanges();. - "System.NullReferenceException" on a navigation property: you read
product.Reviewsbut never loaded it (no lazy loading configured, noInclude). Fix:.Include(p => p.Reviews)in the query. - "The LINQ expression could not be translated": you called a C# method EF can't turn into SQL inside
Where/Select. Fix: pull the data first with.ToList(), then do the C#-only work in memory.
📋 Quick Reference
| Task | Code | Notes |
|---|---|---|
| Declare a table | DbSet<Product> Products | One per table |
| Query (filter) | db.Products.Where(p => p.Price > 100) | → SQL WHERE |
| Run a query | .ToList() | Deferred until here |
| Eager-load relation | .Include(p => p.Reviews) | Avoids N+1 |
| Project columns | .Select(p => p.Name) | Fetch less data |
| Read-only query | .AsNoTracking() | Skip change tracking |
| Inspect a state | db.Entry(p).State | Added/Modified/… |
| Persist changes | db.SaveChanges() | One batched round-trip |
Frequently Asked Questions
Q: When exactly does my query hit the database?
Not when you write the LINQ — that just builds an expression. It runs when you enumerate the result: ToList(), foreach, First(), Count(), Any(), etc. That's deferred execution.
Q: How does SaveChanges() know what SQL to run?
It reads each tracked entity's EntityState. Added → INSERT, Modified → UPDATE (only the changed columns), Deleted → DELETE, Unchanged → nothing. It batches them into one round-trip.
Q: Should I always use AsNoTracking() to be fast?
Only for read-only queries. If you plan to edit the returned entities and call SaveChanges(), you need tracking on — otherwise EF won't notice your edits and won't save them.
Q: My page makes hundreds of queries — why?
Classic N+1: you looped over a list and touched a related property per item, triggering a query each time. Add .Include(...) to load the relation up front in a single query.
Q: Why model EF concepts with a List<T> in the exercises?
EF Core needs a live database, which the browser editor can't host. A List<T> behaves like a DbSet<T> for Add/Find/Remove, so you can run real C# that mirrors the API and concepts.
Mini-Challenge: an In-Memory Repository
No blanks this time — just a brief and an outline. Build a ProductRepository that wraps a List<Product> with Add, Find, Remove, and a Count property — the exact shape EF Core's DbSet<T> gives you, minus the database. Run it and check your output against the expected lines in the comments.
🎯 Mini-Challenge: a mini DbSet over List<T>
Implement Add / Find / Remove / Count; the final count should be 1.
using System;
using System.Collections.Generic;
using System.Linq;
// 🎯 MINI-CHALLENGE: Build an in-memory repository (a mini "DbSet").
// A repository wraps a List<T> and exposes Add / Find / Remove — exactly the
// shape EF Core's DbSet<T> gives you, minus the database.
//
// 1. Give ProductRepository a private List<Product> field called 'items'.
// 2. Add(Product p) -> add p to the list.
// 3. Find(int id) -> return the product with that Id, or null.
// 4. Remove(int
...🎉 Lesson Complete
- ✅ A
DbContextis your DB session; eachDbSet<T>maps a class to a table - ✅ The change tracker labels every entity with an
EntityState(Added/Modified/Unchanged/Deleted) - ✅ LINQ is translated to SQL; execution is deferred until you enumerate the query
- ✅
SaveChanges()batches all tracked changes into one transactional round-trip - ✅
AsNoTracking()speeds up read-only queries by skipping the snapshot - ✅
Include()eager-loads relations and kills the N+1 problem - ✅ Next lesson: EF Core Mastery — relationships, migrations, and advanced tracking strategies
Sign up for free to track which lessons you've completed and get learning reminders.