A code review of the most common LINQ performance mistakes in .NET 10 services, in memory and against EF Core, with the reasoning and the rewrite for each.
The big problems are where the work runs and how often it runs. ToList() before Where pulls whole tables into memory, and loading related data inside the foreach is an N+1. A FirstOrDefault on a list inside a loop makes it O(n times m), and enumerating the same query several times repeats the round trips. Smaller fixes are Any() instead of Count() > 0, and MinBy or a SQL TOP 1 instead of OrderBy().First(). A 50,000-id Contains needs batching or a table-valued parameter. My rewrite filters and projects in one SQL query with AsNoTracking, builds a dictionary for lookups, materialises once, and I measure before and after.
public async Task<OrderReport> BuildReportAsync(DateTime from, IList<int> customerIds)
{
var orders = db.Orders.Include(o => o.Lines).ToList() // 1: ToList before Where
.Where(o => o.CreatedAt >= from);
if (orders.Count() > 0) { /* ... */ } // 2: Count() > 0 (and re-enumeration)
var products = db.Products.ToList();
foreach (var line in orders.SelectMany(o => o.Lines))
{
var product = products.FirstOrDefault(p => p.Id == line.ProductId); // 3: FirstOrDefault in a loop
line.ProductName = product?.Name;
}
var biggest = orders.OrderByDescending(o => o.Total).First(); // 4: sort to get max
var total = orders.Sum(o => o.Total); // 5: multiple enumeration
var avg = orders.Average(o => o.Total);
foreach (var o in orders)
o.Customer = db.Customers.Single(c => c.Id == o.CustomerId); // 6: query per row (N+1)
var vip = db.Orders.Where(o => customerIds.Contains(o.CustomerId)) // 7: Contains over 50,000 ids
.ToList();
...
}
1. ToList() before Where β loads every order and every line (Include) into memory, tracked, then filters in C#. On a large table this is the dominant cost: huge SQL result, network transfer, memory, change-tracking overhead. Fix: filter and project in SQL.
6. Querying inside a loop (N+1) β one SELECT per order to load its customer: 10,000 orders β 10,000 round trips. Fix: join/project in the main query (o.Customer.Name), or load customers once into a dictionary.
7. Contains over 50,000 ids β EF Core translates list.Contains(x) either to a giant IN (@p0, @p1, ...) (older versions; SQL Server has a ~2,100 parameter limit and huge SQL text kills plan caching) or, in EF Core 8+, to an OPENJSON parameter on SQL Server β better, but 50,000 values is still a big payload and can produce poor cardinality estimates. Fix: batch the ids (e.g. Chunk(1000)), use a table-valued parameter/temp table join, or better, express the criterion in SQL (e.g. join on a VipCustomers table or a flag).
3. FirstOrDefault on a list inside a loop β O(lines Γ products). With 100,000 lines and 20,000 products that's 2 billion comparisons. Fix: ToDictionary once (or project the product name in SQL).
5. Enumerating the same query several times β orders is a deferred IEnumerable (because of .Where after ToList): Count(), SelectMany, OrderByDescending, Sum, Average and the foreach each re-run the filter over the in-memory list (and with an IQueryable, each would be a separate database query). Fix: materialise once into a list, or compute aggregates in one SQL query.
4. OrderByDescending().First() β sorts everything (O(n log n), buffers all items) to get one item. Fix: MaxBy(o => o.Total) in memory, or in SQL OrderByDescending(...).Select(...).FirstOrDefaultAsync() (which translates to TOP(1) ... ORDER BY, fine with an index).
2. Count() > 0 β counts all rows to test existence. Fix: Any() (or list.Count > 0 on a materialised list).
Other issues: synchronous ToList() in an async method (blocks threads), tracked entities for a read-only report (AsNoTracking), mutating entities (line.ProductName = ...) as a DTO hack, and DateTime vs DateTimeOffset time-zone ambiguity.
public async Task<OrderReport> BuildReportAsync(DateTimeOffset from, IReadOnlyCollection<int> customerIds, CancellationToken ct)
{
// 1. Filter + project in SQL, no tracking, one round trip
var rows = await db.Orders
.AsNoTracking()
.Where(o => o.CreatedAt >= from)
.Select(o => new OrderRow(
o.Id,
o.Total,
o.Customer.Name, // join instead of N+1
o.Lines.Select(l => new LineRow(l.ProductId, l.Product.Name, l.Quantity)).ToList()))
.TagWith("Order report")
.ToListAsync(ct); // materialise ONCE
if (rows.Count == 0) return OrderReport.Empty;
// 2. In-memory aggregates over the materialised list (single pass each, no re-query)
var biggest = rows.MaxBy(r => r.Total)!;
var total = rows.Sum(r => r.Total);
var average = total / rows.Count;
// 3. VIP orders: batch the large id list (or join a VIP table in SQL)
var vipOrderIds = new List<int>();
foreach (var batch in customerIds.Chunk(1_000))
vipOrderIds.AddRange(await db.Orders.AsNoTracking()
.Where(o => batch.Contains(o.CustomerId) && o.CreatedAt >= from)
.Select(o => o.Id)
.ToListAsync(ct));
return new OrderReport(rows, biggest, total, average, vipOrderIds);
}
If the report must aggregate over very large data, I'd push aggregates into SQL too (GroupBy with Sum/Count translated by EF Core) rather than loading rows. And for the 50,000-id case, a TVP or a staging table join is the robust long-term fix.
Lead with the two biggest wins (filter/project in SQL; remove N+1), quantify with evidence (SQL logs or Application Insights dependency counts), then the algorithmic fixes (dictionary lookups, MaxBy, Any), then hygiene (AsNoTracking, async, single materialisation). Suggest guardrails: EF Core query logging/tags in development, CA1851 (multiple enumeration), CA1827/CA1860 (Any/Count), and a performance test for the report.
A realistic senior code review covering both LINQ to Objects and LINQ to Entities.
Green flags: prioritises by impact (data volume and round trips first); explains each fix with complexity or SQL; uses projection, AsNoTracking, async; knows MinBy/MaxBy, Chunk, ToLookup; has a measurement plan.
Red flags: focuses only on micro-optimisations; rewrites everything as loops; misses N+1 and early ToList.
LogTo or Microsoft.EntityFrameworkCore.Database.Command at Information) shows each SQL statement and duration β N+1 appears as repeated statements.| Step | Original | Rewrite |
|---|---|---|
| Load orders | All orders + lines, tracked | Filtered rows, projected columns, no tracking |
| Customer names | N queries | Join in one query |
| Product names | O(L Γ P) list scans | Join (or O(L) dictionary lookups) |
| Max order | O(n log n) sort | O(n) MaxBy |
| Aggregates | Re-enumerate filter each time | Single materialised list |
| VIP ids | One giant IN/JSON payload |
Batches or set-based join |
Contains translation details (SQL Server)WHERE [o].[CustomerId] IN (1, 2, 3, ...) with constants β new SQL per list, plan cache pollution, and limits for very large lists.WHERE [o].[CustomerId] IN (SELECT [value] FROM OPENJSON(@ids) WITH ([value] int '$')) β one parameterised plan; but SQL Server's cardinality estimate for OPENJSON is fixed, sometimes producing bad plans; EF Core 9/10 add configuration to choose parameterisation strategies (e.g. inline constants for small lists).FromSql with a SqlParameter of Structured type), bulk-insert into a temp table and join, or redesign the criterion.Projecting o.Lines.Select(...).ToList() inside a single query produces a JOIN with row duplication for each line (EF Core handles it). For many orders with many lines, consider AsSplitQuery() or two queries (orders, then lines by order ids) to avoid cartesian growth.
var stats = await db.Orders.Where(o => o.CreatedAt >= from)
.GroupBy(_ => 1)
.Select(g => new { Count = g.Count(), Total = g.Sum(o => o.Total), Max = g.Max(o => o.Total) })
.SingleOrDefaultAsync(ct);
One round trip returning three numbers instead of thousands of rows β often the best choice for dashboards.
The filter CreatedAt >= from plus projections of Total, CustomerId benefit from an index like IX_Orders_CreatedAt INCLUDE (Total, CustomerId). Check the execution plan before and after; avoid adding indexes blindly (write cost).
ToQueryString() for Contains with 10 and 50,000 ids on your EF Core version.GroupBy aggregate query and compare.using Microsoft.EntityFrameworkCore;
namespace Linq.CodeReview;
public sealed class Customer { public int Id { get; set; } public required string Name { get; set; } }
public sealed class OrderLine { public int Id { get; set; } public int OrderId { get; set; } public required string Sku { get; set; } public int Quantity { get; set; } }
public sealed class Order
{
public int Id { get; set; }
public int CustomerId { get; set; }
public Customer Customer { get; set; } = null!;
public DateTime CreatedUtc { get; set; }
public required string Status { get; set; }
public decimal Total { get; set; }
public List<OrderLine> Lines { get; } = [];
}
public sealed class ShopContext(DbContextOptions<ShopContext> options) : DbContext(options)
{
public DbSet<Order> Orders => Set<Order>();
public DbSet<OrderLine> OrderLines => Set<OrderLine>();
}
public sealed record LineDto(string Sku, int Quantity);
public sealed record OrderReportRow(int Id, string CustomerName, decimal Total, IReadOnlyList<LineDto> Lines);
public sealed record OrderReport(IReadOnlyList<OrderReportRow> Rows, OrderReportRow? Biggest, int OpenCount);
public sealed class OrderReportService(ShopContext db)
{
public async Task<OrderReport> BuildAsync(DateTime fromUtc, CancellationToken ct)
{
// One query: filter, join and project in SQL; no tracking; lines projected (no N+1)
var rows = await db.Orders
.AsNoTracking()
.Where(o => o.CreatedUtc >= fromUtc)
.OrderBy(o => o.Id)
.Select(o => new OrderReportRow(
o.Id,
o.Customer.Name,
o.Total,
o.Lines.Select(l => new LineDto(l.Sku, l.Quantity)).ToList()))
.AsSplitQuery()
.ToListAsync(ct);
var biggest = rows.MaxBy(r => r.Total); // O(n) instead of sorting
var openCount = await db.Orders.CountAsync(o => o.Status == "Open", ct); // one round trip, no list needed
return new OrderReport(rows, biggest, openCount);
}
// Large id sets: batch instead of one enormous Contains
public async Task<List<Order>> LoadByIdsAsync(IReadOnlyList<int> ids, CancellationToken ct)
{
var result = new List<Order>(ids.Count);
foreach (var batch in ids.Chunk(1_000))
{
result.AddRange(await db.Orders.AsNoTracking()
.Where(o => batch.Contains(o.Id))
.ToListAsync(ct));
}
return result;
}
// In-memory lookups: build the index once
public static IEnumerable<(Order Order, string? CustomerName)> Attach(IReadOnlyList<Order> orders, IReadOnlyList<Customer> customers)
{
var byId = customers.ToDictionary(c => c.Id);
foreach (var o in orders)
yield return (o, byId.GetValueOrDefault(o.CustomerId)?.Name);
}
}
ToList()What happens: db.X.ToList().Where(...).
Why it is wrong: loads entire tables.
Do instead: compose filters on IQueryable and materialise at the end.
What happens: querying related data per row inside loops.
Why it is wrong: thousands of round trips.
Do instead: joins/projections, Include, or batch-loading into dictionaries.
What happens: list.FirstOrDefault(x => x.Id == id) per item.
Why it is wrong: quadratic time.
Do instead: ToDictionary once or join in SQL.
What happens: OrderBy(...).First() in memory.
Why it is wrong: O(n log n) and buffering for an O(n) problem.
Do instead: MaxBy/MinBy (or TOP(1) in SQL with an index).
Count() > 0What happens: existence checks via counting.
Why it is wrong: full count instead of early exit.
Do instead: Any().
What happens: several aggregates over the same IEnumerable/IQueryable.
Why it is wrong: repeated work or queries.
Do instead: materialise once or aggregate in one SQL query.
Contains listsWhat happens: passing tens of thousands of ids to Contains.
Why it is wrong: large payloads, parameter limits, poor plans.
Do instead: batch, TVPs/temp tables, or set-based criteria.
What happens: writing display values into entities.
Why it is wrong: tracking overhead and risk of accidental saves.
Do instead: AsNoTracking and DTO projections.
The senior skill in LINQ code review is spotting where work happens and how many times. Most real-world LINQ performance problems are not about iterator overhead; they are about pulling too much data, too many round trips, or quadratic loops hidden behind readable one-liners. I review data-access code with the SQL log open, push filtering and projection to the database, and keep in-memory LINQ for small, already-materialised data. I also make sure the fix is measurable so the team learns from it.
In EF Core, DbSet<T> is an IQueryable<T>, so Where, Select and so on build an expression tree, and EF Core translates it to SQL when you enumerate, caching the translation by query shape. Only what EF understands translates, like properties, operators and many string, date and EF.Functions methods. Since EF Core 3.0, client evaluation is allowed only in the final Select, so anything else untranslatable throws. For reads I use AsNoTracking or projections, AsSplitQuery when collection includes explode, and I check the SQL with ToQueryString(), logging and Query Store.
Deferred execution means a LINQ query is a recipe that runs each time you enumerate it, not when you write it. That explains all four symptoms. The "changing" list is a query that re-reads its source and captured variables. The ObjectDisposedException comes from enumerating an EF query after the DbContext was disposed. Three log lines are three enumerations, and the late exception is a lambda failing during enumeration. I fix them by materialising once with ToList at a clear boundary inside the context's scope, and by turning on CA1851 for multiple enumeration.
Count the work per line, including hidden loops in library calls. With O orders, L lines per order, P products:
products.FirstOrDefault per line: O(O Β· L Β· P).seen.Contains on a List: O(OΒ²).report += copies the whole string each time: O(total charsΒ²).Fix: build Dictionary<int, Product> once (O(P)), use HashSet<int> for seen, and use StringBuilder. Total becomes O(P + O Β· L + output size), linear in the input.
How to tell it (STAR)
**S:** Finance's month-end report took about four minutes and sometimes timed out. **T:** I was asked to make it reliable before the next close. **A:** I logged the SQL, found an N+1 pattern and a quadratic in-memory lookup, and rewrote the report around one projected query and a dictionary. **R:** It now runs in about [time], uses under [amount] of memory, and finance stopped raising tickets about it.
Last updated 30 Oct 2026.