EF Core simplifies database access and management in .NET applications a lot. While EF Core makes database operations easier, there are common mistakes developers frequently make, even senior devs.
These mistakes can lead to performance and maintainability issues and bugs. Today I want to show you the top 10 mistakes developers make in EF Core and how to avoid them. Ensuring you get the best results when using EF Core.
1. Not Using Indexes
Mistake: Without proper indexing, EF Core queries can trigger unnecessary table scans, impacting the responsiveness of your application. Indexed queries are fast and table scans can result in a slower query performance.
Solution:
Identify columns frequently used in WHERE
clauses or for sorting and create indexes for them.
In EF Core, you can define these indexes using Fluent API configurations in your DbContext configuration.
csharppublic class Book { public long Id { get; set; } public string Title { get; set; } public string AuthorName { get; set; } public int PublishedYear { get; set; } public int NumberOfPages { get; set; } public DateTime CreatedAtUtc { get; set; } public DateTime? UpdatedAtUtc { get; set; } } public class BookConfiguration : IEntityTypeConfiguration<Book> { public void Configure(EntityTypeBuilder<Book> builder) { builder.ToTable("books"); builder.HasKey(x => x.Id); builder.HasIndex(x => x.Title); } } // Fast query that uses index on "Title" column var book = await context.Books .FirstOrDefaultAsync(b => b.Title == title, cancellationToken);
2. Not Using Projections
Mistake: Fetching the entire entity (including all columns and related data) when you only need a subset of fields - leads to unnecessary data transfer and increased memory usage.
csharp// Fetching ALL columns var book = await context.Books .Include(b => b.Author) .FirstOrDefaultAsync(b => b.Id == id, cancellationToken);
Solution:
Retrieve only the necessary fields rather than the entire entity using Projection with a Select
method in LINQ.
This reduces the overhead and makes your queries more efficient.
csharp// Fetching only needed columns var book = await context.Books .Where(b => b.Id == id) .Select(b => new BooksPreviewResponse { Title = b.Title, Author = b.Author.Name, Year = b.Year }) .FirstOrDefaultAsync(cancellationToken);
3. Overfetching Data
Mistake: Retrieving entire tables or large data sets without pagination can overload both memory and network, especially as the number of records grows.
csharp// Selecting all books (entire database) var allBooks = await context.Books .Include(b => b.Author) .ToListAsync();
Solution:
Use paging to select a fixed number of records, preventing from loading too many records into memory at once.
You can use traditional Offset-based pagination by using Skip
and Take
methods.
csharp// Use paging to select fixed number of records int pageSize = 50; int pageNumber = 1; var books = context.Books .AsNoTracking() .OrderBy(p => p.Title) .Skip((pageNumber - 1) * pageSize) .Take(pageSize) .ToList();
You can also use Cursor-based pagination. It involves using a "cursor" (often the value of a specific field or a special token) to determine where the next page of results should start. This approach can be more efficient and consistent than offset-based pagination as it prevents a database from iterating through rows that are skipped when using Offset-Based pagination.
csharpvar pageSize = 50; var lastId = 10024; // Obtained from the previous query // Fetch next set of results starting after the last fetched Id var books = await context.Books .AsNoTracking() .OrderBy(b => b.Title) .Where(b => b.Id > lastId) .Take(pageSize) .ToListAsync();
Also, consider the following when choosing a pagination type:
- when you need to go to the next and previous page only - you can use Cursor-Based Pagination
- when you need to access any page by its number - you can only use Offset-Based Pagination
4. Not Using AsNoTracking
Mistake: By default, EF Core tracks all entities to detect changes. For read-only operations, this tracking is unnecessary and adds performance overhead.
csharp// Selecting books and loading them to the Change Tracker var authors = await context.Books .Where(b => b.Year >= 2023) .ToListAsync();
Solution:
Use AsNoTracking
on read-only queries to disable change tracking, improving performance and reducing overhead.
csharp// Using AsNoTracking to prevent loading entities to // EF Core Change Tracker to improve memory usage var authors = await context.Books .AsNoTracking() .Where(b => b.Year >= 2023) .ToListAsync();
If needed, you can also make all queries non-trackable within DbContext, this can be useful for readonly DbContext scenarios.
5. Using Eager Loading Unwisely
Mistake:
Fetching related data when using Eager Loading can lead to performance issues if not used carefully.
Including unnecessary related data via eager loading by using Include
and ThenInclude
methods can cause excessive database joins and result in large result sets that hurt performance.
csharp// Include and ThenInclude are for eager loading, // they can lead to performance issues if not used carefully var book = await context.Books .AsNoTracking() .Include(b => b.Author) .ThenInclude(a => a.Publisher) .ToListAsync();
Solution:
Use eager loading wisely.
If possible, consider using filters in Include
and ThenInclude methods to limit data that is loaded together with the main entity.
csharp// You can use filters in Include and ThenInclude methods // to limit data that is loaded together with the main entity var authors = await context.Authors .Include(a => a.Books.Where(b => b.Year >= 2023)) .ToListAsync();
You can also use a SplitQuery to get related data in a separate query.
csharpvar book = await context.Books .AsNoTracking() .Include(b => b.Author) .ThenInclude(a => a.Publisher) .AsSplitQuery() .ToListAsync();
With .AsSplitQuery()
, EF Core will generate multiple queries: one for Book data, one for Author data, and one for Publisher data.
You should use this method with cautious, as it may decrease performance in a lot of scenarios when you don't have a big set of joined data.
6. Ignoring Transactions When Using Batch Operations
Mistake:
When using ExecuteUpdate
and ExecuteDelete
batch operations - they are not tracked in EF Core.
Batch operations like ExecuteUpdateAsync
and ExecuteDeleteAsync
aren't tracked by EF Core.
When a SaveChanges
method fails afterward, there is no automatic rollback for the batch operations that already happened.
csharpawait context.Books .Where(b => b.Id == update.BookId) .ExecuteUpdateAsync(b => b .SetProperty(book => book.Title, book => update.NewTitle) .SetProperty(book => book.UpdatedAtUtc, book => DateTime.UtcNow)); // Add new author await context.Authors.AddAsync(newAuthor); // Save changes await context.SaveChangesAsync(); // If Author creation fails, update operation to Books won't be reverted // as ExecuteUpdate and ExecuteDelete batch operations are not tracked in EF Core.
Solution: Wrap your batch operations and other changes in a transaction. Commit if all operations succeed; otherwise, rollback.
csharpusing var transaction = await _context.Database.BeginTransactionAsync(); try { await context.Books .Where(b => b.Id == update.BookId) .ExecuteUpdateAsync(b => b .SetProperty(book => book.Title, book => update.NewTitle) .SetProperty(book => book.UpdatedAtUtc, book => DateTime.UtcNow)); await context.Authors.AddAsync(newAuthor); await context.SaveChangesAsync(); await transaction.CommitAsync(); } catch (Exception) { // Now you can rollback both operations await transaction.RollbackAsync(); throw; }
7. Ignoring Concurrency Handling
Mistake: Multiple users might edit the same record simultaneously, overwriting each other's updates without any checks.
csharpvar book = await context.Books .FirstOrDefaultAsync(b => b.Id == update.BookId); // Update properties book.Title = update.NewTitle; book.Year = update.NewYear; book.UpdatedAtUtc = DateTime.UtcNow; // What if someone already updated this book - your or their changes may be replaced await _context.SaveChangesAsync();
Solution:
Implement concurrency tokens (e.g., a row version column) and handle DbUpdateConcurrencyException
to manage concurrent updates gracefully.
csharpmodelBuilder.Entity<Book>() .Property<byte[]>("Version") .IsRowVersion(); var book = await context.Books .FirstOrDefaultAsync(b => b.Id == update.BookId); book.Title = update.NewTitle; book.Year = update.NewYear; book.UpdatedAtUtc = DateTime.UtcNow; try { await _context.SaveChangesAsync(); } catch (DbUpdateConcurrencyException ex) {} // Decide what to do here: for example, show error to the user
If you catch a DbUpdateConcurrencyException
you can do the following:
- show error to the user
- decide whether to keep previous or current updated data
8. Not Using Migrations
Mistake: Manually modifying the database schema without using EF Core migrations can cause an application model and the database to become out of sync. This creates a potential for many application errors.
Solution: Use EF Core migrations to manage schema changes.
Recommended Steps:
- Use
Add-Migration
<Name> to create a migration based on model changes. - Use
Update-Database
to apply the migration to the database. - Keep your database schema versioned and consistent across environments.
Ways to execute migrations:
- You can execute migrations in code to update DB directly
- You can execute
Update-Database
command to update DB directly - You can export migrations to a SQL file and execute it manually in the database
9. Forgetting to Dispose Manually Created DbContext
Mistake:
Not disposing of DbContext
instances, especially when using IDbContextFactory
, can cause memory leaks and resource exhaustion.
csharpprivate readonly IDbContextFactory<ApplicationDbContext> _contextFactory; // π« BAD CODE: Memory Leak, DbContext is not disposed public async Task StartAsync(CancellationToken cancellationToken) { var context = await _contextFactory.CreateDbContextAsync(); var books = await context.Books.ToListAsync(); }
Solution:
Correctly wrap your manual DbContext
creation in a using statement for proper disposal.
csharpprivate readonly IDbContextFactory<ApplicationDbContext> _contextFactory; // β DbContext is correctly disposed public async Task StartAsync(CancellationToken cancellationToken) { using var context = await _contextFactory.CreateDbContextAsync(); var books = await context.Books.ToListAsync(); }
10. Not Using Asynchronous Methods
Mistake: Using synchronous methods for database operations can block threads and degrade application responsiveness.
csharp// π« The Mistake: Using synchronous methods for database operations, // which can block threads and degrade application responsiveness. var authors = context.Books .AsNoTracking() .Where(b => b.Year >= 2023) .ToList(); // ... context.SaveChanges();
Solution: Async methods make your application to handle more concurrent operations.
Prefer using asynchronous methods like ToListAsync
and SaveChangesAsync
to keep your application responsive and scalable.
csharp// β Async methods make your application to handle more concurrent operations var authors = await context.Books .AsNoTracking() .Where(b => b.Year >= 2023) .ToListAsync(); // ... await context.SaveChangesAsync();
BONUS Mistakes to Watch Out For
-
Saving Changes in the Loop: Calling
SaveChanges
in a loop can hurt performance, that results in sending requests to the database in each loop iteration. Instead, make updates in the loop and callSaveChanges
after the loop once, after all changes are made. -
Not Configuring Relationships Properly: Incorrect relationship mappings can lead to data inconsistencies and performance issues.
-
Ignoring Database Normalization: Poor schema design can cause data duplication, anomalies, and performance issues.
-
Ignoring Connection Pooling: Not taking advantage of connection pooling in high database access scenarios - can lead to performance degradation and resource exhaustion. And you could potentially face a "Max Connection Reached" error in the database.
Summary
Avoiding these common mistakes in EF Core can significantly improve your application's stability, performance, and scalability.
Keep these best practices in mind as you develop and maintain your .NET applications. Over time, these small optimizations add up, resulting in a better user experience and a more maintainable codebase.
As a result, you will be able to build efficient and reliable applications with database access.
Hope you find this blog post useful. Happy coding!