Skip to main content

Command Palette

Search for a command to run...

7 Common EF Core Mistakes in ASP.NET Core (And How to Fix Them)

Spot and fix the most common EF Core mistakes before they hit production

Updated
β€’12 min read
7 Common EF Core Mistakes in ASP.NET Core (And How to Fix Them)

Entity Framework Core is the ORM of choice for most ASP.NET Core teams β€” and for good reason. It handles the heavy lifting of database access, migrations, and query generation. But EF Core mistakes in ASP.NET Core applications are remarkably common, and many of them don't surface until production load exposes them as slow queries, unexpected memory growth, or incorrect data. Every developer has made at least a few of these; the goal here is to surface them clearly so you can audit your current codebase and fix them before they become incidents.

If you want to see these patterns applied inside a complete, production-ready ASP.NET Core API β€” with all the edge cases wired together β€” the full implementation is available on Patreon, where members get annotated source code that maps directly to what enterprise teams actually ship.

Understanding which EF Core mistakes to avoid is valuable β€” but seeing how they fit inside a full production codebase makes the difference. Chapter 3 of the Zero to Production course covers EF Core beyond the basics β€” domain entity design, repository patterns, performance pitfalls, and more β€” all inside a real API you can run immediately.

ASP.NET Core Web API: Zero to Production


Mistake 1: Triggering the N+1 Query Problem Without Realising It

The N+1 query problem is one of the most damaging EF Core mistakes in ASP.NET Core applications. It happens when code loads a collection of entities and then accesses a navigation property on each one inside a loop β€” resulting in one query to fetch the collection and N additional queries, one per entity, to resolve the related data.

The symptom is subtle in development: everything works, response times look acceptable. In production under real data volumes, the database log fills up with hundreds of near-identical queries fired in rapid succession, and request latency climbs dramatically.

How to Fix It

Use Include() and ThenInclude() to load related data as part of the initial query. If you only need a subset of related fields, use .Select() projection instead β€” this is often better than Include() because it avoids loading columns you don't need.

For complex many-to-many or deep navigation scenarios, consider AsSplitQuery() to avoid the cartesian explosion that Include() can cause on multi-level graphs. The EF Core 10 query performance guide on Coding Droplets goes deeper on split queries vs. joined queries and when each strategy applies.

The diagnostic step matters as much as the fix. Enable SQL query logging in development and review every query your endpoints generate. EF Core's EnableSensitiveDataLogging() combined with a console or file logger will show you the exact SQL being generated β€” this makes N+1 problems immediately visible.


Mistake 2: Skipping AsNoTracking on Read-Only Queries

EF Core tracks every entity it loads by default. The change tracker maintains an in-memory snapshot of each loaded object so it can detect modifications and generate the correct UPDATE statements on SaveChangesAsync(). This is essential for write paths β€” but it is pure overhead for read-only queries.

On endpoints that only read data and return a response, the change tracker adds unnecessary memory pressure and CPU overhead. For high-throughput APIs with frequent read queries, this overhead compounds quickly.

How to Fix It

Add .AsNoTracking() to every query that does not need to write back changes. This tells EF Core to skip the tracking snapshot entirely, reducing both memory usage and query execution time.

For read-heavy DbContext configurations, use UseQueryTrackingBehavior(QueryTrackingBehavior.NoTracking) at the DbContextOptions level to make AsNoTracking the default, then opt back in with .AsTracking() only on queries where you intend to modify the loaded entities.

The rule of thumb: if the query result goes directly to a DTO or response object and is never modified, it should always be AsNoTracking.


Mistake 3: Registering DbContext with the Wrong Lifetime

DbContext is designed to be scoped β€” it holds a connection, tracks state, and is not thread-safe. Registering it as a singleton is a serious bug.

A singleton DbContext is shared across all requests simultaneously. Multiple concurrent requests will attempt to use the same instance, causing thread-safety violations, stale cached query results, and data corruption in the change tracker. This mistake is particularly insidious because it often works fine under low load in development but fails unpredictably under concurrency.

How to Fix It

Always use AddDbContext<T>() which registers the context with scoped lifetime by default. Never call AddSingleton<AppDbContext>() or AddTransient<AppDbContext>() β€” scoped is the correct and only appropriate lifetime.

If you need to use a DbContext from a singleton service (such as a background service or a long-lived cache warmer), do not inject DbContext directly. Instead, inject IServiceScopeFactory and create an explicit scope per operation to resolve a fresh scoped DbContext for each unit of work. This is exactly the pattern covered in the EF Core connection pool exhaustion post on Coding Droplets.


Mistake 4: Loading More Data Than the Query Needs

Over-fetching is one of the quietest EF Core mistakes β€” it doesn't crash anything, it just makes everything slower and more memory-intensive than it should be. It happens when code loads a full entity (all columns) when only a few fields are needed, or when it loads an entire collection when the use case only needs an aggregate count or a subset.

Over-fetching across high-traffic endpoints puts unnecessary load on both the database and the application server. It inflates network payloads between the database and the API, and increases garbage collection pressure in the .NET process.

How to Fix It

Use .Select() to project only the fields you need. This generates SQL with a targeted column list instead of SELECT *, reduces the data transferred over the wire, and produces smaller objects in memory.

When the result is destined for a DTO, project directly to the DTO type inside the query expression. This avoids materializing the full entity in memory only to discard most of its fields a line later.

The discipline of writing projection queries rather than entity queries is one of the highest-leverage habits in EF Core performance work. Applied consistently across a codebase, it can meaningfully reduce database CPU, network I/O, and application memory.


Mistake 5: Misusing Lazy Loading in Production

Lazy loading is a feature, not a default β€” but many developers enable it and then forget about the hidden queries it generates. With lazy loading enabled, any access to a navigation property that hasn't been explicitly loaded triggers a new database query at that point in code execution.

The problem is that lazy loading queries are invisible. They don't appear in the explicit query path β€” they happen on property access, often inside mapping code, serialisation pipelines, or response projection logic that runs after the initial query. By the time a slow endpoint is investigated, the developer may not realise that accessing order.Customer.Address inside a loop is generating a query per iteration.

How to Fix It

For most production ASP.NET Core APIs, lazy loading should be disabled (it is off by default β€” don't enable it). Use explicit loading via Include() and ThenInclude() where you know related data is needed, and use projection via .Select() where you know only specific fields from related entities are required.

If you must work with lazy loading for specific scenarios, isolate it carefully and never use it in code that iterates over collections. The danger zone is always "load a list, then access a navigation property on each item" β€” that pattern will generate N+1 queries regardless of whether they are explicit or lazy.


Mistake 6: Not Using FindAsync for Primary Key Lookups

A common pattern in controller and service code is to use FirstOrDefaultAsync(x => x.Id == id) when looking up a single entity by its primary key. This works correctly β€” but it bypasses the DbContext's identity cache.

EF Core maintains an in-memory cache of tracked entities within a request scope (the identity map). FindAsync() checks this cache first before going to the database. If the entity was already loaded earlier in the same request, FindAsync() returns the cached instance without any database round-trip. FirstOrDefaultAsync() always hits the database.

How to Fix It

Use FindAsync(id) for primary key lookups within a scoped DbContext where the entity may have already been loaded. Reserve FirstOrDefaultAsync() for queries that filter on non-key columns, involve multiple conditions, or need to be combined with Include() or AsNoTracking() (since FindAsync doesn't support fluent chaining).

This distinction is small in isolation, but in service-layer code that performs multiple lookups within a single request scope, it can eliminate several redundant database queries.


Mistake 7: Calling SaveChangesAsync Inside a Loop

Calling SaveChangesAsync() inside a loop is a performance anti-pattern that turns what could be a single transaction into N separate database round-trips. Each SaveChangesAsync() call opens a transaction, flushes pending changes, commits, and returns. Inside a loop processing hundreds of items, this generates enormous database load.

This mistake often appears in data import logic, batch processing endpoints, or event-driven handlers that process items one by one and persist each immediately.

How to Fix It

Accumulate all changes first, then call SaveChangesAsync() once after the loop completes. EF Core batches all pending inserts, updates, and deletes into a single transaction, which is dramatically more efficient than individual per-item round-trips.

For very large batches (thousands of rows), the standard SaveChangesAsync() batching may still be too slow. In those scenarios, use ExecuteUpdate() and ExecuteDelete() for set-based updates that don't require loading entities first, or consider a bulk extension library for high-volume inserts. The trade-offs between these approaches are covered in the EF Core bulk operations guide on Coding Droplets.


How to Audit Your Codebase for These Mistakes

Run through this checklist against your current ASP.NET Core application:

  • Enable SQL query logging in development and review the output of your most-used endpoints
  • Search for FirstOrDefaultAsync(x => x.Id == β€” assess whether FindAsync would be more appropriate
  • Search for .Include() chains β€” verify they use AsNoTracking() on read-only paths
  • Search for SaveChangesAsync() inside any loop body β€” each instance is a candidate for batching
  • Review any DbContext registration that isn't AddDbContext<T>() β€” these should not exist
  • Confirm that lazy loading is disabled unless you have a specific, isolated reason to use it

Most EF Core performance problems in production are self-inflicted. The framework is capable of excellent performance β€” but it requires deliberate query design rather than the "it works, ship it" approach that most tutorial code demonstrates.

β˜• Prefer a one-time tip? Buy us a coffee β€” every bit helps keep the content coming!


FAQ

What is the most common EF Core mistake that causes production performance issues?

The N+1 query problem is the most frequently encountered EF Core mistake in production ASP.NET Core applications. It occurs when a query loads a collection and then accesses navigation properties on each item in a loop, generating one database query per item rather than a single joined query. The fix is to use Include() or .Select() projection to load related data upfront.

Why does missing AsNoTracking cause performance problems in ASP.NET Core?

Without AsNoTracking(), EF Core creates a change tracker snapshot for every entity it loads. For read-only queries, this snapshot is never used but still consumes memory and CPU time to build and maintain. On high-read endpoints or queries that return large result sets, the overhead accumulates and degrades throughput.

Can I register DbContext as Singleton in ASP.NET Core?

No. DbContext is not thread-safe and is designed for scoped lifetime (one instance per HTTP request). Registering it as a singleton causes concurrent request conflicts, stale data in the change tracker, and potential data corruption. Always use AddDbContext<T>() which defaults to scoped, and use IServiceScopeFactory when you need a DbContext inside a singleton service.

What is the difference between FindAsync and FirstOrDefaultAsync in EF Core?

FindAsync(id) performs a primary key lookup and checks the DbContext's in-memory identity cache before going to the database β€” meaning it can return a cached entity without any database round-trip. FirstOrDefaultAsync() always executes a database query. Use FindAsync for primary key lookups within a request scope; use FirstOrDefaultAsync for queries involving non-key columns, conditions, or projections.

Is lazy loading safe to use in ASP.NET Core production APIs?

Lazy loading is off by default in EF Core and should generally remain disabled in production APIs. When enabled, accessing a navigation property triggers an implicit database query at that moment in code β€” inside loops, mapping code, or serialisation pipelines, this silently generates N+1 query patterns that are difficult to detect. Prefer explicit loading with Include() and projection with .Select() for predictable, auditable query behaviour.

How do I detect EF Core N+1 query problems in my application?

Enable EF Core query logging in development using LogLevel.Information or LogLevel.Debug for the Microsoft.EntityFrameworkCore.Database.Command category. Review the SQL output for endpoints that handle collections β€” if you see the same query repeated once per item in a collection, you have an N+1 problem. Tools like MiniProfiler, SQL Server Profiler, and Application Insights query tracking can surface the same issues in staging environments.

When should I call SaveChangesAsync multiple times vs once?

Call SaveChangesAsync() once after accumulating all changes within a unit of work β€” not once per item inside a loop. EF Core batches all pending changes into a single database transaction when you call it once, which is far more efficient than individual per-item commits. The exception is when you need fine-grained error handling per item, in which case explicit transaction management with BeginTransactionAsync() gives you control without the NΓ—round-trip cost.