Entity Framework Core makes data access in .NET wonderfully productive: you write LINQ, and EF Core generates SQL. But that abstraction can hide expensive mistakes - a stray query per row, a context that lives too long, or tracking you never needed. The difference between a snappy data layer and a sluggish one usually comes down to a handful of habits. Here are the ones that matter most.
Keep the DbContext short-lived and scoped
The single most important rule: a DbContext is a unit of work, not a shared service. It is not thread-safe and it accumulates tracked entities, so it must be short-lived. In an ASP.NET Core app, AddDbContext registers it as scoped - one instance per request - which is exactly right. Never make it a singleton, never share one instance across parallel tasks, and dispose it promptly. A long-lived context leaks memory and produces baffling concurrency exceptions.
Use AsNoTracking for read-only queries
By default EF Core tracks every entity it returns so it can detect changes on SaveChanges. That bookkeeping is pure overhead when you’re only reading. For any query whose results you won’t modify, add AsNoTracking() to skip the change tracker - it uses less memory and runs faster. Reserve tracking for the entities you genuinely intend to update.
// Read-only list endpoint: no tracking needed
var products = await _db.Products
.AsNoTracking()
.Where(p => p.IsActive)
.OrderBy(p => p.Name)
.ToListAsync();
Avoid the N+1 query problem
The classic EF Core trap: you fetch a list of orders, then loop over them touching order.Customer, and EF Core quietly fires one extra query per order. Ten orders become eleven queries; a thousand become a thousand and one. This N+1 problem is a leading cause of slow data layers.
The fix is to load related data deliberately. Use Include to eager-load navigations in the same round-trip, or - better still for read paths - project only the columns you need (see the next section). Just be careful not to swing the other way and Include huge object graphs you never use.
Project to DTOs with Select
Loading whole entities when you only need three fields wastes bandwidth, memory and tracking. Instead, project straight into a DTO with Select. EF Core translates it into a single query that pulls exactly those columns, sidesteps the N+1 problem for related data, and never needs tracking:
var summaries = await _db.Orders
.Where(o => o.CustomerId == customerId)
.Select(o => new OrderSummaryDto
{
Id = o.Id,
Product = o.Product,
Total = o.Quantity * o.UnitPrice,
CustomerName = o.Customer.Name // joined in one query, no N+1
})
.AsNoTracking()
.ToListAsync();
Always use the async methods
Database calls are I/O, and blocking a thread while you wait for the database throttles your app’s throughput. Use the async APIs - ToListAsync, FirstOrDefaultAsync, SaveChangesAsync - and await them all the way up the call stack. This keeps threads free to serve other requests, which is exactly what makes an ASP.NET Core service scale under load.
Prefer server-side evaluation
EF Core is happiest when your entire query runs in the database. Trouble starts when part of the expression can’t be translated to SQL and EF Core either throws or, worse in older patterns, silently pulls rows into memory to finish the work (client-side evaluation). Keep Where, OrderBy and aggregates expressible in SQL, and don’t call ToList() too early - materialising a table and then filtering in C# defeats the whole point of a database.
Use migrations and configure with the Fluent API
Manage your schema with EF Core migrations rather than hand-edited SQL. Each change to your model becomes a versioned, reviewable migration you can apply consistently across environments. For the model itself, prefer the Fluent API in OnModelCreating over scattered attributes - it’s where you define keys, relationships, column types and, importantly, indexes on the columns you filter and join by. A missing index is one of the most common causes of a slow query.
protected override void OnModelCreating(ModelBuilder builder)
{
builder.Entity<Order>(e =>
{
e.HasKey(o => o.Id);
e.Property(o => o.Product).HasMaxLength(200).IsRequired();
e.HasIndex(o => o.CustomerId); // speeds up lookups by customer
e.HasOne(o => o.Customer)
.WithMany(c => c.Orders)
.HasForeignKey(o => o.CustomerId);
});
}
Split queries, retries, lazy loading and hot paths
A few more techniques pay off as an app grows:
- Split queries. A single query with several collection
Includes can explode into a huge Cartesian result.AsSplitQuery()tells EF Core to run separate queries and stitch them together, avoiding row multiplication. - Connection resiliency. Enable
EnableRetryOnFailure()so transient network or database blips are retried automatically - essential against cloud databases like Azure SQL. - Be cautious with lazy loading. It’s convenient but a silent N+1 generator; prefer explicit
Includeor projections so every query is intentional. - Compiled queries for hot paths. For a query executed thousands of times,
EF.CompileAsyncQuerycaches the translation and shaves off per-call overhead. - DbContext pooling.
AddDbContextPoolreuses context instances instead of allocating a new one per request, reducing GC pressure on high-throughput services.
Common mistakes to avoid
- A long-lived or singleton DbContext. The root of most EF Core concurrency and memory bugs.
- Tracking everything. Forgetting
AsNoTrackingon read-heavy endpoints wastes memory at scale. - Ignoring generated SQL. Not logging or inspecting the SQL EF Core produces hides N+1 and missing-index problems until production.
- Calling
SaveChangesin a loop. Batch your changes and save once per unit of work instead of hitting the database on every iteration.
Best practices checklist
- Scope the DbContext to a request and dispose it promptly.
- Add
AsNoTrackingto every read-only query. - Project to DTOs with
Selectand eager-load withIncludeto kill N+1. - Go async everywhere and keep queries server-evaluated.
- Use migrations, Fluent API indexes, split queries and retries as your data grows.
A well-tuned EF Core layer is the quiet foundation of a fast API. If you’d like a team that gets these details right from the start, our .NET development service builds data layers and APIs together. And since your data is only as safe as the endpoints in front of it, pair this with our guide to building secure REST APIs with ASP.NET Core.



