Ef 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.

Learn Python, JavaScript, Java and more with free interactive lessons, real projects and AI-powered help. Beginner-friendly.

Part of the free C# course at LearnCodingFast — hands-on lessons with examples you run in your browser, plus practice exercises and a quick quiz.

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 .

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#.

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.

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.

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.

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.

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.

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.

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.

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.

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.

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.

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.

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.