Entity Framework Core Performance: 12 Patterns
Entity Framework Core 10 is the fastest, most capable EF version ever — but the patterns that make it perform under load aren't always obvious. We've seen production .NET applications cut p95 response times by 70%+ by applying a handful of these techniques, with no change to application architecture.
Here are the 12 EF Core performance patterns we apply on every production deployment running on Adaptive ASP.NET Core hosting, with concrete code and the SQL Server 2022 features that amplify each one.
12 | Patterns That Move the Needle
70% | Typical p95 Latency Reduction
EF Core 10 | Latest Version
$9.49 | Plans From / mo (with SQL 2022)
The Setup: DbContext Pooling
Before applying any pattern, use DbContext pooling. The default in ASP.NET Core's DI container creates a new DbContext per request — fast, but each instance allocates internal state.
builder.Services.AddDbContextPool<AppDbContext>(opt =>
opt.UseSqlServer(builder.Configuration.GetConnectionString("Default")));
Trade-off: pooled contexts must reset state cleanly between uses. If you have stateful fields on your DbContext (not recommended), pooling will surprise you. For 95% of apps, the throughput gain is 15–25%.
1. AsNoTracking for Read-Only Queries
The default behavior in EF Core is to track every entity it loads — recording its original values so SaveChanges() can detect modifications. For read-only queries, this overhead is pure waste.
// Default — tracked
var customers = await context.Customers
.Where(c => c.IsActive)
.ToListAsync();
// Read-only — no tracking
var customers = await context.Customers
.AsNoTracking()
.Where(c => c.IsActive)
.ToListAsync();
> 🚀 Real impact: On a 1,000-row query, AsNoTracking is 30–50% faster and uses ~40% less memory. Apply liberally to controller actions that don't modify data.
You can also set it as the default per-context:
builder.Services.AddDbContextPool<AppDbContext>(opt =>
opt.UseSqlServer(connStr)
.UseQueryTrackingBehavior(QueryTrackingBehavior.NoTracking));
2. Use Select Projection (Avoid SELECT *)
The default ToList() materializes every column. For wide tables (Customer with 40 columns when you need 3), that's wasted I/O and memory.
// Bad — loads everything
var customers = await context.Customers
.Where(c => c.IsActive)
.ToListAsync();
// Good — only what you need
var customers = await context.Customers
.Where(c => c.IsActive)
.Select(c => new CustomerSummary
{
Id = c.Id,
Email = c.Email,
LastSeenAt = c.LastSeenAt,
})
.ToListAsync();
Projection also bypasses the change tracker automatically — no need for AsNoTracking. EF Core's query translator turns the Select into specific SELECT columns at the SQL level.
3. Eager-Load with Include (Prevent N+1)
The single most common performance bug in EF Core code: N+1 queries.
// N+1 nightmare — runs 1 query for orders, then N queries for customer per order
var orders = await context.Orders.ToListAsync();
foreach (var order in orders)
{
Console.WriteLine($"{order.Id}: {order.Customer.Name}"); // ← separate query each time
}
Fix with .Include:
var orders = await context.Orders
.Include(o => o.Customer)
.Include(o => o.Items)
.ToListAsync();
In dev, enable sensitive logging to catch N+1 in real time:
opt.UseSqlServer(connStr).EnableSensitiveDataLogging(); // dev only
4. Split Queries for Complex Includes
When you .Include multiple collections, EF Core 6+ generates a single Cartesian-product query that can return absurdly large result sets (orders × items × shipments × ...).
// Generates one massive JOIN — slow with multiple collections
var orders = await context.Orders
.Include(o => o.Items)
.Include(o => o.Shipments)
.Include(o => o.Notes)
.ToListAsync();
// Generates N separate queries — much faster on collections
var orders = await context.Orders
.Include(o => o.Items)
.Include(o => o.Shipments)
.Include(o => o.Notes)
.AsSplitQuery()
.ToListAsync();
> 💡 Rule of thumb: Use AsSplitQuery when including 2+ collection navigations. Use single-query (default) when including only reference navigations or a single collection. Set as default per-context if your app frequently includes multiple collections.
5. Bulk Operations with ExecuteUpdate / ExecuteDelete
EF Core 7+ added bulk update/delete operations that translate to single SQL statements — no need to load entities into memory.
// Bad — loads, updates, saves
var expiredOrders = await context.Orders
.Where(o => o.Status == "Pending" && o.CreatedAt < cutoff)
.ToListAsync();
foreach (var o in expiredOrders) o.Status = "Expired";
await context.SaveChangesAsync();
// Good — single UPDATE statement
await context.Orders
.Where(o => o.Status == "Pending" && o.CreatedAt < cutoff)
.ExecuteUpdateAsync(s => s.SetProperty(o => o.Status, "Expired"));
For deletes:
await context.AuditLogs
.Where(a => a.CreatedAt < DateTime.UtcNow.AddMonths(-6))
.ExecuteDeleteAsync();
On a 100K-row operation, this is the difference between 60 seconds and 200 ms.
6. Compiled Queries for Hot Paths
If you have a query that runs thousands of times per second, EF Core's query compilation overhead (turning LINQ into SQL) adds up. Compiled queries cache the SQL once.
private static readonly Func<AppDbContext, int, Task<Customer?>> GetById =
EF.CompileAsyncQuery((AppDbContext ctx, int id) =>
ctx.Customers.FirstOrDefault(c => c.Id == id));
// Use it
public Task<Customer?> GetCustomerAsync(int id) => GetById(_context, id);
> 🚀 When to apply: Only on queries that genuinely hot-loop — auth lookups, configuration reads, or session validation. For ad-hoc queries, the compiled-query overhead in setup outweighs the runtime gain.
7. Index What You Filter and Sort
The query plan is only as good as the indexes available to it. EF Core 7+ supports index annotations in code:
public class AppDbContext : DbContext
{
protected override void OnModelCreating(ModelBuilder mb)
{
mb.Entity<Order>()
.HasIndex(o => new { o.CustomerId, o.Status, o.CreatedAt })
.IncludeProperties(o => new { o.Total, o.ProductId })
.HasDatabaseName("IX_Orders_Customer_Status_Created");
}
}
Run migrations to apply:
dotnet ef migrations add AddOrderIndexes
dotnet ef database update
> ⚠️ Composite index column order matters. (CustomerId, Status, CreatedAt) supports any prefix — queries filtering by (CustomerId), (CustomerId, Status), or the full set. Put the most-selective column first.
8. Use SQL Server 2022 Native JSON Columns
EF Core 8+ supports SQL Server's native JSON type, which is faster than parsing JSON strings on every query:
public class Product
{
public int Id { get; set; }
public string Name { get; set; } = "";
[Column(TypeName = "json")]
public ProductSpecs Specs { get; set; } = new();
}
public class ProductSpecs
{
public string Color { get; set; } = "";
public decimal Weight { get; set; }
public string[] Features { get; set; } = [];
}
// Query JSON properties in LINQ
var redProducts = await context.Products
.Where(p => p.Specs.Color == "red")
.ToListAsync();
This is only available on SQL Server 2022 — not Express, not older versions. Every Adaptive plan includes SQL Server 2022 (2 / 5 / 10 databases by plan).
9. Connection Resiliency for Transient Failures
Cloud environments have transient network blips. EF Core supports automatic retries:
opt.UseSqlServer(connStr, sqlOpts =>
{
sqlOpts.EnableRetryOnFailure(
maxRetryCount: 5,
maxRetryDelay: TimeSpan.FromSeconds(30),
errorNumbersToAdd: null);
});
Don't enable in apps that use long-running transactions or rely on IDbContextTransaction — retries can deadlock with manual transaction management. For typical request/response apps, enable it.
10. Vector Search with EF Core 10
EF Core 10 added native vector support for AI-driven similarity search. Combined with SQL Server 2022's VECTOR data type:
public class Document
{
public int Id { get; set; }
public string Title { get; set; } = "";
public string Body { get; set; } = "";
[Column(TypeName = "VECTOR(1536)")] // OpenAI embedding dimension
public float[] Embedding { get; set; } = [];
}
// Find similar documents
var queryEmbedding = await openAi.EmbedAsync(searchQuery);
var similar = await context.Documents
.OrderBy(d => EF.Functions.VectorDistance(d.Embedding, queryEmbedding))
.Take(10)
.ToListAsync();
This is the foundation for RAG-style features without leaving your existing SQL Server. See our .NET Core 10 hosting comprehensive guide for the broader AI integration story.
11. Use Raw SQL for Complex Aggregations
LINQ is excellent for CRUD. For complex aggregations, window functions, or WITH (CTE) expressions, raw SQL is often clearer and faster:
var monthlyRevenue = await context.Database
.SqlQuery<MonthlyRevenue>($@"
SELECT
DATEFROMPARTS(YEAR(CreatedAt), MONTH(CreatedAt), 1) AS Month,
SUM(Total) AS Revenue,
COUNT(*) AS OrderCount
FROM Orders
WHERE Status = 'Completed' AND CreatedAt >= {since}
GROUP BY DATEFROMPARTS(YEAR(CreatedAt), MONTH(CreatedAt), 1)
ORDER BY Month")
.ToListAsync();
$@"..." is interpolated as a parameterized query — safe against SQL injection. Don't concatenate strings into queries.
12. Query Store + Plan Forcing for Worst-Case Stability
SQL Server 2022's Query Store records execution plans and lets you force a specific plan if SQL Server's optimizer picks a bad one (the classic "parameter sniffing" problem).
Enable Query Store on your database:
ALTER DATABASE [YourDb] SET QUERY_STORE = ON
(
OPERATION_MODE = READ_WRITE,
INTERVAL_LENGTH_MINUTES = 15,
QUERY_CAPTURE_MODE = ALL
);
Then in SSMS: right-click the database → Reports → Query Store → Top Resource Consuming Queries. Find the regressed query, find the "good" plan, click "Force Plan". This is the production-safety net every database-heavy .NET app should have.
Every Adaptive plan ships with Query Store enabled by default on SQL Server 2022 databases — not available in Express, which most price-competitive hosts include.
Putting It All Together: A Production-Ready DbContext Config
builder.Services.AddDbContextPool<AppDbContext>(opt =>
{
opt.UseSqlServer(
builder.Configuration.GetConnectionString("Default"),
sqlOpts =>
{
sqlOpts.EnableRetryOnFailure(
maxRetryCount: 5,
maxRetryDelay: TimeSpan.FromSeconds(30),
errorNumbersToAdd: null);
sqlOpts.CommandTimeout(30);
});
opt.UseQueryTrackingBehavior(QueryTrackingBehavior.NoTracking);
opt.UseQuerySplittingBehavior(QuerySplittingBehavior.SplitQuery);
if (builder.Environment.IsDevelopment())
{
opt.EnableSensitiveDataLogging();
opt.EnableDetailedErrors();
}
});
Then opt back IN to tracking for write operations:
context.ChangeTracker.QueryTrackingBehavior = QueryTrackingBehavior.TrackAll;
var customer = await context.Customers.FindAsync(id);
customer!.LastSeenAt = DateTime.UtcNow;
await context.SaveChangesAsync();
What This Looks Like in Production
Three patterns we apply across customer applications on Adaptive's hosting:
| Pattern | Typical Latency Win | Where It Helps Most |
|---|---|---|
| AsNoTracking + projection | 30–50% on read queries | List endpoints, reports |
| AsSplitQuery for multi-Include | 50–80% on heavy graphs | Order detail, blog post with tags |
| ExecuteUpdate / ExecuteDelete | 99%+ on bulk operations | Background cleanup jobs |
| Query Store plan forcing | Eliminates regressions | Long-tail queries with bad plan caching |
| SQL Server 2022 JSON columns | 10–20× over JSON-as-string | Settings, metadata-heavy models |
| Composite indexes | Sub-millisecond on indexed paths | Filter + sort combinations |
Frequently Asked Questions
Should I use AsNoTracking on every query?
For read endpoints, yes — set it as the context default. For write paths, you need tracking (so EF Core can detect modifications). Don't blindly opt out; switch back in for the operations that update data.
When should I use raw SQL vs LINQ?
LINQ for 80% of CRUD. Raw SQL for complex aggregations, recursive queries (CTEs), window functions, or queries where you need precise control over the execution plan. EF Core's SqlQuery is type-safe and supports parameter interpolation.
Will EF Core 10 work with SQL Server Express?
The library will connect, but you'll be capped by Express's limits: 10 GB max database size, 1.4 GB RAM, single-CPU bottleneck. Vector search, JSON columns, ledger tables, and Query Store are all unavailable in Express. Every Adaptive plan includes full SQL Server 2022.
Does DbContext pooling work with scoped services?
Yes — AddDbContextPool registers the context as scoped, and pooled instances reset between requests. The catch: don't keep stateful fields on your DbContext (custom collections, cached values, etc.). Stick to plain DbSet definitions and EF Core handles the rest.
What's the difference between AsSplitQuery and SplitQuery default?
AsSplitQuery() per-query is the explicit form. UseQuerySplittingBehavior(QuerySplittingBehavior.SplitQuery) sets it as default — every multi-include query splits. Apply per-context if you frequently include multiple collections; explicit otherwise.
How do I know if my queries are slow?
EF Core 8+ logs slow queries automatically if LogTo is configured. In production, use SQL Server's Query Store (enabled on every Adaptive plan) — it captures real execution plans, runtime, and CPU. Three minutes in SSMS Query Store reports usually finds the worst offender.
Does Native AOT work with EF Core?
EF Core 8 added partial support. EF Core 9 and 10 improved coverage. As of 2026, common patterns (LINQ queries, migrations, DbSet operations) work under AOT. Reflection-heavy custom converters or value-converted properties may not. Validate in a side branch before committing to AOT for an EF-backed app.
title: Get EF Core Performance Out of a SQL Server That Can Actually Use It
description: Real SQL Server 2022 (not Express). Query Store, ledger tables, JSON columns, vector search — included on every plan. Plans from $9.49/mo.
cta-primary: Compare Hosting Plans | /asp-net-hosting-plans
cta-secondary: SQL Server Optimization Guide | /blog/sql-server-optimization-web-applications
Want to go deeper on the database side? Read our SQL Server 2022 optimization guide for the 14 SQL-level patterns that compound with these EF Core patterns. Or see our .NET Core 10 hosting comprehensive guide for the broader runtime story.