Skip to main content

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.

Additional Resources