Home / .NET / Entity Framework Core: Advanced Query Patterns and Performance
.NET

Entity Framework Core: Advanced Query Patterns and Performance

Entity Framework Core transforms data access in .NET applications, but naive usage patterns can tank performance. Understanding advanced query patterns,...

What you will learn

Practical execution with concise explanations, real implementation patterns, and production-ready recommendations.

Entity Framework Core: Advanced Query Patterns and Performance

var tags = new Dictionary<string, object?> { ["QueryName"] = "PendingOrders" }; context.Database.LogTo(msg => logger.LogInformation(msg), tags: tags);


### Step 4: Batching & Transactions

```csharp
await using var tx = await context.Database.BeginTransactionAsync();
foreach(var item in items){ context.Add(item); }
await context.SaveChangesAsync();
await tx.CommitAsync();

Advanced Performance Patterns

Advanced Performance Patterns

Figure: Power Apps form control – edit form with validation rules and error handling.

Pattern 1: Specification Pattern for Complex Queries

public interface ISpecification<T> {
  Expression<Func<T, bool>> Criteria { get; }
  List<Expression<Func<T, object>>> Includes { get; }
}





public class ActiveOrdersSpec : ISpecification<Order> {
  public Expression<Func<Order, bool>> Criteria => 
    o => o.Status == Status.Active && o.Total > 100;
  public List<Expression<Func<Order, object>>> Includes => 
    new() { o => o.Customer, o => o.Items };
}

// Reusable, testable query logic
var spec = new ActiveOrdersSpec();
var orders = await context.Orders
  .Where(spec.Criteria)
  .Include(spec.Includes)
  .ToListAsync();

Pattern 2: Read Models with Projections

// Avoid materializing full entities for simple views
public record OrderListItem(int Id, string CustomerName, decimal Total);

var items = await context.Orders
  .Where(o => o.Status == Status.Pending)
  .Select(o => new OrderListItem(o.Id, o.Customer.Name, o.Total))
  .ToListAsync();

// Benefits: Less data transfer, faster serialization, no tracking overhead

Pattern 3: Query Filters for Multi-Tenancy

// Configure global query filter
modelBuilder.Entity<Order>().HasQueryFilter(o => o.TenantId == _tenantId);

// Now all queries automatically filter by tenant
var orders = await context.Orders.ToListAsync(); // Implicit WHERE TenantId = X

// Bypass when needed
var allOrders = await context.Orders.IgnoreQueryFilters().ToListAsync();

Best Practices

Query Optimization:

  • Use AsNoTracking() for all read-only queries (40%+ memory reduction)
  • Prefer projections (Select) over Include when you don't need full entities
  • Avoid Select N+1: always use Include or projection, never lazy load in loops
  • Use AsSplitQuery() for multiple collection includes to prevent cartesian explosions
  • Enable query splitting globally: UseSqlServer(conn, o => o.UseQuerySplittingBehavior(QuerySplittingBehavior.SplitQuery))

Compilation & Caching:

  • Use compiled queries for frequently executed queries (eliminates translation overhead)
  • Keep queries parameterized to leverage query plan caching
  • Monitor query plan cache hit ratio in SQL Server

Bulk Operations:

  • Use ExecuteUpdate/ExecuteDelete for bulk modifications (EF Core 7+)
  • Consider bulk extension libraries (EFCore.BulkExtensions) for large inserts
  • Disable AutoDetectChanges for bulk operations: context.ChangeTracker.AutoDetectChangesEnabled = false

Diagnostics:

  • Enable sensitive data logging in development: EnableSensitiveDataLogging()
  • Use Query Tags for tracking: .TagWith("Dashboard-OrderList")
  • Profile with SQL Server Query Store or execution plans
  • Monitor EF Core performance counters: query compilation time, execution time

Common Issues & Troubleshooting

Common Issues & Troubleshooting

Figure: Configuration and management dashboard with status overview.

Issue: High memory usage due to change tracking

// Problem: Default tracking wastes memory for read-only queries
var orders = await context.Orders.Include(o => o.Customer).ToListAsync();





// Solution: Use AsNoTracking for read-only scenarios
var orders = await context.Orders
  .AsNoTracking()
  .Include(o => o.Customer)
  .ToListAsync();

// Memory savings: ~40% reduction for large result sets

Issue: N+1 query problem destroying performance

// Problem: Lazy loading triggers N queries
var orders = await context.Orders.ToListAsync();
foreach (var order in orders) {
  Console.WriteLine(order.Customer.Name); // Separate query per order!
}

// Solution 1: Eager loading with Include
var orders = await context.Orders
  .Include(o => o.Customer)
  .ToListAsync();

// Solution 2: Projection (more efficient)
var orders = await context.Orders
  .Select(o => new { o.Id, CustomerName = o.Customer.Name })
  .ToListAsync();

Issue: Cartesian explosion with multiple collections

// Problem: Cartesian product from multiple includes
var orders = await context.Orders
  .Include(o => o.Items)      // 10 items
  .Include(o => o.Payments)   // 3 payments
  .ToListAsync();             // Returns 30 rows (10 × 3)!

// Solution: Use split queries
var orders = await context.Orders
  .AsSplitQuery()
  .Include(o => o.Items)
  .Include(o => o.Payments)
  .ToListAsync();
// Now executes 3 separate queries instead

Issue: Slow bulk operations

// Problem: SaveChanges is slow for thousands of entities
foreach (var item in largeDataSet) {
  context.Add(item);
}
await context.SaveChangesAsync(); // Terrible performance

// Solution: Use ExecuteUpdate for bulk updates (EF Core 7+)
await context.Orders
  .Where(o => o.Status == Status.Pending)
  .ExecuteUpdateAsync(s => s.SetProperty(o => o.Status, Status.Processed));

// Or use bulk extensions library
await context.BulkInsertAsync(largeDataSet);

Issue: Query plan cache pollution

// Problem: String concatenation creates unique query each time
var status = "Pending";
var orders = context.Orders.Where(o => EF.Functions.Like(o.Status, $"%{status}%"));

// Solution: Use parameterized queries
var orders = context.Orders.Where(o => o.Status.Contains(status));
// Parameters are cached, same execution plan reused

Real-World Performance Impact

Case Study: E-commerce Order Dashboard

  • Before: Loading 1000 orders with Include(Customer).Include(Items): 2.3s, 450MB memory
  • After: Projection to OrderListItem DTO: 180ms, 12MB memory
  • Improvement: 12x faster, 97% less memory

Case Study: Bulk Import Pipeline

  • Before: Individual Add() + SaveChanges() for 10K records: 4 minutes
  • After: BulkInsertAsync with batch size 1000: 8 seconds
  • Improvement: 30x faster throughput

Case Study: Multi-Tenant SaaS API

  • Before: Manual tenant filtering in each query: Error-prone, inconsistent
  • After: Global query filters with TenantId: Automatic, secure
  • Improvement: Zero tenant data leaks, simplified codebase

Architecture Decision and Tradeoffs

When designing application development solutions with .NET, consider these key architectural trade-offs:

Approach Best For Tradeoff
Managed / platform service Rapid delivery, reduced ops burden Less customisation, potential vendor lock-in
Custom / self-hosted Full control, advanced tuning Higher operational overhead and cost

Recommendation: Start with the managed approach for most workloads and move to custom only when specific requirements demand it.

Validation and Versioning

  • Last validated: April 2026
  • Validate examples against your tenant, region, and SKU constraints before production rollout.
  • Keep module, CLI, and SDK versions pinned in automation pipelines and review quarterly.

Security and Governance Considerations

  • Apply least-privilege access using RBAC roles and just-in-time elevation for admin tasks.
  • Store secrets in managed secret stores and avoid embedding credentials in scripts or source files.
  • Enable audit logging, data protection policies, and periodic access reviews for regulated workloads.

Cost and Performance Notes

  • Define budgets and alerts, then monitor usage and cost trends continuously after go-live.
  • Baseline performance with synthetic and real-user checks before and after major changes.
  • Scale resources with measured thresholds and revisit sizing after usage pattern changes.

Official Microsoft References

  • https://learn.microsoft.com/dotnet/
  • https://learn.microsoft.com/aspnet/core/
  • https://learn.microsoft.com/azure/developer/dotnet/

Public Examples from Official Sources

  • These examples are sourced from official public Microsoft documentation and sample repositories.
  • Documentation examples: https://learn.microsoft.com/dotnet/
  • Sample repositories: https://github.com/dotnet/samples
  • Prefer adapting these examples to your tenant, subscriptions, and governance requirements before production use.

Key Takeaways

  • AsNoTracking() for read-only queries provides 40%+ memory reduction with zero code complexity
  • Projections (Select) outperform Include() when you don't need full entity graphs
  • Compiled queries eliminate translation overhead for hot paths (10-30% improvement)
  • AsSplitQuery() prevents cartesian explosions but increases round trips—profile first
  • ExecuteUpdate/Delete in EF Core 7+ enable efficient bulk operations without loading entities
  • Query Tags and sensitive data logging are essential for production diagnostics
  • Specification pattern enables reusable, testable query logic across repositories

Next Steps

  • Implement Redis caching layer for hot read models (user profiles, product catalogs)
  • Evaluate EFCore.BulkExtensions or Dapper for high-volume data imports
  • Set up Application Insights for EF Core query telemetry in production
  • Configure SQL Server Query Store for query performance baseline
  • Explore interceptors for cross-cutting concerns (logging, caching, multi-tenancy)
  • Consider CQRS pattern: EF Core for writes, Dapper/ADO.NET for complex reads

Additional Resources


What EF Core optimization gave you the biggest performance gain?

Discussion