Skip to main content
    Courses/C#/EF Core Internals

    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

    💡 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

    EntityStateMeaningSQL at SaveChanges
    AddedNew entity, not yet in the databaseINSERT
    UnchangedLoaded and not modified since(none)
    ModifiedOne or more properties changedUPDATE (changed columns only)
    DeletedMarked for removalDELETE
    DetachedNot 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.

    Try it Yourself »
    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
    ...

    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.

    Try it Yourself »
    C#
    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.

    Try it Yourself »
    C#
    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.

    Try it Yourself »
    C#
    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.

    Try it Yourself »
    C#
    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.

    Try it Yourself »
    C#
    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 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

    TaskCodeNotes
    Declare a tableDbSet<Product> ProductsOne 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 statedb.Entry(p).StateAdded/Modified/…
    Persist changesdb.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.

    Try it Yourself »
    C#
    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 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
    • 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.

    Previous

    Cookie & Privacy Settings

    We use cookies to improve your experience, analyze traffic, and show personalized ads. You can manage your preferences below.

    By clicking "Accept All", you consent to our use of cookies for analytics and personalized advertising. You can customize your preferences or reject non-essential cookies.

    Privacy PolicyTerms of Service