AsNoTracking & Query Optimization
AsNoTracking Overview
By default, EF Core tracks all entities returned from queries. For read-only scenarios, this tracking overhead is unnecessary.
The Problem: Tracking Overhead
// Default behavior - WITH tracking
var products = await context.Products.ToListAsync();
// What happens:
// 1. EF creates snapshot of each entity
// 2. ChangeTracker monitors all entities
// 3. Memory used for tracking state
// 4. CPU used for change detection
// 5. 20-30% performance overhead
AsNoTracking Solution
// Read-only query - NO tracking
var products = await context.Products
.AsNoTracking()
.ToListAsync();
// Benefits:
// - 20-30% faster
// - Less memory usage
// - No change tracking overhead
// - Better for large result sets
Performance Comparison
[MemoryDiagnoser]
public class TrackingBenchmark
{
[Benchmark(Baseline = true)]
public async Task<List<Product>> WithTracking()
{
return await _context.Products
.Take(1000)
.ToListAsync();
}
[Benchmark]
public async Task<List<Product>> WithoutTracking()
{
return await _context.Products
.AsNoTracking()
.Take(1000)
.ToListAsync();
}
}
// Results (1000 entities):
// WithTracking: 150ms, 5.2 MB
// WithoutTracking: 100ms, 3.1 MB
// Improvement: 33% faster, 40% less memory
When to Use AsNoTracking
✅ Use AsNoTracking For:
// Read-only API endpoints
[HttpGet]
public async Task<IActionResult> GetProducts()
{
var products = await _context.Products
.AsNoTracking()
.ToListAsync();
return Ok(products);
}
// Reports and analytics
var report = await _context.Orders
.AsNoTracking()
.Where(o => o.OrderDate >= startDate)
.GroupBy(o => o.CustomerId)
.Select(g => new { CustomerId = g.Key, Total = g.Sum(o => o.Amount) })
.ToListAsync();
// Display lists
var blogList = await _context.Blogs
.AsNoTracking()
.Include(b => b.Posts)
.ToListAsync();
// Export/batch processing
var exportData = await _context.Products
.AsNoTracking()
.ToListAsync();
❌ Don't Use AsNoTracking When:
// Need to update entities
var product = await _context.Products
.AsNoTracking() // ❌ Wrong!
.FirstAsync(p => p.Id == id);
product.Price = 99.99m;
await _context.SaveChangesAsync(); // Nothing happens!
// Need to track relationships
var order = await _context.Orders
.AsNoTracking() // ❌ Wrong!
.Include(o => o.OrderItems)
.FirstAsync();
order.OrderItems.Add(new OrderItem { ... });
await _context.SaveChangesAsync(); // Nothing happens!
Global NoTracking Configuration
// Set default for entire context
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
{
optionsBuilder
.UseSqlServer(connectionString)
.UseQueryTrackingBehavior(QueryTrackingBehavior.NoTracking);
}
// Override for specific queries that need tracking
var product = await context.Products
.AsTracking() // Enable tracking for this query
.FirstAsync(p => p.Id == id);
AsNoTrackingWithIdentityResolution
// Problem: Duplicate objects with AsNoTracking
var blogs = await context.Blogs
.AsNoTracking()
.Include(b => b.Posts)
.ThenInclude(p => p.Author)
.ToListAsync();
// If same author appears in multiple posts,
// you get separate Author instances!
// Solution: EF Core 5+
var blogs = await context.Blogs
.AsNoTrackingWithIdentityResolution()
.Include(b => b.Posts)
.ThenInclude(p => p.Author)
.ToListAsync();
// Same author = same object instance
// Slower than AsNoTracking, but fixes duplicates
Compiled Queries
Create Compiled Query
// Define once, reuse many times
private static readonly Func<AppDbContext, int, Task<Product>> _getProductById =
EF.CompileAsyncQuery((AppDbContext context, int id) =>
context.Products
.AsNoTracking()
.FirstOrDefault(p => p.Id == id));
// Usage - much faster on subsequent calls
var product = await _getProductById(context, 42);
Compiled Query with Parameters
private static readonly Func<AppDbContext, int, decimal, Task<List<Product>>> _getProducts =
EF.CompileAsyncQuery((AppDbContext context, int categoryId, decimal minPrice) =>
context.Products
.AsNoTracking()
.Where(p => p.CategoryId == categoryId && p.Price >= minPrice)
.OrderBy(p => p.Name)
.ToList());
// Usage
var products = await _getProducts(context, 5, 100m);
Benchmark
[Benchmark(Baseline = true)]
public async Task<Product> RegularQuery()
{
return await _context.Products
.AsNoTracking()
.FirstOrDefaultAsync(p => p.Id == 1);
}
[Benchmark]
public async Task<Product> CompiledQuery()
{
return await _getProductById(_context, 1);
}
// Results (1000 executions):
// RegularQuery: 500ms
// CompiledQuery: 250ms
// Improvement: 2x faster!
Query Tags
// Add tags for query identification
var products = await context.Products
.TagWith("ProductList: Dashboard query")
.AsNoTracking()
.Where(p => p.IsActive)
.ToListAsync();
// Generated SQL includes comment:
// -- ProductList: Dashboard query
// SELECT * FROM Products WHERE IsActive = 1
// Benefits:
// - Identify queries in SQL Profiler
// - Track performance by feature
// - Debug production issues
Additional Optimizations
Limit Columns with Projection
// ❌ BAD: Load all columns
var products = await context.Products
.AsNoTracking()
.ToListAsync();
// ✅ GOOD: Only needed columns
var products = await context.Products
.Select(p => new ProductDto
{
Id = p.Id,
Name = p.Name,
Price = p.Price
})
.ToListAsync();
// Even better than AsNoTracking!
Pagination
// ❌ BAD: Load everything
var products = await context.Products
.AsNoTracking()
.ToListAsync();
// ✅ GOOD: Load page at a time
var products = await context.Products
.AsNoTracking()
.OrderBy(p => p.Name)
.Skip((page - 1) * pageSize)
.Take(pageSize)
.ToListAsync();
Filtering Early
// ❌ BAD: Filter in memory
var products = await context.Products
.AsNoTracking()
.ToListAsync();
var filtered = products.Where(p => p.Price > 100).ToList();
// ✅ GOOD: Filter in database
var products = await context.Products
.AsNoTracking()
.Where(p => p.Price > 100)
.ToListAsync();
Query Splitting
// Problem: Cartesian explosion with multiple includes
var blogs = await context.Blogs
.AsNoTracking()
.Include(b => b.Posts)
.Include(b => b.Authors)
.ToListAsync();
// If 10 blogs, 100 posts, 50 authors:
// Result set can be 10 * 100 * 50 = 50,000 rows!
// ✅ Solution: Split query
var blogs = await context.Blogs
.AsNoTracking()
.Include(b => b.Posts)
.Include(b => b.Authors)
.AsSplitQuery()
.ToListAsync();
// Executes 3 separate queries - much better!
Best Practices Summary
// Perfect read-only query
var products = await context.Products
.AsNoTracking() // 1. No tracking
.Where(p => p.IsActive) // 2. Filter early
.OrderBy(p => p.Name) // 3. Order
.Select(p => new ProductDto // 4. Project to DTO
{
Id = p.Id,
Name = p.Name,
Price = p.Price,
CategoryName = p.Category.Name
})
.Skip((page - 1) * pageSize) // 5. Paginate
.Take(pageSize)
.TagWith("ProductList: Main page") // 6. Tag for monitoring
.ToListAsync();
Interview Questions
Q: When should you use AsNoTracking?
A: Use AsNoTracking for read-only queries where you won't modify the entities. Benefits include 20-30% performance improvement and reduced memory usage. Don't use when you need to update entities.
Q: What's the difference between AsNoTracking and AsNoTrackingWithIdentityResolution?
A: AsNoTracking can create duplicate object instances for the same entity. AsNoTrackingWithIdentityResolution ensures same entity = same object instance, but is slightly slower.
Q: How do compiled queries improve performance?
A: Compiled queries cache the query translation, eliminating overhead of re-parsing LINQ and generating SQL on each execution. Provides ~2x performance improvement for frequently executed queries.
Q: What are query tags used for?
A: Query tags add comments to generated SQL for identification in profilers, monitoring tools, and logs. Helps track which application code generated specific queries.