📁 Sangam Dhakal - Portfolio v2.0 - Netscape Navigator 4.0
_
□
×

πŸ“š Interview question

Code review: this order-reporting code mixes EF Core and LINQ to Objects. It calls ToList() before Where, uses Count() > 0, calls FirstOrDefault on a list inside a loop, does OrderBy().First(), enumerates the same query several times, loads related data inside a foreach, and builds IN lists with Contains over 50,000 ids. Find the LINQ performance mistakes and rewrite it.

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.

Senior CodeReview Advanced code-review csharp ef-core linq performance

🎯 Key points

  • Materialising before filtering (`ToList().Where`) loads whole tables; filter and project in SQL first
  • N+1 queries from loading related data in a loop; use projection, Include or one batched query
  • Lookups inside loops (`list.FirstOrDefault` per item) are O(n x m); build a dictionary or lookup once
  • `Count() > 0` instead of `Any()`; `OrderBy().First()` instead of `MinBy`/`MaxBy` or a SQL TOP 1
  • Multiple enumeration of the same query repeats work or round trips
  • Huge `Contains` lists: SQL parameter limits and plan issues; batch, use a TVP, temp table or join
  • Select only needed columns; use AsNoTracking for read-only; use async with cancellation
  • Measure before and after (SQL logs, Query Store, BenchmarkDotNet, Application Insights)

⏱ 30-second answer

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.

πŸ—£ Full answer (senior)

Code under review (representative)

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();
    ...
}

The problems, in order of impact

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.

The rewrite

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.

How I'd present the review

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.

πŸ” What the interviewer is checking

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.

πŸ“š Learn in depth

1. Measuring the problem

  • EF Core logging (LogTo or Microsoft.EntityFrameworkCore.Database.Command at Information) shows each SQL statement and duration β€” N+1 appears as repeated statements.
  • Application Insights dependency telemetry counts SQL calls per request and their durations.
  • SQL Server Query Store and execution plans show expensive queries and missing indexes.
  • dotnet-counters shows allocation rate and GC impact from loading large graphs.
  • BenchmarkDotNet for in-memory algorithmic changes (list lookup vs dictionary).

2. Complexity of the original vs rewrite

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

3. Contains translation details (SQL Server)

  • EF Core ≀ 7: WHERE [o].[CustomerId] IN (1, 2, 3, ...) with constants β€” new SQL per list, plan cache pollution, and limits for very large lists.
  • EF Core 8+: 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).
  • For tens of thousands of values: TVPs (via FromSql with a SqlParameter of Structured type), bulk-insert into a temp table and join, or redesign the criterion.

4. Split vs single query for collections

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.

5. Server-side aggregation

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.

6. Index support

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).

Try it yourself

  1. Run the original against a seeded database with SQL logging and count statements.
  2. Apply the rewrite and compare SQL count, duration and memory.
  3. Inspect ToQueryString() for Contains with 10 and 50,000 ids on your EF Core version.
  4. Replace in-memory aggregates with a GroupBy aggregate query and compare.

πŸ’» Code

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);
    }
}

⚠ Common mistakes

1. Filtering in memory after 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.

2. N+1 queries

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.

3. List lookups in loops

What happens: list.FirstOrDefault(x => x.Id == id) per item. Why it is wrong: quadratic time. Do instead: ToDictionary once or join in SQL.

4. Sorting to get one item

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).

5. Count() > 0

What happens: existence checks via counting. Why it is wrong: full count instead of early exit. Do instead: Any().

6. Multiple enumeration of deferred queries

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.

7. Huge Contains lists

What 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.

8. Tracking and mutating entities for reports

What happens: writing display values into entities. Why it is wrong: tracking overhead and risk of accidental saves. Do instead: AsNoTracking and DTO projections.

πŸ› Senior insight: trade-offs

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.

πŸ”— Follow-ups the interviewer may ask

  1. When: Candidate proposes projection and AsSplitQuery. Why: Checks the candidate understands how EF Core translates and executes the rewritten query.
    How to answer

    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.

  2. When: Candidate spots the query enumerated three times. Why: Multiple enumeration is a deferred execution problem.
    How to answer

    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.

  3. When: Candidate calls the loop O(n x m). Why: The in-loop lookup is a time-complexity problem.
    How to answer

    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.

πŸ›  Real-world problems: how you handle them

Month-end order report took four minutes
Situation
A Blazor admin portal backed by an ASP.NET Core API and Azure SQL generated a month-end order report used by finance in [team/region].
Signals
Report requests took about 4 minutes and the API pod's memory spiked to 1.5 GB; Application Insights showed over 10,000 SQL dependency calls per report.
Investigation
Enabled EF Core command logging in a staging environment, counted queries (one per order for lines), profiled the in-memory part with dotnet-trace and saw FirstOrDefault scanning a customer list inside the loop.
Resolution
Rewrote the report as one projected query with AsSplitQuery for lines, replaced the in-loop lookup with a dictionary, used MaxBy, and made all calls async with cancellation.
Prevention
Added an integration test asserting the report issues at most three SQL commands, an Application Insights alert on dependency count per request, and LINQ review guidance.

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.

↩ Usually asked after

More in LINQ

Last updated 30 Oct 2026.

🌐 Sangam's Webring

βͺ Previous 🏠 Main Next ⏩
πŸ’¬ Chat with me