Skills optimizing-ef-core-queries
Optimize Entity Framework Core queries by fixing N+1 problems, choosing correct tracking modes, using compiled queries, and avoiding common performance traps. Use when EF Core queries are slow, generating excessive SQL, or causing high database load.
install
source · Clone the upstream repo
git clone https://github.com/dotnet/skills
Claude Code · Install into ~/.claude/skills/
T=$(mktemp -d) && git clone --depth=1 https://github.com/dotnet/skills "$T" && mkdir -p ~/.claude/skills && cp -r "$T/plugins/dotnet-data/skills/optimizing-ef-core-queries" ~/.claude/skills/dotnet-skills-optimizing-ef-core-queries && rm -rf "$T"
manifest:
plugins/dotnet-data/skills/optimizing-ef-core-queries/SKILL.mdsource content
Optimizing EF Core Queries
When to Use
- EF Core queries are slow or generating too many SQL statements
- Database CPU/IO is high due to ORM inefficiency
- N+1 query patterns are detected in logs
- Large result sets cause memory pressure
When Not to Use
- The user is using Dapper or raw ADO.NET (not EF Core)
- The performance issue is database-side (missing indexes, bad schema)
- The user is building a new data access layer from scratch
Inputs
| Input | Required | Description |
|---|---|---|
| Slow EF Core queries | Yes | The LINQ queries or DbContext usage to optimize |
| SQL output or logs | No | EF Core generated SQL or query execution logs |
Workflow
Step 1: Enable query logging to see the actual SQL
// In Program.cs or DbContext configuration: optionsBuilder .UseSqlServer(connectionString) .LogTo(Console.WriteLine, LogLevel.Information) .EnableSensitiveDataLogging() // shows parameter values (dev only!) .EnableDetailedErrors();
Or use the
Microsoft.EntityFrameworkCore log category:
{ "Logging": { "LogLevel": { "Microsoft.EntityFrameworkCore.Database.Command": "Information" } } }
Step 2: Fix N+1 query patterns
The #1 EF Core performance killer. Happens when loading related entities in a loop.
Before (N+1 — 1 query for orders + N queries for items):
var orders = await db.Orders.ToListAsync(); foreach (var order in orders) { // Each access triggers a lazy-load query! var items = order.Items.Count; }
After (eager loading — 1 or 2 queries total):
// Option 1: Include (JOIN) var orders = await db.Orders .Include(o => o.Items) .ToListAsync(); // Option 2: Split query (separate SQL, avoids cartesian explosion) var orders = await db.Orders .Include(o => o.Items) .AsSplitQuery() .ToListAsync(); // Option 3: Explicit projection (best - only fetches needed columns) var orderSummaries = await db.Orders .Select(o => new OrderSummary { OrderId = o.Id, Total = o.Items.Sum(i => i.Price), ItemCount = o.Items.Count }) .ToListAsync();
When to use Split vs Single query:
| Scenario | Use |
|---|---|
| 1 level of Include | Single query (default) |
| Multiple Includes (Cartesian risk) | |
| Include with large child collections | |
| Need transaction consistency | Single query |
Step 3: Use NoTracking for read-only queries
Change tracking overhead is significant. Disable it when you don't need to update entities:
// Per-query var products = await db.Products .AsNoTracking() .Where(p => p.IsActive) .ToListAsync(); // Global default for read-heavy apps services.AddDbContext<AppDbContext>(options => options.UseSqlServer(connectionString) .UseQueryTrackingBehavior(QueryTrackingBehavior.NoTracking));
Use
when the query returns duplicate entities to avoid duplicated objects in memory.AsNoTrackingWithIdentityResolution()
Step 4: Use compiled queries for hot paths
// Define once as static private static readonly Func<AppDbContext, int, Task<Order?>> GetOrderById = EF.CompileAsyncQuery((AppDbContext db, int id) => db.Orders .Include(o => o.Items) .FirstOrDefault(o => o.Id == id)); // Use repeatedly — skips query compilation overhead var order = await GetOrderById(db, orderId);
Step 5: Avoid common query traps
| Trap | Problem | Fix |
|---|---|---|
before | Loads entire table into memory | Filter first: |
to check existence | Scans all rows | Use instead |
after | Include is ignored with projection | Remove Include, use Select only |
in Where | May not translate, falls to client eval | Use for SQL LIKE |
Calling inside | Causes nested queries | Use projection with all the way |
Step 6: Use raw SQL or FromSql for complex queries
When LINQ can't express it efficiently:
var results = await db.Orders .FromSqlInterpolated($@" SELECT o.* FROM Orders o INNER JOIN ( SELECT OrderId, SUM(Price) as Total FROM OrderItems GROUP BY OrderId HAVING SUM(Price) > {minTotal} ) t ON o.Id = t.OrderId") .AsNoTracking() .ToListAsync();
Validation
- SQL logging shows expected number of queries (no N+1)
- Read-only queries use
AsNoTracking() - Hot-path queries use compiled queries
- No client-side evaluation warnings in logs
- Include/split strategy matches data shape
Common Pitfalls
| Pitfall | Solution |
|---|---|
| Lazy loading silently creating N+1 | Remove or disable lazy loading |
| Global query filters forgotten in perf analysis | Check in model config; use if needed |
kept alive too long | DbContext should be scoped (per-request); don't cache it |
| Batch updates fetching then saving | EF Core 7+: use / for bulk operations |
String interpolation in | SQL injection risk — use (parameterized) |