BrandGhost
EF Core Performance Best Practices in .NET 10

EF Core Performance Best Practices in .NET 10

EF Core Performance Best Practices in .NET 10

Entity Framework Core is the go-to ORM for .NET developers, but it's also one of the easiest places to accidentally tank your application's performance. EF Core performance matters more than ever as applications scale, traffic grows, and response time expectations tighten. The good news: most EF Core performance problems are fixable with a handful of well-understood techniques. This guide covers the key ones -- from quick wins like AsNoTracking() to advanced patterns like compiled queries and bulk operations in .NET 10.

Whether you're building APIs, background workers, or full-stack Blazor apps, these techniques apply directly to real-world production code.


1. AsNoTracking() -- The Fastest Easy Win

EF Core's change tracking is powerful. It watches every entity you load, detects modifications, and generates UPDATE statements automatically. That's incredibly convenient.

It's also expensive when you don't need it.

Every entity you load through a tracked query gets added to the change tracker's identity map. EF Core stores a snapshot of the original values so it can detect changes on SaveChanges(). If you're running a read-only query -- say, building an API response or rendering a page -- you're paying that cost for nothing.

AsNoTracking() skips all of that. It returns entities that are disconnected from the context, which means faster queries and lower memory pressure.

// Tracked query -- pays full change tracking overhead
var trackedPosts = await dbContext.BlogPosts
    .Where(p => p.IsPublished)
    .ToListAsync();

// No-tracking query -- faster, lower memory, perfect for read-only scenarios
var readOnlyPosts = await dbContext.BlogPosts
    .AsNoTracking()
    .Where(p => p.IsPublished)
    .ToListAsync();

When to use it: Any time you're fetching data to read and return -- API endpoints, dashboards, reports, projections.

When NOT to use it: When you load an entity and then call SaveChanges() to update it. Without tracking, EF Core has no record of the entity, so your changes won't be detected. Stick with tracked queries when you need the full load-modify-save pattern.

A useful pattern: configure UseQueryTrackingBehavior(QueryTrackingBehavior.NoTracking) globally and opt into tracking per query with AsTracking() only where needed.


2. Compiled Queries -- Eliminating Repeated Query Compilation

Every LINQ query you write gets translated to SQL at runtime. EF Core caches query plans, so repeated identical queries benefit from that cache. But there's still overhead in generating and looking up the cache key on every call.

For hot-path queries -- executed hundreds or thousands of times per second -- that overhead adds up. EF.CompileAsyncQuery eliminates it by compiling the query once at startup and reusing the compiled delegate for every invocation.

// Compiled query -- defined once as a static field, reused forever
private static readonly Func<AppDbContext, int, Task<BlogPost?>> GetPostById =
    EF.CompileAsyncQuery((AppDbContext db, int id) =>
        db.BlogPosts
            .AsNoTracking()
            .Where(p => p.Id == id && p.IsPublished)
            .FirstOrDefault());

// Usage in your service
public async Task<BlogPost?> GetPublishedPostAsync(int id)
{
    return await GetPostById(_dbContext, id);
}

Notice that the compiled query is a static field. It's created once when the class is loaded and shared across all instances. The context and parameters are passed per invocation -- EF Core handles that correctly.

Use compiled queries on your most frequently-called read paths. Check your telemetry for the top most-called database operations; those are your compiled query candidates.


3. Compiled Models -- Faster Startup for Large Schemas

Compiled models (dotnet ef dbcontext optimize) were introduced in EF Core 6. EF Core 9 extended them with experimental NativeAOT/pre-compiled query support, and EF Core 10 made pre-compiled queries production-ready. For any schema with 50+ entity types, generating a compiled model is worth the added build step.

At startup, EF Core builds an in-memory model of your entire schema -- entity types, relationships, property configurations, shadow properties, the works. For small schemas, this is fast. For large schemas, it can add hundreds of milliseconds to cold start time, which matters a lot for container-based apps that spin up frequently.

The dotnet ef dbcontext optimize command generates a pre-compiled model as C# code. EF Core uses this at startup instead of building the model dynamically.

dotnet ef dbcontext optimize --output-dir CompiledModels --namespace MyApp.Data.CompiledModels

Then register it in your context configuration:

protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
{
    optionsBuilder
        .UseSqlServer(connectionString)
        .UseModel(MyAppDbContextModel.Instance);
}

Regenerate the compiled model whenever you change your entity configuration or add/remove entities. It's a one-time developer task after each schema change, and the startup time savings are real for larger apps.

Important: The compiled model must be regenerated after every schema/model change by re-running dotnet ef dbcontext optimize.


4. Avoiding Select N+1

The N+1 problem is the most common EF Core performance trap. It's worth understanding deeply.

Here's the scenario: you load a list of Author entities, then loop over them to access each author's Posts navigation property. If Posts isn't eagerly loaded, EF Core fires a separate database query for each author. One query for the authors plus one query per author for posts equals N+1 queries total.

// ❌ N+1 problem -- 1 query for authors + 1 query per author to load Posts
var authors = await dbContext.Authors.ToListAsync();
foreach (var author in authors)
{
    // Lazy-loads Posts for each author individually
    var postCount = author.Posts.Count;
}

// ✅ Eager loading with Include -- single query (or 2 with AsSplitQuery)
var authors = await dbContext.Authors
    .Include(a => a.Posts)
    .AsNoTracking()
    .ToListAsync();

To detect N+1 problems, enable EF Core query logging. You'll see repeated queries with different parameter values -- a clear sign of individual loads happening in a loop. More on logging below.

Understanding LINQ well helps here. If you want a solid foundation on how LINQ queries compose, the LINQ in C# Complete Guide covers the essentials. And for understanding exactly when a query actually hits the database, LINQ deferred execution is key reading -- the same deferred execution model applies directly to EF Core LINQ queries.


5. Split Queries -- Avoiding Cartesian Explosion

Include() solves N+1. But when you include multiple collections, EF Core generates a single query with JOINs that can produce a cartesian product. With large datasets, this blows up fast.

Consider loading Order with both OrderItems and Payments. If an order has 50 items and 5 payments, the JOIN produces 250 rows -- 5x the data you actually need. With thousands of orders, the bandwidth and memory cost gets painful.

AsSplitQuery() solves it by sending separate queries for each collection, then combining the results in memory:

// Single query with cartesian explosion risk
var orders = await dbContext.Orders
    .Include(o => o.OrderItems)
    .Include(o => o.Payments)
    .AsNoTracking()
    .ToListAsync();

// Split queries -- separate SQL per collection, no cartesian blowup
var ordersSplit = await dbContext.Orders
    .Include(o => o.OrderItems)
    .Include(o => o.Payments)
    .AsSplitQuery()
    .AsNoTracking()
    .ToListAsync();

The trade-off: split queries use multiple round-trips and are not executed in a single transaction, so there's a small window for inconsistency if data changes between the queries. For most read scenarios, that's acceptable. For strict transactional consistency, wrap in an explicit transaction or stay with a single query.

You can also set split query behavior as the global default:

optionsBuilder.UseSqlServer(connectionString,
    o => o.UseQuerySplittingBehavior(QuerySplittingBehavior.SplitQuery));

6. Bulk Operations -- ExecuteUpdateAsync and ExecuteDeleteAsync

Before EF Core 7, if you needed to update or delete a set of records, you had to load them into memory first, modify each one, then call SaveChanges. That's expensive for large datasets. EF Core 7 introduced ExecuteUpdateAsync and ExecuteDeleteAsync to issue direct SQL without loading entities.

// ❌ Old way -- loads all matching entities into memory, then saves
var oldPosts = await dbContext.BlogPosts
    .Where(p => p.PublishDate < DateTimeOffset.UtcNow.AddYears(-3))
    .ToListAsync();
foreach (var post in oldPosts)
{
    post.IsArchived = true;
}
await dbContext.SaveChangesAsync();

// ✅ EF Core 7+ -- single UPDATE statement, zero entity loading
await dbContext.BlogPosts
    .Where(p => p.PublishDate < DateTimeOffset.UtcNow.AddYears(-3))
    .ExecuteUpdateAsync(s => s.SetProperty(p => p.IsArchived, true));

// ✅ Direct DELETE -- no entity loading, no SaveChanges overhead
await dbContext.AuditLogs
    .Where(log => log.CreatedAt < DateTimeOffset.UtcNow.AddMonths(-6))
    .ExecuteDeleteAsync();

These operations bypass the change tracker entirely -- they translate directly to SQL UPDATE and DELETE statements. For batch operations on thousands of rows, the performance difference is dramatic. No round-tripping entities through memory, no change tracking overhead, no SaveChanges loop.

One important caveat: ExecuteUpdateAsync and ExecuteDeleteAsync do not trigger EF Core interceptors, domain events, or save-time logic like value generators. If your domain relies on those hooks for important business rules, factor that in when choosing this pattern.


7. Projection with Select() -- Only Fetch What You Need

Loading full entities is convenient but wasteful when you only need a few fields. If your BlogPost entity has 20 properties -- including large text fields like Content -- fetching all 20 to display just the title and publish date is throwing bandwidth and memory away.

Use Select() to project to a DTO with only what you need:

// ❌ Over-fetching -- loads ALL columns, including large Content and HtmlContent fields
var posts = await dbContext.BlogPosts
    .Where(p => p.IsPublished)
    .AsNoTracking()
    .ToListAsync();

// ✅ Projection -- only fetches Id, Title, PublishDate (targeted SELECT in SQL)
var postSummaries = await dbContext.BlogPosts
    .Where(p => p.IsPublished)
    .OrderByDescending(p => p.PublishDate)
    .Select(p => new BlogPostSummaryDto(p.Id, p.Title, p.PublishDate))
    .ToListAsync();

public record BlogPostSummaryDto(int Id, string Title, DateTimeOffset PublishDate);

The generated SQL only selects the projected columns -- EF Core translates the Select() expression to SELECT Id, Title, PublishDate FROM BlogPosts WHERE IsPublished = 1. For list views, index pages, and summary displays, this is almost always the right pattern.

Note: projected types are not entities and are never tracked by the change tracker, so you don't need AsNoTracking() when projecting. It doesn't hurt to leave it in for clarity though.

For more on how LINQ filtering and projection compose, the LINQ filtering in C# guide covers Where, Any, All, Contains, and OfType in depth.


8. DbContext Lifetime and Connection Pooling

EF Core's DbContext is designed to be short-lived. It maintains an open connection to the database, tracks loaded entities, and accumulates pending changes. You don't want that living for the full lifetime of your application.

In ASP.NET Core, register your context as scoped (the default):

builder.Services.AddDbContext<AppDbContext>(options =>
    options.UseSqlServer(connectionString));

Scoped means one instance per HTTP request -- created at the start, disposed at the end. This is the correct lifetime for web apps.

For background services, IHostedService implementations, or parallel work, don't inject DbContext directly. It won't be scoped correctly. Use IDbContextFactory<T> instead, which lets you create an explicit, short-lived context for each unit of work:

builder.Services.AddDbContextFactory<AppDbContext>(options =>
    options.UseSqlServer(connectionString));

public class DataCleanupService : BackgroundService
{
    private readonly IDbContextFactory<AppDbContext> _contextFactory;

    public DataCleanupService(IDbContextFactory<AppDbContext> contextFactory)
    {
        _contextFactory = contextFactory;
    }

    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        while (!stoppingToken.IsCancellationRequested)
        {
            await using var context = await _contextFactory.CreateDbContextAsync(stoppingToken);

            await context.AuditLogs
                .Where(l => l.CreatedAt < DateTimeOffset.UtcNow.AddMonths(-6))
                .ExecuteDeleteAsync(stoppingToken);

            await Task.Delay(TimeSpan.FromHours(1), stoppingToken);
        }
    }
}

EF Core uses connection pooling under the hood via SqlConnection pooling at the ADO.NET layer. Keep your contexts short-lived and the pool handles reconnections efficiently. Fighting the scoping model -- holding contexts open too long or creating them too eagerly -- is where connection exhaustion issues come from.


9. Query Plan Caching and Parameterized Queries

EF Core caches compiled query plans. When the same query structure executes twice, EF Core skips the translation step and reuses the cached plan. This is automatic and significant for throughput.

What breaks caching: string concatenation or dynamic query construction that changes the structure of the query expression tree on each call.

// ❌ Breaks query plan caching -- different raw SQL string on every call
var filter = $"%{searchTerm}%";
var posts = await dbContext.BlogPosts
    .FromSqlRaw($"SELECT * FROM BlogPosts WHERE Title LIKE '{filter}'")
    .ToListAsync();

// ✅ Parameterized LINQ -- same query plan reused, parameter value changes
var posts = await dbContext.BlogPosts
    .Where(p => EF.Functions.Like(p.Title, $"%{searchTerm}%"))
    .AsNoTracking()
    .ToListAsync();

EF Core's LINQ translation always generates parameterized SQL. The parameter values change per call, but the query plan is identical and gets reused from cache. Stick to LINQ expressions over raw SQL wherever possible.

When you do need raw SQL, use FromSqlInterpolated instead of FromSqlRaw -- it automatically parameterizes the interpolated values. This prevents both query plan cache misses and SQL injection vulnerabilities in one move.


10. Logging Slow Queries with Serilog

You can't optimize what you can't measure. EF Core has built-in logging support that integrates with .NET's ILogger infrastructure. Combined with Serilog, you get structured logs with all the query details you need to find slow paths in production.

The recommended pattern: configure a minimum command execution time so you only log queries that actually cross a performance budget -- not every query in your application.

// Program.cs -- EF Core with Serilog and slow query detection
builder.Services.AddDbContext<AppDbContext>(options =>
{
    options.UseSqlServer(connectionString)
           .UseLoggerFactory(LoggerFactory.Create(lb =>
               lb.AddSerilog()))
           .EnableDetailedErrors()
           // ⚠️ Only in development -- logs actual parameter values including PII
           // .EnableSensitiveDataLogging()
           ;
});

// In Serilog configuration -- filter EF Core command events by duration
builder.Host.UseSerilog((ctx, config) =>
{
    config
        .ReadFrom.Configuration(ctx.Configuration)
        .Enrich.FromLogContext()
        .WriteTo.Console()
        .WriteTo.File("logs/app-.log", rollingInterval: RollingInterval.Day)
        // Separate sink for slow queries: filter on EF Core's elapsed time property
        .WriteTo.Logger(lc => lc
            .Filter.ByIncludingOnly(e =>
                e.Properties.TryGetValue("ElapsedMilliseconds", out var ms) &&
                ms is ScalarValue sv &&
                sv.Value is long ms2 &&
                ms2 > 500)
            .WriteTo.File("logs/slow-queries-.log", rollingInterval: RollingInterval.Day));
});

EF Core emits CommandExecutedEventData events through the ILogger pipeline for every database command. The ElapsedMilliseconds property carries the execution duration. Filtering on that property in a Serilog sub-logger gives you a dedicated slow-query log that's easy to review and alert on.

Note: The property name ElapsedMilliseconds is written by EF Core via ILogger. Verify this works in your specific logging setup -- if using LogTo() or a custom formatter bridge, the enriched property name may differ. An alternative for more reliable slow query detection is IDbCommandInterceptor.

For a complete guide to Serilog setup in ASP.NET Core, see How to Set Up Serilog in ASP.NET Core. For the full Serilog capabilities including enrichers, sinks, and filtering, check out the Serilog in .NET guide. And for a broader look at logging in .NET beyond Serilog, the Logging in .NET complete guide covers the full landscape.

Critical production note: EnableSensitiveDataLogging() logs actual parameter values in query text. That includes passwords, PII, and financial data -- whatever's in your database. Never enable it in production. Use it locally when debugging a specific query, then remove it before committing.


Putting It All Together

Here's a practical priority order for tackling EF Core performance improvements:

Start with the biggest wins for the least effort:

  1. Add AsNoTracking() to all read-only queries -- 10 minutes of work, immediate impact.
  2. Find N+1 queries with logging, fix with Include() -- usually a one-line change per query.
  3. Replace entity-loading batch operations with ExecuteUpdateAsync / ExecuteDeleteAsync.

Next, optimize hot paths: 4. Add Select() projections to your most-called list and index endpoints. 5. Profile your startup time -- if it's slow, consider compiled models. 6. Add compiled queries to your highest-frequency hot-path reads.

Then tune edge cases: 7. Evaluate AsSplitQuery() for queries with multiple collection Includes on large datasets. 8. Verify you're on scoped DbContext in web apps and IDbContextFactory in background services. 9. Wire up Serilog slow query logging and let it run for a few days to find surprises.

EF Core is fast when used correctly. The defaults are designed for correctness and developer experience -- not raw throughput. Shifting toward the patterns above is how you get both.


FAQ

What is the single biggest EF Core performance mistake developers make?

The most common mistake is loading full entities when only a few fields are needed, combined with not using AsNoTracking() for read-only queries. These two problems alone can cause an order-of-magnitude difference in throughput for query-heavy applications. Fixing them doesn't require deep EF Core knowledge -- just consistent habits applied to every query.

When should I use AsNoTracking() vs tracked queries?

Use AsNoTracking() any time you're loading data to read and return without modifying it -- API responses, report queries, dashboard data, projections. Use tracked queries when you need to load an entity, modify it, and call SaveChanges(). If you're unsure, a safe default is to configure NoTracking globally and opt into tracking explicitly with AsTracking() only where required.

How do I detect N+1 queries in EF Core?

Enable EF Core logging and look for repeated queries with the same structure but different parameter values in the logs. In development, EnableSensitiveDataLogging() shows the actual parameter values so you can confirm the N+1 pattern. Tools like MiniProfiler, dotnet-monitor, or SQL Server Profiler can also surface N+1 patterns in running applications. The Logging in .NET complete guide covers how to configure EF Core logging through the standard ILogger pipeline.

What is cartesian explosion and how does AsSplitQuery() fix it?

Cartesian explosion occurs when you Include() multiple collection navigation properties in a single query. EF Core generates JOINs across all collections, and the result set grows multiplicatively -- if an entity has 50 items in collection A and 10 items in collection B, the single JOIN returns 500 rows instead of 60. AsSplitQuery() avoids this by issuing separate queries per collection and combining the results in application memory rather than in the database.

Are EF Core compiled models worth the added build step?

For small to medium schemas (under 50 entity types), the startup improvement is negligible -- probably not worth the added workflow step. For larger schemas (100+ entities), especially in containerized workloads that cold-start frequently, compiled models can meaningfully reduce startup latency. The trade-off is that you must regenerate the compiled model after every schema change. It's a compile-time artifact, not a runtime one, so it never affects production correctness -- only startup speed.

Does ExecuteUpdateAsync bypass domain events and interceptors?

Yes -- ExecuteUpdateAsync and ExecuteDeleteAsync bypass the EF Core change tracker entirely. They issue direct SQL without loading entities, which means SaveChanges interceptors, domain events triggered on save, and value generators that run during SaveChanges are not triggered. This is intentional for performance. If your domain logic depends on those hooks for important business rules, either use the traditional load-modify-save pattern or raise domain events manually alongside the bulk operation.

How should I configure DbContext lifetime in a background service?

Inject IDbContextFactory<T> into your background service rather than DbContext directly. DbContext is scoped (one instance per HTTP request) and not appropriate for long-running services. IDbContextFactory<T> lets you create a short-lived context for each unit of work and dispose it when done -- the same controlled lifetime you get in a web request, but applied explicitly where you need it.


Wrapping Up

EF Core performance is not a single setting you flip -- it's a collection of good habits applied consistently across your codebase. AsNoTracking(), compiled queries, smart use of Include() and Select(), bulk operations, and solid logging form the foundation. .NET 10 and EF Core 10 make these patterns easier and more capable than ever.

The best approach: instrument your application first, find your actual slow queries with Serilog or another logging tool, then apply the appropriate fix from this guide. Premature optimization is as wasteful in EF Core as anywhere else. But once you know where the time goes, the techniques above give you precise, well-tested tools to address it.

For more on the underlying query mechanics, check out LINQ deferred execution -- understanding when EF Core actually sends a query to the database is foundational to writing efficient, predictable data access code.

EF Core LINQ Querying: Filtering, Projections, and Performance

Master EF Core LINQ queries in .NET 10 -- Where, Select projections, Include for eager loading, AsNoTracking, compiled queries, and avoiding N+1 issues.

Entity Framework Core in .NET: The Complete Guide

Learn Entity Framework Core in .NET 10 -- DbContext, migrations, LINQ queries, relationships, performance tips, and when to use EF Core vs Dapper in C#.

EF Core CRUD Operations in C#: Create, Read, Update, Delete

Master EF Core CRUD in C# with .NET 10 -- learn AddAsync, SaveChangesAsync, FindAsync, Where queries, tracked updates, and bulk ExecuteUpdate/ExecuteDelete.

An error has occurred. This application may no longer respond until reloaded. Reload