Entity Framework Core Internals
Reviewed & published by Brayan K
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.
Part of the free C# course at LearnCodingFast — hands-on lessons with worked examples and the output they print, plus practice exercises and a quick quiz.
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
💡 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#.
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 string Name { get; set; } = ""; // -> a NOT NULL text column
public decimal Price { get; set; } // -> a decimal/money column
// Navigation property: one Product has many Reviews (a related table).
public List<Review> Reviews { get; set; } = new();
}
public class Review
{
public int Id { get; set; }
public string Content { get; set; } = "";
public int Rating { get; set; } // 1-5
public int ProductId { get; set; } // FOREIGN KEY back to Product
public Product Product { get; set; } = null!;
}
// The DbContext is your SESSION with the database. One DbSet<T> per table.
public class ShopContext : DbContext
{
public DbSet<Product> Products => Set<Product>(); // the Products table
public DbSet<Review> Reviews => Set<Review>(); // the Reviews table
protected override void OnConfiguring(DbContextOptionsBuilder options)
{
// Tells EF which database to talk to (here a local SQLite file).
options.UseSqlite("Data Source=shop.db");
}
}
class Program
{
static void Main()
{
// 'using' disposes the context (closes the connection) at the end.
using var db = new ShopContext();
db.Database.EnsureCreated(); // creates the tables if they don't exist
// EF turns this Add + SaveChanges into a real INSERT statement:
db.Products.Add(new Product { Name = "Laptop", Price = 999.99m });
db.SaveChanges();
// ✅ Expected SQL generated by EF Core:
// INSERT INTO "Products" ("Name", "Price") VALUES ('Laptop', 999.99);
// ✅ Products table now has 1 row.
}
}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.
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) Make 'Products' a new empty List<Product>.
public List<Product> Products = ___; // 👉 new List<Product>()
// Pretend SaveChanges: report how many rows we are holding.
public int SaveChanges() => Products.Count;
}
class Program
{
static void Main()
{
var db = new FakeContext();
// 2) Add a Product named "Laptop" to the Products list.
db.Products.___(new Product { Id = 1, Name = "Laptop" }); // 👉 Add
db.Products.Add(new Product { Id = 2, Name = "Desk" });
int rows = db.SaveChanges();
Console.WriteLine($"Saved {rows} product(s)");
Console.WriteLine($"First product: {db.Products[0].Name}");
// ✅ Expected output:
// Saved 2 product(s)
// First product: Laptop
}
}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.
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. The tracker flips its state to Modified.
product.Price = 1099.99m;
Console.WriteLine(db.Entry(product).State); // Modified
// 3) Add a brand-new entity. Its state is Added (not in the DB yet).
var monitor = new Product { Name = "Monitor", Price = 449.99m };
db.Products.Add(monitor);
Console.WriteLine(db.Entry(monitor).State); // Added
// 4) Mark one for deletion. Its state becomes Deleted.
var old = db.Products.Last();
db.Products.Remove(old);
Console.WriteLine(db.Entry(old).State); // Deleted
// 5) SaveChanges turns every tracked state into ONE batched round-trip.
int affected = db.SaveChanges();
// ✅ Expected SQL (one batch): an UPDATE (only the Price column),
// an INSERT (Monitor), and a DELETE (the old row).
// UPDATE "Products" SET "Price" = 1099.99 WHERE "Id" = 1;
// INSERT INTO "Products" ("Name","Price") VALUES ('Monitor', 449.99);
// DELETE FROM "Products" WHERE "Id" = 2;
Console.WriteLine($"{affected} rows affected"); // 3 rows affected
// 6) After SaveChanges every surviving entity is Unchanged again.
Console.WriteLine(db.Entry(product).State); // Unchanged
}
}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.
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; }
}
class Program
{
static void Main()
{
// Loaded from the "database": starts life as Unchanged.
var p = new TrackedProduct { Name = "Laptop", State = EntityState.Unchanged };
Console.WriteLine($"{p.Name}: {p.State}");
// 2) The user edits the price, so flip the state to Modified.
p.State = EntityState.___; // 👉 Modified
Console.WriteLine($"{p.Name}: {p.State}");
// After a save, EF resets it to Unchanged (done for you):
p.State = EntityState.Unchanged;
Console.WriteLine($"{p.Name}: {p.State}");
// ✅ Expected output:
// Laptop: Unchanged
// Laptop: Modified
// Laptop: Unchanged
}
}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.
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
.OrderBy(p => p.Name); // -> ORDER BY "Name"
// DEFERRED EXECUTION: the SQL runs HERE, when ToList() enumerates it.
var expensive = query.ToList();
// ✅ Expected SQL: SELECT "Id","Name","Price" FROM "Products"
// WHERE "Price" > 100 ORDER BY "Name";
foreach (var p in expensive)
Console.WriteLine($"{p.Name}: {p.Price:C}");
// PROJECTION with Select — fetch ONLY the columns you need (faster):
var names = db.Products.Select(p => p.Name).ToList();
// ✅ Expected SQL: SELECT "Name" FROM "Products";
// EAGER LOADING with Include — pull related Reviews in the SAME query
// (this is how you AVOID the N+1 problem, covered below):
var withReviews = db.Products
.Include(p => p.Reviews)
.ToList();
// ✅ Expected SQL: a single SELECT joining Products and Reviews.
// AsNoTracking — read-only query, skip the change tracker = less memory,
// faster, but you can't SaveChanges() edits to these objects.
var readOnly = db.Products
.AsNoTracking()
.Where(p => p.Price < 500)
.ToList();
Console.WriteLine($"{readOnly.Count} cheap product(s)");
}
}🔎 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 UPDATERule 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.
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(); // 1 query
foreach (var p in products)
Console.WriteLine(p.Reviews.Count); // +1 query EACH (N)
// ✅ Result: 1 + N queries. Slow, and it gets worse as data grows.
// ✅ GOOD: ask for the related data UP FRONT with Include — EF loads
// everything in ONE query (a JOIN). 100 products = 1 query.
var fixedProducts = db.Products
.Include(p => p.Reviews)
.ToList(); // 1 query, total
foreach (var p in fixedProducts)
Console.WriteLine(p.Reviews.Count); // no extra queries
// ✅ Result: 1 query. This is the fix — eager-load what you'll use.
}
}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 DbContext short-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 Select when 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 with AsNoTracking(), changed a property, then called SaveChanges() and nothing happened. No-tracking entities aren't watched. Fix: drop AsNoTracking() 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 — add db.SaveChanges();.
- "System.NullReferenceException" on a navigation property: you read product.Reviews but never loaded it (no lazy loading configured, no Include). 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.
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 id) -> remove the product with that Id.
// 5. Count -> a property returning items.Count.
//
// In Main: add two products, Find one and print its Name, Remove it,
// then print the Count.
//
// ✅ Expected output:
// Found: Laptop
// Remaining: 1
class Product
{
public int Id { get; set; }
public string Name { get; set; } = "";
}
class ProductRepository
{
// your field, methods and Count property here
}
class Program
{
static void Main()
{
// your code here
}
}🎉 Lesson Complete
- ✅ A DbContext is your DB session; each DbSet<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
Practice quiz
What is a DbContext in EF Core?
- A single database table
- A LINQ query
- Your session with the database that tracks changes
- The connection string
Answer: Your session with the database that tracks changes. A DbContext is your short-lived session with the database — it opens a connection, holds your data, and tracks changes.
What does a DbSet<T> represent?
- One table, as a queryable collection of entities
- A single row
- A migration
- A foreign key
Answer: One table, as a queryable collection of entities. You expose one DbSet<T> per table; it behaves like a queryable collection of T backed by that table.
When you load an entity, what EntityState does the change tracker assign it?
- Added
- Modified
- Detached
- Unchanged
Answer: Unchanged. A freshly loaded, unmodified entity starts life as Unchanged.
After you change a property on a tracked entity, what is its EntityState?
- Unchanged
- Modified
- Added
- Deleted
Answer: Modified. The change tracker flips an edited entity's state to Modified, so SaveChanges emits an UPDATE.
When does a deferred LINQ query actually hit the database?
- When you enumerate it (ToList, foreach, First, Count...)
- When you write the LINQ chain
- When the DbContext is created
- Never — it's always in memory
Answer: When you enumerate it (ToList, foreach, First, Count...). Building the query just creates an expression; the SQL runs only when you enumerate the result.
What does EF Core do when you call SaveChanges()?
- Sends one query per entity immediately
- Discards untracked entities
- Batches all tracked changes into one transactional round-trip
- Only saves Added entities
Answer: Batches all tracked changes into one transactional round-trip. SaveChanges walks the change tracker and sends every non-Unchanged change as one batched round-trip inside a transaction.
What does AsNoTracking() do?
- Prevents the query from running
- Skips the change-tracker snapshot for read-only queries
- Deletes the entities after reading
- Forces eager loading
Answer: Skips the change-tracker snapshot for read-only queries. AsNoTracking skips the snapshot, making read-only queries faster and lighter — but EF won't notice edits to those entities.
What is the N+1 problem?
- Adding N+1 rows at once
- A migration that runs twice
- A query that returns null
- One query, then one more query per item to load a relation
Answer: One query, then one more query per item to load a relation. N+1 is one query for a list plus one extra query per item when you touch a navigation property — 100 items becomes 101 round-trips.
How do you fix the N+1 problem in EF Core?
- Use AsNoTracking()
- Eager-load the relation up front with Include()
- Call SaveChanges() more often
- Use a Singleton DbContext
Answer: Eager-load the relation up front with Include(). Include() eager-loads the related data in a single JOIN query, so the relation isn't lazily fetched per item.
Why does lazy loading make N+1 easy to introduce accidentally?
- It loads everything eagerly
- It disables change tracking
- The extra per-item queries are invisible in the C# code
- It only works with AsNoTracking
Answer: The extra per-item queries are invisible in the C# code. Lazy loading auto-fetches a relation on first access, so the extra queries are hidden — prefer explicit Include() so the cost is visible.
Continue this course
- Previous: Middleware, Filters & Custom Attributes in ASP.NET
- Next: EF Core Relationships, Tracking & Migrations Mastery — One-to-many, many-to-many, owned entities, and migration strategies
- Quick reference: C# cheat sheet
- From the blog: C# LINQ: A Complete Guide