← Open in the full interactive course (progress tracking, search & more)

Every mistake on this page compiles, and most of them look completely ordinary in a code review. That's exactly what makes them expensive in production.

This Part has taken you from how IEnumerable<T> and IQueryable<T> actually work, through building your own operators, to naming and understanding LINQ to Objects and LINQ to Entities as genuinely different providers with genuinely different execution models. Every one of those lessons taught you a tool used correctly. This lesson, closing out Part III, looks at the same territory from the opposite direction: the real performance mistakes developers make with these exact tools most often — mistakes that compile cleanly, often even run correctly in a small dev database or a quick local test, and only reveal their real cost once they hit production-scale data.

In this lesson, you'll walk through five real LINQ performance traps spanning both LINQ to Objects and LINQ to Entities — repeated enumeration of an un-materialized query, the N+1 query problem (the most damaging of the five, and treated in the most depth), pulling too much data into memory before filtering, missing AsNoTracking() on read-only EF Core queries, and unnecessary intermediate ToList() calls — see exactly why each one costs what it costs, and close with a concrete checklist for verifying a query's real cost instead of guessing.

What Is It?

The Simple Explanation

A "LINQ performance trap," in the sense used on this page, is code that looks completely reasonable — often even idiomatic — and produces correct results, but does meaningfully more work than the task actually requires: re-running a database query that didn't need to run again, issuing hundreds of small queries where one would do, pulling far more rows or columns across the wire than the final result actually needed, or tracking entities that were never going to be modified.

The Technical Definition

What unites all five traps below is that each one violates something this Part specifically established: that IQueryable<T> execution is deferred and re-runs on every materialization, that EF Core translates LINQ into real SQL with a real network round trip attached, that change tracking has a real per-entity cost, and that yield return-based operators stream rather than buffer. None of these are compiler errors — they're violations of an execution model you now understand in real depth, which is exactly what makes them worth a dedicated, curated tour rather than a vague "be careful" warning.

Why This Lesson Exists

Every mistake in this lesson uses APIs you already know how to use correctly. The danger isn't unfamiliarity with syntax — it's that each mistake looks identical, at a glance, to its correct counterpart. Recognizing the shape of each trap, and knowing what question to ask when reviewing your own or someone else's query, is what actually prevents it from reaching production.

Why Does It Exist?

The Problem — LINQ Performance Bugs Rarely Look Like Bugs

A missing null check crashes loudly, right at the mistake. Most of these don't. A repeated foreach over the same IQueryable<T> variable returns perfectly correct data, every single time — it just quietly re-runs the whole query against the database each time, instead of once. A loop that queries per item works flawlessly against 10 test rows and only becomes visibly slow once a real dataset has thousands. These mistakes are dangerous precisely because they're invisible under the conditions most code review and casual local testing actually happens in.

The Solution — A Curated Tour, Grounded in What This Part Already Taught

The rest of this lesson is exactly that tour: five traps, each shown as suspicious-looking-fine code next to its fix, with the underlying "why" explained using the mechanics — deferred execution, expression tree translation, change tracking, streaming iterators — this Part built up lesson by lesson.

Big Picture

TrapWhat it costsWhich earlier concept it violates
Repeated enumerationRe-runs the whole query — a fresh round trip and SQL execution, every timeDeferred execution / IQueryable<T> materialization
N+1 queriesOne query becomes hundreds or thousandsNavigation properties and joins (this Part, Intermediate relationships)
Over-fetching before filteringPulls far more rows/columns than the result actually needsServer-side vs. client-side evaluation, projection
Missing AsNoTracking()Pays snapshot + change-tracking overhead with zero payoffTracking vs. no-tracking (Intermediate)
Unnecessary ToList()Breaks query composition; forces early, often premature materializationDeferred execution, IQueryable<T> vs. IEnumerable<T>

Notice the pattern: every one of these traces directly back to a mechanism you already understand correctly from earlier in this Part or from Intermediate. This lesson isn't introducing new concepts — it's teaching you to recognize when one of those mechanisms has quietly been misused.

Trap 1 — Multiple Enumeration of an Un-materialized Query

The mistake

IQueryable<Product> query = context.Products
    .Where(p => p.Stock > 0);

int count = query.Count();       // hits the database
var first10 = query.Take(10).ToList(); // hits it AGAIN
bool any = query.Any();          // and AGAIN

The fix

List<Product> products = await context.Products
    .Where(p => p.Stock > 0)
    .ToListAsync();  // ONE round trip

int count = products.Count;
var first10 = products.Take(10).ToList();
bool any = products.Any();

Why it's dangerous: An IQueryable<T> is, as this Part established, a description of a query — not a stored result. Every single materializing call (.Count(), .Any(), .ToList(), a foreach) re-executes the entire query against the database from scratch, independently. The mistake version above hits the database three separate times for data that could have been fetched once. This gets worse, not better, in LINQ to Objects too: an un-materialized IEnumerable<T> built with yield return re-runs its iterator logic on every enumeration as well — if that iterator does expensive work (parsing, computing, calling an external API), each enumeration pays that cost again.

The fix is always the same shape: materialize once, deliberately, into a concrete collection (List<T>, an array) the moment you know you'll need the results more than once — then work against that concrete collection with ordinary LINQ to Objects for anything further.

Trap 2 — The N+1 Query Problem

This is, without real competition, the most common and most damaging real-world EF Core performance bug — worth the most depth of anything in this lesson.

The mistake — 1 query, then N more

List<Order> orders = await context.Orders
    .ToListAsync();                     // query #1

foreach (var order in orders)
{
    // this line runs the Customer's navigation
    // property lazily — ONE query PER order
    Console.WriteLine(order.Customer!.Name);
}
// 1 order-list query + N customer queries = N+1 total

The fix — eager-load with Include

List<Order> orders = await context.Orders
    .Include(o => o.Customer)   // ONE query,
    .ToListAsync();             // with a JOIN

foreach (var order in orders)
    Console.WriteLine(order.Customer!.Name);
// 1 query total, ever — regardless of order count

Why it's dangerous: "N+1" means exactly what it sounds like: 1 query to fetch a list of N items, plus N further queries — one per item — to fetch each item's related data. With 10 orders, that's 11 total round trips to the database instead of 1. With 10,000 orders in production, it's 10,001 — each one a separate network round trip, separate SQL parsing and execution, separate connection-pool churn. A page that felt instant against 20 test rows in development can take tens of seconds — or simply time out — against a realistic production dataset, and the code causing it looks completely unremarkable in review: just a foreach loop reading a property.

The fix, as shown above, is .Include(...) — eager loading the related data as part of the original query, translated into a single SQL JOIN, exactly the navigation-property-to-JOIN pattern from the previous lesson. For projections that only need a few related fields rather than whole related entities, a single well-shaped Select(...) projecting across the navigation property (still one query, one join) is often even better than Include, since it also narrows the columns fetched:

// Also one query — and narrower, since it only projects what's needed var summaries = await context.Orders .Select(o => new { o.Id, CustomerName = o.Customer!.Name }) .ToListAsync();

The one thing every fix shares: N+1 is solved by reshaping the query to fetch everything in a single round trip — never by trying to make each of the N individual queries faster.

Trap 3 — Pulling More Data Into Memory Than the Task Needs

The mistake — filter and project AFTER materializing

List<Product> all = await context.Products
    .ToListAsync();     // EVERY row, EVERY column

var cheap = all
    .Where(p => p.Price < 20)
    .Select(p => p.Name)
    .ToList();

The fix — filter and project close to the database

List<string> cheap = await context.Products
    .Where(p => p.Price < 20)  // narrows rows
    .Select(p => p.Name)       // narrows columns
    .ToListAsync();            // THEN materialize

Why it's dangerous: Calling .ToListAsync() before filtering — as the mistake version does — pulls every column of every row across the network, only to immediately throw most of it away in C#. This is exactly the "client-side vs. server-side evaluation" distinction from earlier in this Part: filtering and projecting before materializing keeps the work inside the translatable IQueryable<T> chain, where EF Core turns it into a narrow WHERE and a narrow SELECT column list — real work the database does efficiently, often using an index. Filtering and projecting after materializing means every one of those savings is thrown away, and the full, unfiltered table crosses the wire regardless.

The rule of thumb: keep .Where(...) and .Select(...) as far "left" in the query chain as possible — as close to the DbSet<T> as the logic allows — and materialize only once, as the very last step.

Trap 4 — Missing AsNoTracking() on a Read-Only Query

The mistake

[HttpGet]
public async Task<IActionResult> GetAll()
{
    // returned as read-only JSON — SaveChanges()
    // is never going to be called on these
    var products = await context.Products
        .ToListAsync();  // tracked, by default

    return Ok(products);
}

The fix

[HttpGet]
public async Task<IActionResult> GetAll()
{
    var products = await context.Products
        .AsNoTracking()   // skip snapshotting
        .ToListAsync();

    return Ok(products);
}

Why it's dangerous: As the Intermediate tracking-vs-no-tracking lesson covered in depth, every tracked entity costs a snapshot of its original values plus an entry in the change tracker — real memory and real CPU work, paid on every single request, for bookkeeping that only ever pays off if SaveChanges() is later called on that entity. A read-only listing endpoint that never calls SaveChanges() pays that cost on every request, forever, for zero benefit. It's individually a small cost per entity — but multiplied across every request, on a high-traffic read endpoint, it's a real, measurable, entirely avoidable tax.

The rule from that earlier lesson still applies exactly here: ask, per query, "will anything this query returns be modified and saved?" If the honest answer is no, .AsNoTracking() is free performance with no downside.

Trap 5 — Unnecessary Intermediate ToList() Calls Breaking Composition

The mistake

var step1 = context.Products
    .Where(p => p.Stock > 0)
    .ToList();               // materializes HERE

var step2 = step1
    .Where(p => p.Price < 50)  // now LINQ to Objects —
    .ToList();                 // the second filter never
                                // reaches the database at all

The fix — compose first, materialize once

List<Product> results = await context.Products
    .Where(p => p.Stock > 0)
    .Where(p => p.Price < 50)
    .ToListAsync();  // BOTH filters translate to SQL,
                     // ONE round trip, ONE narrowed result

Why it's dangerous: An intermediate .ToList() in the middle of a query chain does two costly things at once: it forces materialization earlier than necessary (an unfiltered-so-far result crosses the network), and it silently switches every operator chained afterward from LINQ to Entities to LINQ to Objects — the second .Where(...) above never becomes SQL at all; it runs in memory, against data that should have been filtered by the database in the first place. This is the mirror image of Trap 3: composing filters and projections into one continuous IQueryable<T> chain, and materializing only once at the very end, lets EF Core see and translate the whole query together — often more efficiently than the sum of two separate round trips ever could be.

Analogy

One Delivery Truck vs. a Thousand Separate Trips

Imagine ordering supplies for 500 stores from a central warehouse. The efficient way: one shipping order lists everything needed for all 500 stores, and one truck route delivers it all in a single, well-planned trip — that's Include(...), or a single well-shaped projection, replacing N+1. The trap: sending a separate truck, one at a time, to fetch each store's supplies individually — technically correct, every store gets what it needs, but 500 separate trips instead of one, each with its own loading time, its own fuel, its own delay. Nobody would design a supply chain that way on purpose; it happens by accident, one line of ordinary-looking code at a time, exactly the way N+1 does.

Under the Hood — How to Actually Verify a Query's Real Cost

A CHECKLIST — LOOK, DON'T GUESS
1. Look at the ACTUAL generated SQL
2. Measure, don't estimate
3. Test against realistic data volumes, not toy datasets
4. Don't guess — the tools exist precisely so you don't have to

Common Confusion

1. "N+1 only matters for huge datasets"

It's most visible at scale, but the extra round trips exist even for a small N — they're just fast enough, individually, to go unnoticed. The real danger is that N+1 code passes review and passes casual testing precisely because small-scale symptoms are mild; the underlying shape of the query (one per item, in a loop) is the actual defect, regardless of how many items happen to be in the loop today. Data grows; the code causing N+1 usually doesn't get revisited until it already hurts.

2. "AsNoTracking() and repeated enumeration are unrelated concerns"

They compound. A tracked query that's also enumerated multiple times pays the full snapshot-and-tracking cost again on every re-enumeration — each fresh call to .ToListAsync() against the same un-materialized IQueryable<T> both re-runs the SQL and re-creates tracking entries for every returned row. Fixing either trap alone helps; recognizing that these traps stack is what separates a merely-acceptable fix from a genuinely well-optimized query.

When Should I Use This Checklist?

Apply this scrutiny when

Don't over-apply it

Rule of thumb: Filter and project as early and as close to the data source as possible. Materialize exactly once, exactly when you actually need concrete results. Fetch related data in the same query it belongs with, not in a loop afterward. And when in doubt about any query's real cost — look at the generated SQL and measure. Don't guess.

Mental Model

One query, once, as narrow as possible, as close to the data as possible.

Remember:
· An IQueryable<T> re-runs on every materialization — materialize once, deliberately.
· A loop that queries per item is N+1 — fetch related data in the original query instead.
· Filter and project before materializing, not after.
· No SaveChanges() planned? AsNoTracking() is free.
· An intermediate .ToList() silently ends translation for everything chained after it.
· When unsure what a query actually costs — look at the SQL, and measure.

Key Takeaway


Check Your Understanding

You've toured five real production LINQ performance traps, and how to actually verify a query's cost instead of guessing. Let's confirm the reasoning stuck — this is Part III's final check.

1. A developer loads a list of 200 orders with context.Orders.ToListAsync(), then loops over them accessing order.Customer.Name for each one (a lazily-loaded navigation property). How many total database queries does this cause, and what's the fix?

Show answer

Correct: B

Why B is correct: As covered in Trap 2, this is the textbook N+1 shape: 1 query to fetch the orders, plus 1 additional query per order to lazily fetch its customer — 201 total. .Include(...) (or an equivalent single projection) folds the customer data into the original query as a JOIN, bringing the total back down to 1.

Why A is incorrect: Navigation properties are not included automatically — without .Include(...) (or an explicit projection), accessing one lazily triggers a separate query, exactly the mistake this trap describes.

Why C is incorrect: Fetching the initial 200 orders is itself a real query — it's the "+1" in "N+1," alongside the N per-order queries that follow.

Why D is incorrect: Accessing a lazily-loaded navigation property is exactly what triggers an additional database query — that's the entire mechanism behind N+1.

Reinforcement: N+1 is solved by reshaping the query to fetch everything the loop needs up front, in one round trip — never by trying to speed up each of the N individual queries.

2. Why does calling .Count(), then .ToList(), then .Any() — all on the same un-materialized IQueryable<Product> variable — cause three separate database round trips instead of one?

Show answer

Correct: B

Why B is correct: As explained in Trap 1, this is deferred execution doing exactly what it always does — an IQueryable<T> is a description, not a cached answer, so each materializing method independently re-runs the full query against the database.

Why A is incorrect: There is no automatic caching of query results — this is precisely the misconception this trap exists to correct; each call genuinely re-executes.

Why C is incorrect: These methods can be called in any order — the repeated-execution behavior happens regardless of call order, since each one independently triggers its own full query.

Why D is incorrect: All three calls can use the same DbContext instance — the repeated execution is about the query being re-run, not about context lifecycle.

Reinforcement: Materialize an IQueryable<T> once, into a concrete collection, the moment you know its results will be used more than once.

3. A query chain has an unnecessary .ToList() in the middle: context.Products.Where(a).ToList().Where(b).ToList(). Beyond the extra materialization cost, what else goes wrong?

Show answer

Correct: B

Why B is correct: As covered in Trap 5, once .ToList() materializes the query, the result is a plain List<Product> — every operator chained after it, including the second .Where(b), resolves to LINQ to Objects instead of LINQ to Entities, running entirely in memory against data that was never filtered by b on the database side.

Why A is incorrect: The consequence is more than memory overhead — it's a genuine loss of translation, meaning the second filter provides none of the server-side efficiency the first one did.

Why C is incorrect: Multiple .Where(...) calls in a single translatable chain are completely normal and supported — the problem here is specifically the intermediate .ToList() breaking that chain into two separate pieces, not the number of filters.

Why D is incorrect: EF Core has no such automatic detection or optimization — the composition is entirely the developer's responsibility to get right.

Reinforcement: Compose your entire filter/project/sort chain against the IQueryable<T> first, and materialize exactly once, at the end — an intermediate .ToList() silently and permanently ends translation for everything chained after it.

4. A team wants to confirm whether a specific LINQ query is causing an N+1 problem in production, rather than guessing based on how the code looks. According to this lesson's closing checklist, what's the most direct way to verify it?

Show answer

Correct: B

Why B is correct: As laid out in Under the Hood, query logging and .ToQueryString() show the actual SQL EF Core generates and executes — N+1 shows up unmistakably as the same query shape repeated many times, which is exactly the kind of concrete evidence this checklist recommends over guessing.

Why A is incorrect: Reading code can suggest a suspicion, but confirming an N+1 problem requires seeing the actual queries executed — code review alone can miss lazy-loading behavior that isn't visually obvious in the loop itself.

Why C is incorrect: This is exactly the trap Common Confusion warns about — small local datasets mask N+1's real cost; a fast local response proves nothing about production-scale behavior.

Why D is incorrect: Changing code without measuring either the before or after state is guessing, not verifying — the entire point of this closing checklist is to replace guesswork with concrete evidence.

Reinforcement: "Look at the generated SQL and measure against realistic data" is the concrete, repeatable way to confirm a LINQ performance problem — not intuition, and not a quick local smoke test.

You've completed Part III — LINQ Deep Dive. You can now design real custom LINQ operators, speak precisely about LINQ to Objects versus LINQ to Entities, explain EF Core's translation pipeline and its real edges, read LINQ and predict the SQL underneath it, and — critically — recognize the production performance traps that catch even experienced teams off guard. That's the whole arc, from mechanics to mastery.


dotnetmadeeasy.com — Learn C# and .NET, the right way.