Writing Efficient, Maintainable EF Core Code
Following EF Core best practices separates applications that stay fast and maintainable as they grow from those that quietly degrade under load. The most common failures are not exotic bugs but repeated mistakes: tracking read-only data unnecessarily, loading entire entity graphs when only a few columns are needed, sharing a DbContext across requests, and letting migrations drift from the real schema. Each practice below targets one of these recurring failure modes with a concrete, testable habit rather than a vague guideline.
Cricket analogy: Like Rahul Dravid grinding out a Test innings by cutting out risky shots rather than chasing one big cover drive, EF Core performance comes from removing dozens of small unnecessary reads, not one heroic optimization.
Query Performance: Tracking, Projection, and Includes
For read-only scenarios, call AsNoTracking() so EF Core skips creating change-tracking snapshots, which reduces memory pressure and CPU work significantly on large result sets. Combine this with Select() projections that pull back only the columns a view actually needs instead of materializing full entities with every navigation property. When you do need related data, use Include() and ThenInclude() deliberately and measure the generated SQL — unbounded eager loading across several one-to-many relationships produces a cartesian explosion that can multiply row counts and slow queries dramatically.
Cricket analogy: A commentator pulling only the strike rate and boundary count for a scorecard graphic, instead of every ball-by-ball statistic since 1877, is like a Select() projection — fetch just what the screen needs, nothing more.
// Read-only listing: no tracking, project only what the view needs
var orderSummaries = await dbContext.Orders
.AsNoTracking()
.Where(o => o.CustomerId == customerId)
.OrderByDescending(o => o.PlacedAt)
.Select(o => new OrderSummaryDto
{
OrderId = o.Id,
Total = o.Total,
Status = o.Status.ToString(),
ItemCount = o.Items.Count
})
.ToListAsync();
// Deliberate eager loading when you truly need the graph
var order = await dbContext.Orders
.Include(o => o.Items)
.ThenInclude(i => i.Product)
.FirstOrDefaultAsync(o => o.Id == orderId);DbContext Lifetime and Thread Safety
DbContext is intentionally lightweight and cheap to create, and it is designed to be short-lived — typically scoped to a single web request via dependency injection's AddDbContext, or to a single unit of work in a background job. It is not thread-safe: two operations running concurrently against the same context instance will throw InvalidOperationException about a second operation being started before the first completed. Never cache a DbContext as a singleton or static field to 'save allocations' — that habit reintroduces stale-tracking bugs and concurrency exceptions that are hard to diagnose in production.
Cricket analogy: A fresh ball is used for each new spell rather than reusing a scuffed one from three overs ago; a DbContext should likewise be fresh per request rather than an old, state-laden instance reused across unrelated work.
Sharing one DbContext instance across multiple concurrent threads is one of the most common causes of 'A second operation was started on this context before a previous operation completed' exceptions. Always resolve DbContext through DI with its default scoped lifetime, and never store it in a static field or inject it into a singleton service.
Migrations and Change Tracking Discipline
Treat migrations as reviewable artifacts: run dotnet ef migrations add with a descriptive name, then actually read the generated Up() and Down() methods before committing, because EF Core's migration scaffolding can generate destructive column drops or data-losing type changes that need manual adjustment. In production, prefer generating an idempotent SQL script with dotnet ef migrations script --idempotent and applying it through your deployment pipeline rather than calling Database.Migrate() automatically at application startup, which can race across multiple instances and apply schema changes at an unpredictable moment. On the SaveChanges side, batch related changes into a single SaveChangesAsync() call inside a logical unit of work instead of calling it after every individual property change, and wrap multi-step operations in an explicit transaction when they must succeed or fail together.
Cricket analogy: A team reviews and signs off on team selection and batting order before the toss rather than changing it mid-innings; reviewing a migration's Up/Down methods before deployment is the same pre-match discipline applied to schema changes.
Database.EnsureCreated() is convenient for quick prototypes and in-memory tests, but it bypasses the migrations history table entirely and cannot be mixed with dotnet ef migrations. If you call EnsureCreated() on a database that later needs migrations, EF Core will not know which migrations have 'already' been applied, leading to confusing schema drift.
- Use AsNoTracking() for read-only queries to skip change-tracking overhead.
- Project with Select() into DTOs instead of materializing full entity graphs you don't need.
- Use Include()/ThenInclude() deliberately and check generated SQL to avoid cartesian explosions.
- Keep DbContext scoped and short-lived; never share one instance across threads or store it as a singleton.
- Review generated migrations before applying them, and prefer idempotent SQL scripts over automatic Database.Migrate() at startup.
- Batch related changes into one SaveChangesAsync() call and use explicit transactions for multi-step operations.
Practice what you learned
1. What is the primary benefit of calling AsNoTracking() on a read-only query?
2. Why can chaining multiple Include/ThenInclude calls across sibling collections be dangerous?
3. What is the recommended lifetime for a DbContext in a typical ASP.NET Core web app?
4. What problem can occur if a DbContext instance is shared across concurrent threads?
5. Why is calling Database.Migrate() automatically at application startup risky in production?
Was this page helpful?
You May Also Like
EF Core vs Dapper
A comparison of EF Core's full ORM feature set against Dapper's lightweight micro-ORM approach, and how to choose or combine them.
Unit Testing with the In-Memory Provider
How to use EF Core's InMemory provider and SQLite in-memory mode to test data access code, and where each approach falls short.
EF Core Quick Reference
A condensed reference of the EF Core CLI commands, LINQ query patterns, and configuration APIs you reach for most often.
Related Reading
Related Study Notes in Microsoft Technologies
Browse all study notesWindows 10 / UWP Development Study Notes
.NET · 30 topics
Microsoft TechnologiesWindows Batch Scripting Study Notes
Batch · 30 topics
Microsoft TechnologiesMFC (Microsoft Foundation Classes) Study Notes
C++ · 30 topics
Microsoft TechnologiesSilverlight Study Notes
.NET · 30 topics
Microsoft TechnologiesXAML Study Notes
.NET · 30 topics
Microsoft TechnologiesWPF (Windows Presentation Foundation) Study Notes
.NET · 30 topics