Skip to main content
Entity Framework Data Access

7 EF Core Data Access Mistakes That Slow Down Your App and How to Fix Them

Entity Framework Core is a productivity powerhouse — but it's also a performance trap if you treat it like a black box. Teams often ship an app that feels snappy in development, then watch response times balloon under real traffic. The culprit is almost never EF Core itself; it's how we ask it to fetch and save data. This guide identifies seven specific data access mistakes that slow down your app, explains the mechanics behind each one, and shows you how to fix them with clear, actionable steps. 1. The N+1 Query Problem: When Lazy Loading Becomes a Performance Sink One of the most common performance killers in EF Core is the N+1 query pattern. It happens when you load a parent entity and then iterate over its child navigation properties, triggering a separate database query for each child.

Entity Framework Core is a productivity powerhouse — but it's also a performance trap if you treat it like a black box. Teams often ship an app that feels snappy in development, then watch response times balloon under real traffic. The culprit is almost never EF Core itself; it's how we ask it to fetch and save data. This guide identifies seven specific data access mistakes that slow down your app, explains the mechanics behind each one, and shows you how to fix them with clear, actionable steps.

1. The N+1 Query Problem: When Lazy Loading Becomes a Performance Sink

One of the most common performance killers in EF Core is the N+1 query pattern. It happens when you load a parent entity and then iterate over its child navigation properties, triggering a separate database query for each child. For example, suppose you have a Blog entity with a collection of Post entities. If you write a loop like this, EF Core will execute one query to get all blogs, and then for each blog, another query to load its posts. With 100 blogs, that's 101 queries.

The fix is to use eager loading with the Include method or, better yet, projection with Select. Eager loading tells EF Core to join the related data in a single query. Projection goes further by only fetching the columns you actually need, which reduces payload size and often avoids the N+1 entirely. For read-only scenarios, projection is almost always the right choice because it also prevents unintended lazy loading in case you accidentally touch a navigation property later.

How to detect N+1

You can spot N+1 by enabling logging in your DbContext. Look for repeated identical SQL statements that differ only by a single ID parameter. Tools like MiniProfiler or the EF Core logging interceptors can highlight these patterns during development. Another telltale sign: your app seems fast on small datasets but slows down linearly as the number of parent rows grows.

Fix with explicit loading or batching

Sometimes you cannot avoid loading related data lazily due to business logic. In those cases, use explicit loading with Collection.LoadAsync or Reference.LoadAsync in a controlled batch. Alternatively, you can load all child data upfront with a second query and then rely on relationship fixup — EF Core automatically connects loaded entities via their foreign keys. This approach reduces round trips from N+1 to just 2 or 3 queries.

2. Tracking Too Many Entities: The Change Tracker Overhead

By default, EF Core tracks every entity it loads. This tracking enables features like automatic change detection and lazy loading, but it comes at a cost. When you load hundreds or thousands of entities for a read-only operation, the change tracker consumes memory and CPU cycles to maintain snapshots and detect changes. Over time, this overhead accumulates and can cause significant slowdowns, especially in high-throughput scenarios.

The fix is straightforward: use AsNoTracking() for read-only queries. This tells EF Core to skip change tracking, reducing memory usage and speeding up query execution. For bulk reads, consider setting the default query tracking behavior at the context level via ChangeTracker.QueryTrackingBehavior = QueryTrackingBehavior.NoTracking. However, be careful — if you later need to update those entities, you must explicitly attach them and set their state.

Trade-offs and when to track

Change tracking is essential when you plan to update entities and save changes in the same unit of work. For example, in a web API endpoint that loads an entity, modifies a property, and calls SaveChangesAsync, tracking is necessary. But if you're only reading data to display on a dashboard, disable tracking. The performance difference can be dramatic: a query that takes 200ms with tracking might drop to 50ms without it, simply because the tracker isn't building snapshots of every row.

Batch operations and the tracker

When performing bulk inserts or updates, the change tracker can become a bottleneck because it holds references to every modified entity. For operations involving hundreds of rows, consider using the ExecuteUpdate and ExecuteDelete methods introduced in EF Core 7. These execute raw SQL without loading entities into memory, bypassing the tracker entirely. For inserts, use AddRange with SaveChanges in batches of 100–200 to keep tracker memory under control.

3. Missing or Inefficient Database Indexes

EF Core generates SQL queries, but it cannot create indexes for you. If your queries filter on columns that lack an index, the database performs a full table scan — reading every row to find matches. This mistake often goes unnoticed until the table grows beyond a few thousand rows. The fix is to analyze the SQL queries EF Core produces and create indexes on columns used in WHERE, ORDER BY, and JOIN clauses.

You can use EF Core's migration tools to add indexes via the HasIndex method in your OnModelCreating override. For example: modelBuilder.Entity<Order>().HasIndex(o => o.CustomerId);. But don't over-index — each index adds overhead on writes. Focus on the queries that matter most: the ones executed frequently or against large datasets.

Composite and covering indexes

For queries that filter on multiple columns, a composite index can be more efficient than separate single-column indexes. For example, if you often look up orders by CustomerId and OrderDate, create a composite index on both columns. Additionally, covering indexes that include all columns referenced in a query can eliminate the need to access the table data at all, speeding up reads dramatically. Use database profiling tools (like SQL Server Management Studio's execution plan) to see which indexes EF Core's queries would benefit from.

Indexing for ordering and aggregation

If you frequently order by a column or use aggregate functions like COUNT, SUM, or GROUP BY, indexes can help the database avoid sorting and scanning. For example, an index on (CategoryId, CreatedAt) can speed up a query that groups by category and orders by creation date. However, be mindful of the index's key column order — put the most selective column first.

4. Selecting All Columns When You Only Need a Few

It's tempting to write context.Orders.ToListAsync() and then map to a DTO in memory. But this pulls every column from the Orders table — including large text fields, binary data, or columns you don't need. This wastes bandwidth, memory, and CPU. The fix is to project only the required columns using Select before executing the query. For example: context.Orders.Select(o => new OrderSummary { Id = o.Id, Total = o.Total }).ToListAsync().

Projection has an added benefit: it often eliminates the need for Include because you can flatten related data in the same Select. For instance, to get order summaries with customer names, you can write context.Orders.Select(o => new { o.Id, CustomerName = o.Customer.Name }). EF Core translates this into a JOIN that returns only the columns you specified.

AutoMapper and projection

If you use AutoMapper, you can leverage its ProjectTo method to automatically generate Select expressions based on your DTO mappings. This reduces boilerplate and ensures you only fetch needed columns. However, be careful with complex projections that involve nested collections — they can still cause N+1 if not configured correctly. Always test the generated SQL with logging.

When to select the whole entity

There are cases where you need all columns, such as when you plan to update the entity and need its original values for concurrency checks. In those scenarios, pulling the full row is necessary. But for read-only displays, always project. Even if the table has only a few columns, projection makes your intent explicit and guards against future schema changes that add large columns.

5. Not Using Batching for Multiple SaveChanges Calls

Each call to SaveChangesAsync starts a database transaction and sends a round trip to the server. If you insert, update, or delete multiple entities in a loop, calling SaveChanges after each operation, you incur a huge overhead. The fix is to batch all changes into a single SaveChanges call. EF Core automatically groups multiple insert/update/delete operations into a single transaction, so you only pay the round-trip cost once.

For example, instead of looping and saving each order line item individually, add all items to the context and call SaveChanges once. EF Core will generate a single batch of SQL commands. In EF Core 7 and later, you can also use ExecuteUpdate and ExecuteDelete for bulk operations without loading entities, which is even faster because it avoids the change tracker entirely.

Transaction scope and batching

If you need to split work across multiple SaveChanges calls (e.g., to avoid long-running transactions), use explicit transaction management with Database.BeginTransaction. This allows you to batch related changes together and commit in stages. However, be aware that each commit still involves a round trip. The goal is to minimize the number of round trips while keeping transactions short enough to avoid lock contention.

Batching in high-concurrency scenarios

When multiple users are inserting data simultaneously, batching can improve throughput but also increase lock duration. Monitor for deadlocks and consider using smaller batches (e.g., 100 rows per save) to reduce contention. You can also use the SqlBulkCopy approach for very large imports, though that bypasses EF Core's change tracking and requires manual handling of relationships.

6. Using Generic Queries Without Compiled Query Optimization

Every time EF Core executes a LINQ query, it compiles the expression tree into SQL. This compilation has a cost, especially for complex queries. If the same query is executed repeatedly with different parameters, the compilation overhead can add up. The fix is to use compiled queries via EF.CompileQuery or EF.CompileAsyncQuery. These cache the compiled SQL plan and reuse it, skipping the compilation step on subsequent calls.

Compiled queries are particularly beneficial for hot paths — for example, looking up a user by ID or fetching an order by number. The improvement is most noticeable when the query is complex (multiple joins, subqueries) and executed hundreds of times per second. In benchmarks, compiled queries can be 2–5x faster than the equivalent non-compiled query for such scenarios.

How to implement compiled queries

You define a compiled query as a static field or property that takes a DbContext and parameters. For example: private static readonly Func<AppContext, int, Task<Order>> GetOrderById = EF.CompileAsyncQuery((AppContext ctx, int id) => ctx.Orders.FirstOrDefault(o => o.Id == id));. Then call it like var order = await GetOrderById(context, orderId);. Note that compiled queries cannot use Include directly; you must include navigation properties in the query expression.

When not to use compiled queries

Compiled queries are less flexible — you cannot change the query structure at runtime. If your query varies significantly (different filters, ordering), compilation might not help because each variation requires a separate compiled query. In those cases, the overhead of managing many compiled queries may outweigh the benefit. Stick to compiled queries for stable, high-frequency queries and use regular LINQ for ad-hoc queries.

7. Ignoring Asynchronous Programming and Connection Pooling

Synchronous database calls block the calling thread, which reduces scalability, especially in web applications. If you call .ToList() instead of .ToListAsync(), you tie up a thread pool thread while waiting for the database. Under load, this can lead to thread pool starvation and increased latency. The fix is to always use the async versions of EF Core methods: ToListAsync, FirstOrDefaultAsync, SaveChangesAsync, etc.

Another related mistake is not properly managing connection pooling. EF Core uses ADO.NET's connection pool, which is efficient by default. However, if you create and dispose many DbContext instances rapidly, you can exhaust the connection pool, causing delays. The best practice is to use a DbContext per unit of work (e.g., per HTTP request) and rely on dependency injection to manage its lifetime. Avoid holding a DbContext open for long periods or creating them in loops without disposal.

Async all the way

Make sure your async calls are awaited properly. A common anti-pattern is using .Result or .Wait() on async tasks, which can cause deadlocks in UI or ASP.NET contexts. If you must call async code from a synchronous method, consider using GetAwaiter().GetResult() only in console apps or test code, and never in web applications. For libraries, provide both sync and async methods if possible, but prefer async for new code.

Connection pool tuning

If your application experiences connection timeouts or pool exhaustion, you can adjust the connection string settings: Max Pool Size (default 100) and Min Pool Size (default 0). Setting a minimum pool size ensures a number of connections are always ready, reducing initial latency. However, be careful not to set it too high, as idle connections consume server resources. Monitor your pool usage with performance counters to find the right balance.

Avoiding these seven mistakes will dramatically improve your EF Core application's performance. Start by profiling your current queries, then apply the fixes iteratively. The key is to treat data access as a performance-critical layer — not an afterthought. With these patterns in place, you can keep your app fast, even as it scales.

Share this article:

Comments (0)

No comments yet. Be the first to comment!