Entity Framework Core Performance: Change Tracking, N+1, and AsNoTracking
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 tripNow 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 willSaveChanges()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 — pushWhere/Selectinto the query. Include-ing everything "just in case," creating cartesian explosions and pulling data no code path renders.- Reusing a
DbContextacross requests or threads.DbContextis 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
| Approach | Queries | Tracked? | Best for |
|---|---|---|---|
| Lazy loading | 1 + N | Yes | Almost never on lists — the N+1 trap |
Include() | 1 (join) or split | Yes (unless AsNoTracking) | Loading full entities you will edit |
AsSplitQuery + Include | 1 per relationship | Optional | Wide graphs where joins explode |
Projection (.Select) | 1 | No | Read-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
- Entity Framework Core performance guidance (Microsoft Learn) — the official performance overview and diagnostics.
- Tracking vs. no-tracking queries (Microsoft Learn) — how the change tracker behaves and when to disable it.
- Loading related data (Microsoft Learn) — eager, explicit, lazy loading, and split queries.
- Efficient querying (Microsoft Learn) — projection, pagination, and buffering guidance.

