AL.
🇪🇸 ES
Back to blog
.NET & C# · 10 min read

6 Entity Framework Core Performance Pitfalls (and How to Fix Them)

Avoid these 6 common Entity Framework Core performance pitfalls in .NET — with real-world scenarios, root causes, and refactors.


Entity Framework Core makes database access feel effortless. Write some C# code, and EF handles the SQL. But this convenience comes with a trap: it’s easy to write code that looks fine but performs terribly at scale.

You don’t notice when your table has 100 rows. When it has 100,000 rows, your API starts timing out.

In this article, we’ll explore six common EF Core performance pitfalls that I see in code reviews regularly. For each, I’ll show the problem code, explain why it’s slow, and provide a fix with measurable improvements.

Pitfall 1: N+1 Queries (The Lazy Loading Trap)

The Problem

[HttpGet]
public async Task<ActionResult<List<OrderDto>>> GetOrders()
{
    var orders = await _context.Orders.ToListAsync();

    var orderDtos = orders.Select(o => new OrderDto
    {
        Id = o.Id,
        CustomerName = o.Customer.Name, // N+1 query!
        Items = o.Items.Select(i => new OrderItemDto // Another N+1!
        {
            ProductName = i.Product.Name,
            Quantity = i.Quantity
        }).ToList()
    }).ToList();

    return Ok(orderDtos);
}

If you have 50 orders with 10 items each:

  • 1 query for orders
  • 50 queries for customers (one per order)
  • 50 queries for items (one per order)
  • 500 queries for products (one per item)

Total: 601 queries

Why It’s Slow

Each query has overhead:

  • Network round-trip to database
  • Query parsing and execution
  • Result marshalling

Even on a fast database, 601 round-trips add up. On a high-latency connection, this is a disaster.

The Fix: Eager Loading with Include

[HttpGet]
public async Task<ActionResult<List<OrderDto>>> GetOrders()
{
    var orders = await _context.Orders
        .Include(o => o.Customer)
        .Include(o => o.Items)
            .ThenInclude(i => i.Product)
        .AsNoTracking() // Bonus optimization (see Pitfall 2)
        .ToListAsync();

    var orderDtos = orders.Select(o => new OrderDto
    {
        Id = o.Id,
        CustomerName = o.Customer.Name,
        Items = o.Items.Select(i => new OrderItemDto
        {
            ProductName = i.Product.Name,
            Quantity = i.Quantity
        }).ToList()
    }).ToList();

    return Ok(orderDtos);
}

Total: 1 query (or 2-3 with AsSplitQuery - see Pitfall 6)

Performance Impact

  • Before: 601 queries, ~3 seconds
  • After: 1 query, ~50ms

60x faster!

When to Use Include

Use Include when:

  • You always need the related data
  • The relationship is one-to-one or one-to-many (not too large)

Avoid Include when:

  • Related data is optional
  • Collections are huge (use projection instead - see Pitfall 3)

Pitfall 2: Tracking Everything

The Problem

[HttpGet("products")]
public async Task<ActionResult<List<ProductDto>>> GetProducts()
{
    // EF Core tracks all entities by default
    var products = await _context.Products.ToListAsync();

    return Ok(products.Select(p => new ProductDto
    {
        Id = p.Id,
        Name = p.Name,
        Price = p.Price
    }));
}

Why It’s Slow

By default, EF Core’s change tracker monitors every entity loaded:

  • Allocates memory for tracking information
  • Takes snapshots of original values
  • Watches for changes

For read-only queries, this is wasted overhead.

The Fix: AsNoTracking

[HttpGet("products")]
public async Task<ActionResult<List<ProductDto>>> GetProducts()
{
    var products = await _context.Products
        .AsNoTracking() // Disable change tracking
        .ToListAsync();

    return Ok(products.Select(p => new ProductDto
    {
        Id = p.Id,
        Name = p.Name,
        Price = p.Price
    }));
}

Performance Impact

For 10,000 products:

  • Before: ~150ms, 25 MB memory
  • After: ~80ms, 15 MB memory

Almost 2x faster, 40% less memory

When to Use AsNoTracking

Use AsNoTracking when:

  • Query is read-only
  • You won’t call SaveChanges on these entities
  • Building DTOs or view models

Don’t use when:

  • You need to update entities
  • You rely on change tracking features

Global No-Tracking (Opt-In Tracking)

If most queries are read-only:

public class AppDbContext : DbContext
{
    public AppDbContext(DbContextOptions<AppDbContext> options)
        : base(options)
    {
        // Change default behavior
        ChangeTracker.QueryTrackingBehavior = QueryTrackingBehavior.NoTracking;
    }

    // Override for updates
    public async Task<int> UpdateProductAsync(Product product)
    {
        _context.Attach(product).State = EntityState.Modified;
        return await _context.SaveChangesAsync();
    }
}

Pitfall 3: Loading Entire Entities When You Need a Few Columns

The Problem

[HttpGet("product-names")]
public async Task<ActionResult<List<string>>> GetProductNames()
{
    var products = await _context.Products.ToListAsync();
    return Ok(products.Select(p => p.Name).ToList());
}

If Product has 20 columns including BLOB fields, you’re loading all that data just to use the Name column.

Why It’s Slow

  • Transfers unnecessary data over network
  • Deserializes unused columns
  • Uses more memory

The Fix: Select Projection

[HttpGet("product-names")]
public async Task<ActionResult<List<string>>> GetProductNames()
{
    var names = await _context.Products
        .Select(p => p.Name)
        .ToListAsync();

    return Ok(names);
}

Generated SQL:

-- Before
SELECT * FROM Products

-- After
SELECT Name FROM Products

More Complex Projection

[HttpGet("orders")]
public async Task<ActionResult<List<OrderSummaryDto>>> GetOrderSummaries()
{
    var summaries = await _context.Orders
        .Select(o => new OrderSummaryDto
        {
            Id = o.Id,
            CustomerName = o.Customer.Name, // Joins automatically
            ItemCount = o.Items.Count,       // Aggregates in SQL
            TotalAmount = o.Items.Sum(i => i.Price * i.Quantity)
        })
        .ToListAsync();

    return Ok(summaries);
}

This generates a single efficient SQL query with joins and aggregates.

Performance Impact

For orders with large descriptions and images:

  • Before: ~500ms, transfers 10 MB
  • After: ~80ms, transfers 200 KB

6x faster, 50x less data

When to Use Projection

Always prefer projection for:

  • DTOs for APIs
  • View models for UI
  • Reports and exports
  • Any read-only query

Pitfall 4: Not Using Compiled Queries for Hot Paths

The Problem

public async Task<Product?> GetProductByIdAsync(int id)
{
    return await _context.Products.FirstOrDefaultAsync(p => p.Id == id);
}

Every time this runs, EF Core:

  1. Parses the LINQ expression
  2. Generates SQL
  3. Caches the query plan

Steps 1-2 are wasted work on repeated calls.

Why It’s Slow

Expression parsing and SQL generation take time. For queries called thousands of times per second, this adds up.

The Fix: Compiled Queries

private static readonly Func<AppDbContext, int, Task<Product?>> _getProductById =
    EF.CompileAsyncQuery((AppDbContext context, int id) =>
        context.Products.FirstOrDefault(p => p.Id == id));

public async Task<Product?> GetProductByIdAsync(int id)
{
    return await _getProductById(_context, id);
}

The query is compiled once at startup, then reused.

More Complex Example

private static readonly Func<AppDbContext, DateTime, DateTime, IAsyncEnumerable<Order>> _getOrdersByDateRange =
    EF.CompileAsyncQuery((AppDbContext context, DateTime start, DateTime end) =>
        context.Orders
            .Where(o => o.CreatedAt >= start && o.CreatedAt <= end)
            .Include(o => o.Customer)
            .OrderByDescending(o => o.CreatedAt));

public async Task<List<Order>> GetOrdersByDateRangeAsync(DateTime start, DateTime end)
{
    var orders = new List<Order>();
    await foreach (var order in _getOrdersByDateRange(_context, start, end))
    {
        orders.Add(order);
    }
    return orders;
}

Performance Impact

For a query called 10,000 times:

  • Before: ~5 seconds
  • After: ~3 seconds

40% faster on hot paths

When to Use Compiled Queries

Use compiled queries for:

  • High-frequency queries (called hundreds+ times/second)
  • API endpoints under heavy load
  • Background jobs that run frequently

Don’t use for:

  • Ad-hoc queries
  • Queries with dynamic WHERE clauses (use dynamic LINQ instead)

Pitfall 5: Missing Indexes Causing Table Scans

The Problem

public async Task<List<Order>> GetOrdersByCustomerEmailAsync(string email)
{
    return await _context.Orders
        .Where(o => o.Customer.Email == email)
        .ToListAsync();
}

If Email isn’t indexed:

-- Full table scan!
SELECT * FROM Orders o
JOIN Customers c ON o.CustomerId = c.Id
WHERE c.Email = 'user@example.com'

Why It’s Slow

Database scans every row in the Customers table to find matches. With 1 million customers, this checks 1 million rows.

The Fix: Add Index in Fluent API

public class AppDbContext : DbContext
{
    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        // Add index on Email
        modelBuilder.Entity<Customer>()
            .HasIndex(c => c.Email);

        // Composite index for common query
        modelBuilder.Entity<Order>()
            .HasIndex(o => new { o.CustomerId, o.CreatedAt });

        // Unique index
        modelBuilder.Entity<User>()
            .HasIndex(u => u.Username)
            .IsUnique();

        // Filtered index (SQL Server)
        modelBuilder.Entity<Order>()
            .HasIndex(o => o.Status)
            .HasFilter("[Status] = 'Pending'");
    }
}

Generate migration:

dotnet ef migrations add AddIndexes
dotnet ef database update

Performance Impact

Query for user@example.com in 1 million customers:

  • Before: ~2 seconds (table scan)
  • After: ~5ms (index seek)

400x faster!

When to Add Indexes

Index columns used in:

  • WHERE clauses
  • JOIN conditions
  • ORDER BY clauses
  • Foreign keys (EF Core auto-indexes these)

Don’t over-index:

  • Indexes slow down writes (INSERT/UPDATE/DELETE)
  • Each index uses disk space
  • Too many indexes hurt query planner performance

Finding Missing Indexes

Use database profiler or query execution plans to identify table scans:

// Enable sensitive data logging in development
optionsBuilder.EnableSensitiveDataLogging()
              .LogTo(Console.WriteLine, LogLevel.Information);

Look for warnings like “Table Scan” in SQL Server execution plans.

Pitfall 6: Cartesian Explosion with Multiple Includes

The Problem

public async Task<List<Order>> GetOrdersWithDetailsAsync()
{
    return await _context.Orders
        .Include(o => o.Items)        // 10 items per order
        .Include(o => o.Payments)     // 2 payments per order
        .Include(o => o.Shipments)    // 3 shipments per order
        .ToListAsync();
}

Why It’s Slow

EF Core generates a single query with multiple JOINs. This creates a Cartesian product:

For 1 order with 10 items, 2 payments, and 3 shipments:

  • Result set has 10 × 2 × 3 = 60 rows
  • All duplicated order data

For 100 orders:

  • Result set has 100 × 10 × 2 × 3 = 6,000 rows

Massive data duplication transferred from database.

The Fix: AsSplitQuery

public async Task<List<Order>> GetOrdersWithDetailsAsync()
{
    return await _context.Orders
        .Include(o => o.Items)
        .Include(o => o.Payments)
        .Include(o => o.Shipments)
        .AsSplitQuery() // Split into multiple queries
        .ToListAsync();
}

This executes:

  1. One query for Orders
  2. One query for Items (WHERE OrderId IN (…))
  3. One query for Payments (WHERE OrderId IN (…))
  4. One query for Shipments (WHERE OrderId IN (…))

No Cartesian explosion.

Performance Impact

For 100 orders:

  • Before: ~2 seconds, transfers 5 MB (6,000 rows)
  • After: ~300ms, transfers 500 KB (1,300 rows)

6x faster, 10x less data

When to Use AsSplitQuery

Use AsSplitQuery when:

  • Including multiple collections
  • Large result sets
  • Experiencing Cartesian explosion

Don’t use when:

  • Including single navigation properties
  • Small result sets (overhead of multiple queries)

Making Split Queries the Default

public class AppDbContext : DbContext
{
    protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
    {
        optionsBuilder.UseQuerySplittingBehavior(QuerySplittingBehavior.SplitQuery);
    }
}

Override per query with .AsSingleQuery() when needed.

Bonus: General Best Practices

1. Pagination

Never load entire tables:

// Bad
var products = await _context.Products.ToListAsync();

// Good
var products = await _context.Products
    .OrderBy(p => p.Id)
    .Skip((page - 1) * pageSize)
    .Take(pageSize)
    .ToListAsync();

2. Async All the Way

// Bad: Blocks thread
var products = _context.Products.ToList();

// Good: Async
var products = await _context.Products.ToListAsync();

3. Batch Updates

// Bad: N queries
foreach (var product in products)
{
    product.Price *= 1.1m;
    await _context.SaveChangesAsync(); // Don't do this!
}

// Good: 1 transaction
foreach (var product in products)
{
    product.Price *= 1.1m;
}
await _context.SaveChangesAsync(); // Once at the end

4. Use ExecuteUpdate for Bulk Changes (EF Core 7+)

// Instead of loading, modifying, saving
await _context.Products
    .Where(p => p.CategoryId == 5)
    .ExecuteUpdateAsync(s => s.SetProperty(p => p.Price, p => p.Price * 1.1m));

Single SQL UPDATE, no tracking overhead.

Measuring Performance

Always benchmark your changes:

using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;

public class EFCoreBenchmarks
{
    private AppDbContext _context;

    [GlobalSetup]
    public void Setup()
    {
        // Initialize context
    }

    [Benchmark]
    public async Task WithoutAsNoTracking()
    {
        var products = await _context.Products.ToListAsync();
    }

    [Benchmark]
    public async Task WithAsNoTracking()
    {
        var products = await _context.Products.AsNoTracking().ToListAsync();
    }
}

// Run benchmarks
var summary = BenchmarkRunner.Run<EFCoreBenchmarks>();

Conclusion

Entity Framework Core is powerful, but it requires understanding what’s happening under the hood. These six pitfalls account for 90% of the EF performance issues I see:

  1. N+1 queries: Use Include and ThenInclude
  2. Tracking overhead: Use AsNoTracking for read-only queries
  3. Loading too much data: Use Select projection
  4. Repeated query compilation: Use compiled queries for hot paths
  5. Missing indexes: Index foreign keys and frequently queried columns
  6. Cartesian explosion: Use AsSplitQuery for multiple collections

Fix these, and your EF Core applications will be fast, scalable, and efficient.

Always profile your queries, look at the generated SQL, and measure before and after performance. Your users (and database server) will thank you.