DevgainsDevgainsDevgains
All articles

Entity Framework Core Performance: Change Tracking, N+1, and AsNoTracking

·9 min read·Updated Jul 13, 2026

Entity Framework Core performance problems almost never come from EF Core being slow. They come from EF Core doing exactly what you asked — tracking every entity you load, issuing a query for every relationship you touch, and materializing columns you never read. The ORM is a leaky abstraction over SQL, and the developers who write fast EF Core code are the ones who know which LINQ expression turns into which query. This guide explains how change tracking works, why EF Core produces N+1 queries by default, and the three levers — AsNoTracking, Include, and projection — that turn a slow endpoint into a fast one.

This is a supporting page in the Devgains .NET cluster. It assumes you are comfortable with async/await in C# and how services are wired up with dependency injection in ASP.NET Core, since a DbContext is a scoped service you resolve per request.

Quick answer: how do you make EF Core fast?

Most Entity Framework Core performance wins come from three habits:

  • Use AsNoTracking() for read-only queries. It skips change tracking, so EF Core does less work and uses less memory when you are not going to save changes.
  • Eager-load with Include() or project to a DTO instead of lazy-loading relationships one row at a time. This is how you kill the N+1 problem.
  • Select only the columns you need with a projection (.Select(...)), so the database returns a narrow result set instead of every column of every table.

Everything else — batching, compiled queries, split queries — is a refinement on top of getting these three right first.

Why Entity Framework Core performance matters

EF Core sits directly on the hot path of most .NET web applications. A single slow list endpoint that issues 300 queries where 2 would do will dominate your p95 latency, exhaust the connection pool, and make the whole service feel sluggish under load. Because the ORM hides the SQL, these problems are invisible in code review: the LINQ reads cleanly, the tests pass against a database with ten rows, and the endpoint melts in production once the table has ten thousand. Understanding what EF Core emits is the difference between an app that scales and one that needs a rewrite.

How change tracking works

When you load an entity through a tracking query, EF Core keeps a snapshot of it in the DbContext's change tracker. On SaveChanges(), it compares each tracked entity against its snapshot, works out what changed, and generates the minimal INSERT/UPDATE/DELETE statements.

using var db = new AppDbContext();
 
// Tracked: EF Core snapshots this entity in the change tracker.
var user = await db.Users.FirstAsync(u => u.Id == id);
user.LastLoginUtc = DateTime.UtcNow;
 
// EF diffs the snapshot, sees only LastLoginUtc changed, and emits
// UPDATE Users SET LastLoginUtc = @p WHERE Id = @id
await db.SaveChangesAsync();

Change tracking is what makes writes ergonomic — you mutate objects and call SaveChanges(). But it has a cost. For every entity in the result set, EF Core builds and holds a snapshot. Load 10,000 rows into a tracking query and you pay for 10,000 snapshots you will never save. For read-only work, that is pure waste.

AsNoTracking: opt out when you only read

If a query feeds a read-only path — a list view, an API response, a report — tell EF Core not to track:

var users = await db.Users
    .AsNoTracking()
    .Where(u => u.IsActive)
    .ToListAsync();

AsNoTracking() skips snapshotting entirely. The entities come back detached, EF Core uses less memory, and materialization is measurably faster on large result sets. The rule of thumb: track only what you intend to modify. For everything else, AsNoTracking() is the default you should reach for. You can even flip the context-wide default with ChangeTracker.QueryTrackingBehavior = QueryTrackingBehavior.NoTracking and opt into tracking where you write.

Why EF Core causes N+1 queries

The N+1 query problem is the most common EF Core performance bug. It happens when you load a list of parents with one query, then trigger a separate query for each parent's children:

var blogs = await db.Blogs.ToListAsync();        // 1 query for N blogs
foreach (var blog in blogs)
{
    // Each access to blog.Posts fires its own SELECT — N more queries.
    Console.WriteLine($"{blog.Name}: {blog.Posts.Count} posts");
}

If Posts is a lazy-loaded navigation property, that loop issues 1 + N queries. With 500 blogs, that is 501 round trips to the database, each with its own latency. Every individual query is fast, so nothing looks wrong — the cost is the count, and it grows with your data.

EF Core makes this easy to hit because lazy loading disguises database access as ordinary property access. blog.Posts looks free; it is a query.

Fix it with Include (eager loading)

Tell EF Core to load the related data up front with Include():

var blogs = await db.Blogs
    .AsNoTracking()
    .Include(b => b.Posts)
    .ToListAsync();   // relationships loaded in the same round trip

Now EF Core fetches blogs and their posts together instead of one-post-at-a-time. For deep graphs, chain ThenInclude() to pull further levels. Be deliberate, though: a single query with several Includes uses SQL JOINs, which multiply parent columns across every child row (the cartesian explosion). When that result set gets wide, switch to split queries so EF Core issues one query per relationship instead of one giant join:

var blogs = await db.Blogs
    .AsNoTracking()
    .Include(b => b.Posts)
    .Include(b => b.Authors)
    .AsSplitQuery()   // one SELECT per Include, no cartesian blow-up
    .ToListAsync();

Fix it better with projection

Often you do not need whole entities at all — you need a handful of fields. Project straight to a DTO and let the database do the shaping:

var summaries = await db.Blogs
    .Select(b => new BlogSummary
    {
        Name = b.Name,
        PostCount = b.Posts.Count   // becomes a correlated subquery / join, not N+1
    })
    .ToListAsync();

A projection is implicitly no-tracking, selects only the columns named, and lets EF Core translate b.Posts.Count into SQL rather than loading every post into memory. For read paths, projection is frequently the fastest option of all — narrow rows, no snapshots, one query.

Best practices

  • Default to AsNoTracking() for reads. Track only entities you will SaveChanges() on.
  • Never lazy-load inside a loop. Use Include() or a projection to fetch related data in one go.
  • Project to DTOs on read paths. Select the columns you render, nothing more.
  • Await the async APIs (ToListAsync, FirstAsync, SaveChangesAsync) so request threads are freed during I/O — see async/await in C#.
  • Batch writes. EF Core batches multiple changes into fewer round trips on one SaveChanges(); don't call it inside a loop.
  • Index the columns you filter and join on. EF Core emits the SQL, but the database's query planner still needs indexes to run it fast.
  • Log the generated SQL in development so you can see exactly what each LINQ query becomes.

Common mistakes

  • Tracking read-only queries. The single most common source of wasted memory and CPU.
  • Calling .ToList() too early, then filtering in memory. Anything after client-side materialization runs in C#, not SQL — push Where/Select into the query.
  • Include-ing everything "just in case," creating cartesian explosions and pulling data no code path renders.
  • Reusing a DbContext across requests or threads. DbContext is not thread-safe and is registered as a scoped service for a reason.
  • Ignoring the SQL. If you never look at what EF Core emits, you are flying blind.

Include vs projection vs lazy loading

ApproachQueriesTracked?Best for
Lazy loading1 + NYesAlmost never on lists — the N+1 trap
Include()1 (join) or splitYes (unless AsNoTracking)Loading full entities you will edit
AsSplitQuery + Include1 per relationshipOptionalWide graphs where joins explode
Projection (.Select)1NoRead-only views and API responses

Takeaways

  • EF Core performance is mostly about knowing which LINQ turns into which SQL — the abstraction is leaky by design.
  • Change tracking powers ergonomic writes but costs a snapshot per entity; use AsNoTracking() for anything read-only.
  • N+1 comes from lazy-loading relationships in a loop; fix it with Include() (eager loading) or a projection.
  • Projection to a DTO is often the fastest read path: one query, narrow columns, no tracking.
  • Look at the generated SQL. You cannot tune what you cannot see.

FAQ

What is the biggest Entity Framework Core performance win? Using AsNoTracking() on read-only queries and eliminating N+1 by eager-loading with Include() or projecting to a DTO. These two changes fix the majority of slow EF Core endpoints.

When should I use AsNoTracking? On any query whose results you will not modify and save — list views, API responses, reports. Tracking only helps when you intend to call SaveChanges().

Does Include cause performance problems? It can. Multiple Includes become SQL joins that multiply rows (cartesian explosion). For wide object graphs, use AsSplitQuery() so EF Core issues one query per relationship instead.

Is EF Core slower than raw SQL? For most CRUD it is close, and the productivity gain is large. On measured hot paths you can drop to raw SQL with FromSqlRaw or a micro-ORM, but fix tracking and N+1 first — that is usually the whole gap.

Conclusion

Entity Framework Core is not slow; unconsidered EF Core is slow. Once you internalize that a tracking query snapshots every row, that touching a lazy navigation fires a query, and that a projection lets the database do the shaping, the fast path becomes obvious: AsNoTracking() for reads, Include() or projection instead of lazy loading, and only the columns you need. Turn on SQL logging, read what your LINQ actually emits, and the abstraction stops leaking surprises. From here the .NET cluster goes deeper into building fast services — browse the full .NET category and the database guides to continue.

References

9 min read

Read next