DEV Community

Cover image for LINQ (Language Integrated Query) in C#
Rhuturaj Takle
Rhuturaj Takle

Posted on

LINQ (Language Integrated Query) in C#

LINQ (Language Integrated Query) in C

A deep-dive walkthrough of LINQ in C# — covering method syntax vs. query syntax, deferred vs. immediate execution and the bugs that arise from confusing them, the standard query operators grouped by purpose, how LINQ is really just extension methods built on the delegates and generics covered elsewhere in this series, LINQ to Objects vs. LINQ to Entities/SQL and expression trees, performance considerations, and the trade-offs that determine when LINQ is the clearer choice versus a plain loop.


Table of Contents

  1. Introduction
  2. What LINQ Actually Is
  3. Method Syntax vs. Query Syntax
  4. Deferred Execution: The Single Most Important LINQ Concept
  5. Immediate Execution: ToList, ToArray, and the Conversion/Aggregation Operators
  6. The Standard Query Operators, Grouped by Purpose
  7. IEnumerable<T> and Extension Methods: How LINQ Is Actually Built
  8. LINQ to Objects vs. LINQ to Entities/SQL: Expression Trees
  9. Multiple Enumeration: A Genuinely Common Bug
  10. Performance Considerations
  11. Composing Queries: Why Deferred Execution Enables This
  12. LINQ and Async: IAsyncEnumerable<T>
  13. When LINQ Is the Wrong Tool
  14. Common Pitfalls
  15. Quick Reference Table
  16. Conclusion

Introduction

LINQ lets you query and transform collections — in-memory objects, database tables, XML, and more — using a consistent, declarative syntax directly inside C#, rather than writing imperative loops by hand or dropping into a separate query language for each different data source. Under the hood, LINQ isn't a separate feature bolted onto the language; it's built almost entirely from mechanisms this series has already covered in depth — generic interfaces (IEnumerable<T>), extension methods, and delegates (Func<T, TResult>, most commonly as lambdas) — composed together into the fluent, chainable style most C# developers know as .Where(...).Select(...). This guide walks through LINQ's mechanics in depth: the two equivalent syntaxes, deferred execution (arguably the single concept most responsible for LINQ-related bugs when misunderstood), the standard operators grouped by what they actually do, and how LINQ to Objects differs fundamentally from LINQ to Entities/SQL via expression trees.

var expensiveProducts = products
    .Where(p => p.Price > 100)      // FILTER — keep only matching elements
    .OrderBy(p => p.Name)            // SORT — order the remaining elements
    .Select(p => p.Name);            // PROJECT — transform each element into something else

// Nothing has actually run yet (Section 3) — this just describes the query.
// Iterating `expensiveProducts` (a foreach, or ToList()) is what actually executes it.
Enter fullscreen mode Exit fullscreen mode

1. What LINQ Actually Is

A consistent query syntax across genuinely different data sources

LINQ to Objects   →  querying in-memory collections (List<T>, arrays, etc.)
LINQ to Entities  →  querying a database through Entity Framework
LINQ to XML       →  querying XML documents
Others            →  LINQ to SQL, PLINQ (parallel), and various third-party providers
Enter fullscreen mode Exit fullscreen mode

The genuinely distinctive idea behind LINQ is that the same Where, Select, OrderBy syntax works whether you're filtering an in-memory List<Customer> or filtering rows in a SQL Server table through Entity Framework — the syntax is unified, even though (as Section 7 covers in depth) what actually happens underneath is fundamentally different depending on the data source.

Every LINQ query is built from three things you already understand from this series

IEnumerable<T> (generics)   →  the sequence being queried
Extension methods            →  how Where/Select/OrderBy attach themselves to that sequence
Func<T, TResult> (delegates) →  the lambda you pass in, describing what each operator should do
Enter fullscreen mode Exit fullscreen mode

There's no new fundamental language feature here — LINQ is a library, written using the extension method and generic delegate mechanisms this series' Generics and Delegates guides already cover, applied to IEnumerable<T> specifically. Understanding this is what makes LINQ feel like a natural extension of C# rather than a separate thing to memorize, and Section 6 walks through exactly how this composition works.


2. Method Syntax vs. Query Syntax

Method syntax: chained extension method calls, the more commonly used form

var expensiveProducts = products
    .Where(p => p.Price > 100)
    .OrderBy(p => p.Name)
    .Select(p => p.Name);
Enter fullscreen mode Exit fullscreen mode

This is what most real-world C# code looks like — a chain of method calls, each one an extension method on IEnumerable<T> (Section 6), each taking a lambda describing what it should do. It reads left-to-right in the order operations actually apply, which most developers find intuitive once they're used to it.

Query syntax: SQL-like keywords, translated by the compiler into method syntax

var expensiveProducts =
    from p in products
    where p.Price > 100
    orderby p.Name
    select p.Name;
Enter fullscreen mode Exit fullscreen mode

This is functionally, exactly identical to the method syntax version above — the C# compiler translates from/where/orderby/select directly into the equivalent chain of Where/OrderBy/Select calls at compile time. Query syntax exists as an alternative, more SQL-familiar surface over the exact same underlying mechanism; it isn't a different LINQ, just different C# syntax for producing identical compiled code.

Why method syntax dominates in practice, and where query syntax still shines

// Query syntax handles a multi-source JOIN more readably than the method-syntax equivalent
var results =
    from order in orders
    join customer in customers on order.CustomerId equals customer.Id
    select new { order.Id, customer.Name };

// The equivalent in method syntax is noticeably more awkward to read
var results2 = orders.Join(customers, o => o.CustomerId, c => c.Id, (o, c) => new { o.Id, c.Name });
Enter fullscreen mode Exit fullscreen mode

Method syntax covers the full range of LINQ operators (some, like Count() or FirstOrDefault(), have no query-syntax equivalent at all and must be called as methods regardless), while query syntax is limited to a smaller subset of operators but reads more naturally for genuinely SQL-like operations, particularly joins and grouping with multiple clauses — many real codebases use query syntax specifically for a multi-table join and method syntax for everything else, mixing the two deliberately based on which reads more clearly for a given query's shape.


3. Deferred Execution: The Single Most Important LINQ Concept

A LINQ query doesn't run when you write it — it runs when you enumerate it

var query = products.Where(p => p.Price > 100); // NOTHING has executed yet — this just builds a description

Console.WriteLine("Query created, but not yet run.");

foreach (var product in query) // execution actually happens HERE, one element at a time
{
    Console.WriteLine(product.Name);
}
Enter fullscreen mode Exit fullscreen mode

This is the single most important concept in all of LINQ, and the source of more real-world bugs and confusion than any other aspect of the feature: writing products.Where(...) does not filter anything immediately — it builds a lazy, unexecuted description of the operation, which only actually runs when something iterates over it (a foreach, or a call to ToList(), Count(), First(), and similar). Until that moment, the query is inert.

Why this matters: a deferred query re-evaluates against the CURRENT state of its source, every time

var numbers = new List<int> { 1, 2, 3 };
var evenNumbers = numbers.Where(n => n % 2 == 0); // deferred — not executed yet

numbers.Add(4); // mutating the SOURCE list, AFTER the query was defined but BEFORE it's enumerated

foreach (var n in evenNumbers) Console.WriteLine(n); // prints 2 AND 4 — the query saw the list's CURRENT state
Enter fullscreen mode Exit fullscreen mode

This is a genuinely surprising behavior to developers who assume Where immediately "snapshots" the matching elements at the point it's called — it doesn't; it re-evaluates against whatever the source collection actually contains at the moment of enumeration, which means a query defined once can produce different results on different enumerations if the underlying source changes in between, or even between two separate foreach loops over the same query variable.

Iterator blocks and yield return: how deferred execution is actually implemented

// A simplified illustration of what a deferred LINQ operator looks like underneath
public static IEnumerable<T> Where<T>(this IEnumerable<T> source, Func<T, bool> predicate)
{
    foreach (var item in source)
    {
        if (predicate(item))
            yield return item; // execution PAUSES here, resumes on the NEXT MoveNext() call
    }
}
Enter fullscreen mode Exit fullscreen mode

C#'s yield return (an iterator block) is the language feature underneath deferred execution — a method using yield return doesn't run to completion when called; it returns a state machine that produces one element at a time, only as the consumer asks for the next one via MoveNext(). This is why a foreach over a LINQ query genuinely processes elements one at a time, pulling from the source lazily, rather than computing the entire result set upfront.


4. Immediate Execution: ToList, ToArray, and the Conversion/Aggregation Operators

Forcing a query to run right now, and capture a fixed snapshot of the result

var numbers = new List<int> { 1, 2, 3 };
var evenNumbersSnapshot = numbers.Where(n => n % 2 == 0).ToList(); // executes IMMEDIATELY, right here

numbers.Add(4); // this mutation has NO effect on evenNumbersSnapshot — it's already a fixed List<int>

Console.WriteLine(evenNumbersSnapshot.Count); // still 1 (just the "2" that existed at ToList() time)
Enter fullscreen mode Exit fullscreen mode

.ToList() (and .ToArray(), .ToDictionary(), .ToHashSet()) forces the deferred query to actually execute right then, materializing a real, independent, in-memory collection — unlike Section 3's deferred version, this snapshot is completely disconnected from any later changes to the source.

Aggregation operators are also immediate — they have to be, by their nature

int count = products.Where(p => p.Price > 100).Count();       // must enumerate everything to count it — immediate
decimal total = products.Sum(p => p.Price);                    // must visit every element to sum them — immediate
Product cheapest = products.OrderBy(p => p.Price).FirstOrDefault(); // must find the actual first result — immediate
Enter fullscreen mode Exit fullscreen mode

Operators that produce a single, final value rather than another sequence (Count(), Sum(), Average(), Max(), Min(), First(), FirstOrDefault(), Any(), All()) are inherently immediate — there's no meaningful way to "defer" producing a single number or a single element, since producing it requires actually running the query to at least some extent.

Choosing between deferred and immediate deliberately

Deferred is appropriate when: the query will be enumerated once, shortly
  after being defined, and the source isn't expected to change in between —
  or when composing a larger query (Section 10) from smaller pieces.
Immediate (ToList/ToArray) is appropriate when: you need a stable snapshot
  independent of later source mutations, or when the SAME query result will
  be enumerated multiple times (Section 8 covers why re-enumerating a
  deferred query repeatedly is often a real, avoidable performance cost).
Enter fullscreen mode Exit fullscreen mode

This is a genuine, deliberate design decision worth making explicitly rather than defaulting blindly to one or the other — Section 8 and Section 9 cover the concrete costs of getting this choice wrong in either direction.


5. The Standard Query Operators, Grouped by Purpose

Filtering: keep only elements matching a condition

var adults = people.Where(p => p.Age >= 18);
var firstAdult = people.First(p => p.Age >= 18);      // throws if none match
var firstAdultOrNull = people.FirstOrDefault(p => p.Age >= 18); // returns default(T) if none match
Enter fullscreen mode Exit fullscreen mode

Where is the workhorse filtering operator; First/FirstOrDefault combine filtering with taking exactly one result, differing in how they handle the "nothing matched" case — First throws an exception, FirstOrDefault returns default (per this series' Generics guide's Section 10 discussion of exactly what that means for a given T).

Projection: transform each element into something else

var names = people.Select(p => p.Name);                              // one-to-one transformation
var allPets = people.SelectMany(p => p.Pets);                        // flattens a collection-of-collections into one sequence
var summaries = people.Select((p, index) => $"{index}: {p.Name}");   // the overload exposing each element's index
Enter fullscreen mode Exit fullscreen mode

Select is a one-to-one transformation (each input element produces exactly one output element); SelectMany is specifically for flattening — when each input element itself produces a sequence, SelectMany concatenates all of those sequences into one flat result, rather than producing a sequence-of-sequences the way Select would.

Ordering

var sorted = products.OrderBy(p => p.Price);                          // ascending
var sortedDesc = products.OrderByDescending(p => p.Price);            // descending
var multiSort = products.OrderBy(p => p.Category).ThenBy(p => p.Price); // secondary sort key
Enter fullscreen mode Exit fullscreen mode

ThenBy/ThenByDescending chain onto an existing OrderBy to add secondary (and further) sort keys — worth knowing that calling OrderBy twice in a row does not achieve this; the second OrderBy call would simply re-sort everything by its own key, discarding the first sort entirely, which is exactly why ThenBy exists as a distinct operator rather than OrderBy being chainable directly.

Grouping

var byCategory = products.GroupBy(p => p.Category);

foreach (var group in byCategory)
{
    Console.WriteLine($"{group.Key}: {group.Count()} products"); // group.Key is the grouping value
    foreach (var product in group) Console.WriteLine($"  {product.Name}"); // each group IS itself an IEnumerable<T>
}
Enter fullscreen mode Exit fullscreen mode

GroupBy produces a sequence of groups, where each group is both a key (group.Key, the value the elements were grouped by) and, itself, an IEnumerable<T> of the elements sharing that key — this is the LINQ equivalent of a SQL GROUP BY, and it's a genuinely common operator once querying anything beyond a flat, single-level filter/sort.

Aggregation

int total = products.Count();
decimal sum = products.Sum(p => p.Price);
decimal average = products.Average(p => p.Price);
Product mostExpensive = products.MaxBy(p => p.Price); // (C# 10+) — the ELEMENT with the max value, not just the value
decimal customAggregate = products.Aggregate(0m, (runningTotal, p) => runningTotal + p.Price * 1.1m); // fully custom
Enter fullscreen mode Exit fullscreen mode

Aggregate is the most general-purpose of these — it's a fold/reduce operation, taking a starting value (called the "seed") and a function combining the running result with each element in turn, which lets you express essentially any aggregation the more specific operators (Sum, Average, Max) don't directly provide.

Set operations

var union = listA.Union(listB);             // all UNIQUE elements from either list
var intersection = listA.Intersect(listB);  // only elements present in BOTH
var difference = listA.Except(listB);       // elements in listA that are NOT in listB
var distinct = products.Distinct();         // removes duplicates from a single sequence
Enter fullscreen mode Exit fullscreen mode

These treat sequences as mathematical sets — worth knowing they rely on the elements' equality comparison (per this series' OOP guide's discussion of value vs. reference equality) to determine what counts as "the same" element, which matters especially for Distinct() and Union() on custom reference types that haven't overridden Equals/GetHashCode.

Partitioning and quantifying

var firstThree = products.Take(3);
var skipFirstThree = products.Skip(3);
var page2 = products.Skip(10).Take(10); // a common pagination pattern: skip the first page, take the second
bool anyExpensive = products.Any(p => p.Price > 1000);  // TRUE if AT LEAST ONE matches
bool allInStock = products.All(p => p.InStock);          // TRUE only if EVERY element matches
Enter fullscreen mode Exit fullscreen mode

Skip/Take are the standard building blocks for pagination; Any/All are quantifier operators answering "does at least one match" or "do all match" without needing to manually loop and check.


6. IEnumerable<T> and Extension Methods: How LINQ Is Actually Built

Every LINQ operator is an extension method on IEnumerable<T>

public static class Enumerable // this IS the real, actual class in System.Linq
{
    public static IEnumerable<T> Where<T>(this IEnumerable<T> source, Func<T, bool> predicate)
    {
        foreach (var item in source)
            if (predicate(item)) yield return item;
    }
}
Enter fullscreen mode Exit fullscreen mode

This is, in simplified form, genuinely what Where looks like inside .NET's own System.Linq namespace — an extension method (the this IEnumerable<T> source parameter is what makes it callable as products.Where(...) rather than Enumerable.Where(products, ...)), taking a Func<T, bool> delegate (per this series' Delegates guide) as its filtering logic, and using yield return (Section 3) to implement deferred execution.

This is why any custom type implementing IEnumerable<T> gets the ENTIRE LINQ operator set for free

public class ProductCatalog : IEnumerable<Product>
{
    private readonly List<Product> _products = new();
    public IEnumerator<Product> GetEnumerator() => _products.GetEnumerator();
    IEnumerator IEnumerable.GetEnumerator() => GetEnumerator();
}

var catalog = new ProductCatalog();
var expensive = catalog.Where(p => p.Price > 100); // works — ProductCatalog gets ALL of LINQ, automatically
Enter fullscreen mode Exit fullscreen mode

Because Where, Select, and every other standard operator are extension methods defined generically on IEnumerable<T>, any type that implements that one interface — even a completely custom collection type you wrote yourself — automatically gains the full range of LINQ operators, with zero additional code, exactly the kind of broad, automatic reuse this series' Generics guide's Section 1 identifies as generics' core value proposition, here applied at the scale of an entire library's worth of operators.


7. LINQ to Objects vs. LINQ to Entities/SQL: Expression Trees

LINQ to Objects: the lambda is compiled to ordinary, executable code

var expensiveProducts = products.Where(p => p.Price > 100); // products is a List<Product>, IN MEMORY

// The lambda `p => p.Price > 100` is compiled to a real Func<Product, bool> DELEGATE —
// ordinary, JIT-compiled machine code that executes directly against each in-memory Product object.
Enter fullscreen mode Exit fullscreen mode

For IEnumerable<T>-based LINQ (querying in-memory collections), the lambda you write is compiled exactly as this series' Delegates guide describes — into a real, executable delegate that runs directly against each element in memory, one at a time, per Section 3's deferred iteration.

LINQ to Entities: the SAME lambda syntax, but compiled to a data structure describing the lambda, not executable code

IQueryable<Product> query = dbContext.Products.Where(p => p.Price > 100); // dbContext.Products is IQueryable<T>

// The SAME-LOOKING lambda `p => p.Price > 100` is here compiled to an EXPRESSION TREE —
// a data structure describing "compare a property access to a constant," which Entity Framework
// then TRANSLATES into an actual SQL WHERE clause, executed on the database server, not in .NET at all.
Enter fullscreen mode Exit fullscreen mode

This is the crucial, easy-to-miss distinction: IQueryable<T> (as opposed to IEnumerable<T>) causes the same lambda syntax to be compiled into an expression tree — a data structure representing the lambda's logic as data (an object graph describing "this is a property access," "this is a greater-than comparison," "this is a constant 100") rather than as executable code. A LINQ provider like Entity Framework walks that expression tree and translates it into the target query language (SQL, in EF's case), meaning the actual filtering happens on the database server, not by pulling every row into .NET memory first and filtering there.

Why this distinction matters practically: not every C# expression can be translated

// ❌ This throws at RUNTIME (or, in some providers, silently pulls everything into memory first) —
//    a custom C# method has no SQL equivalent the provider knows how to translate
var results = dbContext.Products.Where(p => MyCustomBusinessLogic(p)).ToList();

// ✅ This translates cleanly — simple property comparisons map directly to SQL
var results2 = dbContext.Products.Where(p => p.Price > 100 && p.Category == "Electronics").ToList();
Enter fullscreen mode Exit fullscreen mode

Because IQueryable<T> providers have to translate the expression tree into a genuinely different query language, they can only support a subset of what you could write in ordinary C# — arbitrary method calls, complex control flow, or anything without a clean SQL equivalent will either throw an exception at query-execution time or, in some cases, silently fail to translate as intended. This is a real, practical constraint worth knowing exists — LINQ to Entities queries need to be written with translatability in mind, which is a meaningfully different discipline than writing ordinary LINQ to Objects code, even though the syntax looks identical.


8. Multiple Enumeration: A Genuinely Common Bug

Enumerating a deferred query more than once re-runs it, every time

IEnumerable<Product> expensiveProducts = GetExpensiveProductsFromDatabase(); // returns a deferred IQueryable/IEnumerable

int count = expensiveProducts.Count();       // executes the query ONCE, hits the database
var list = expensiveProducts.ToList();       // executes the query AGAIN, hits the database a SECOND time
foreach (var p in expensiveProducts) { }     // executes it a THIRD time
Enter fullscreen mode Exit fullscreen mode

Because a deferred query is a description, not a stored result, each separate enumeration — Count(), ToList(), a foreach — re-runs the underlying logic entirely from scratch. For an in-memory IEnumerable<T>, this is wasteful but usually harmless; for an IQueryable<T> backed by a database (Section 7), this means multiple, entirely separate round trips to the database for what looks like "the same data" referenced multiple times in the code.

The straightforward fix: materialize once, reuse the materialized result

var expensiveProducts = GetExpensiveProductsFromDatabase().ToList(); // executes ONCE, right here

int count = expensiveProducts.Count();   // operates on the in-memory List<T> — no further database hits
var firstFew = expensiveProducts.Take(3); // still deferred, but over an in-memory list — cheap regardless
foreach (var p in expensiveProducts) { } // no additional database round trip
Enter fullscreen mode Exit fullscreen mode

Calling .ToList() (or .ToArray()) once, immediately after the query is defined and before it's used multiple times, converts a potentially expensive, repeatable deferred query into a single, fixed, in-memory snapshot — every subsequent use operates against that snapshot rather than re-triggering the original, potentially costly operation.


9. Performance Considerations

LINQ operators generally aren't free, even when the code reads simply

// This looks simple, but under the hood it iterates the source THREE separate times conceptually
// (though the JIT and streaming nature of iterators mitigate this more than it might first appear)
var result = products.Where(p => p.InStock).OrderBy(p => p.Price).Select(p => p.Name).ToList();
Enter fullscreen mode Exit fullscreen mode

Each chained operator wraps the previous one in another layer of iterator (per Section 3's yield return mechanism) — this is generally efficient in practice because of how iterators stream data element-by-element rather than materializing intermediate collections at each step, but it's not literally free, and a very hot code path with extremely tight performance requirements may still be measurably faster as a hand-written loop.

OrderBy specifically requires seeing the entire sequence before producing any output

Unlike Where or Select, which can process and yield elements one at a time
  as they're encountered, OrderBy fundamentally cannot produce its first
  result until it has seen EVERY element in the source — sorting requires
  the full data set. This is worth knowing when chaining OrderBy with Take:
Enter fullscreen mode Exit fullscreen mode
var top3 = products.OrderBy(p => p.Price).Take(3); // OrderBy must still process the WHOLE sequence
                                                      // internally before Take(3) can select the first 3
Enter fullscreen mode Exit fullscreen mode

This is a subtlety worth being aware of: Take(3) doesn't let OrderBy somehow skip work on the rest of the sequence — sorting inherently requires the complete picture before any element's final position is known, so OrderBy(...).Take(3) still does the full sort's worth of work, even though only 3 results are ultimately consumed.

LINQ to Entities: the real performance cost is usually the database round trip, not the C# code

// ❌ Pulls the ENTIRE table into memory, THEN filters in .NET — the filter never reaches the database
var expensive = dbContext.Products.ToList().Where(p => p.Price > 100);

// ✅ The filter is part of the EXPRESSION TREE (Section 7), translated into SQL,
//    so only matching rows are ever transferred from the database at all
var expensive2 = dbContext.Products.Where(p => p.Price > 100).ToList();
Enter fullscreen mode Exit fullscreen mode

This is a genuinely common, costly mistake specific to IQueryable<T> sources — calling .ToList() too early (before the filtering/sorting operators) forces the entire table to be pulled into application memory first, after which any further LINQ operators run as ordinary, in-memory LINQ to Objects rather than being translated into SQL — the fix is simply ordering your operators so filtering and sorting happen before the .ToList() that materializes the result, letting the database do that work instead of your application's memory and CPU.


10. Composing Queries: Why Deferred Execution Enables This

Building a query incrementally, across multiple statements

IEnumerable<Product> query = products;

if (onlyInStock)
    query = query.Where(p => p.InStock); // still deferred — nothing has run yet

if (!string.IsNullOrEmpty(categoryFilter))
    query = query.Where(p => p.Category == categoryFilter); // ALSO still deferred, layering onto the previous query

var results = query.OrderBy(p => p.Name).ToList(); // NOW it finally executes, with EVERY condition applied at once
Enter fullscreen mode Exit fullscreen mode

This conditional, incremental query-building pattern is only possible because of Section 3's deferred execution — each .Where(...) call doesn't run anything, it just wraps the previous query in another layer, so you can build up a complex, conditional query across multiple lines (or even multiple methods) and only pay the actual execution cost once, at the very end, with every condition correctly composed together.

For IQueryable<T> sources, this composition translates into a single, combined SQL query

Per Section 7: because each `.Where(...)` call above is building up an
  EXPRESSION TREE rather than running anything, Entity Framework sees the
  FULLY composed query only at the point of `.ToList()`, and translates
  the ENTIRE thing into one SQL query with a combined WHERE clause — not
  several separate round trips, one per condition.
Enter fullscreen mode Exit fullscreen mode

This is a genuinely elegant, practical benefit of expression-tree-based composition specifically: conditionally building a complex filter across several C# statements still results in exactly one database round trip and one SQL query, with all the conditions combined correctly into a single WHERE clause — the deferred, compositional nature of LINQ is what makes this possible without manually building a SQL string yourself.


11. LINQ and Async: IAsyncEnumerable<T>

Why ordinary foreach over IEnumerable<T> can't be async

IEnumerable<T>'s MoveNext() is a synchronous call — there's no way to
  `await` fetching the next element without blocking the calling thread,
  which is exactly the problem this series' guides on async/await elsewhere
  are built to avoid for anything genuinely I/O-bound (a database query
  streaming results, for instance).
Enter fullscreen mode Exit fullscreen mode

For genuinely asynchronous data sources (streaming results from a database or network call, where each "next element" might require waiting on I/O), ordinary IEnumerable<T> and its synchronous iteration protocol are the wrong fit.

IAsyncEnumerable<T> and await foreach

public async IAsyncEnumerable<Product> GetProductsStreamAsync()
{
    await foreach (var row in _dbConnection.QueryStreamAsync("SELECT * FROM Products"))
    {
        yield return MapToProduct(row); // yields asynchronously, one element at a time
    }
}

await foreach (var product in GetProductsStreamAsync()) // consuming side ALSO uses await foreach
{
    Console.WriteLine(product.Name);
}
Enter fullscreen mode Exit fullscreen mode

IAsyncEnumerable<T> (introduced in C# 8) is the asynchronous counterpart to IEnumerable<T>, and await foreach is its consumption syntax — this lets a sequence be produced and consumed one element at a time, with genuine await-based asynchrony at each step, which matters for large, streamed result sets where you don't want to wait for (or hold in memory) the entire result before processing can begin. LINQ itself has a growing but still more limited set of operators supporting IAsyncEnumerable<T> directly (via the separate System.Linq.Async package) compared to the full standard operator set available for IEnumerable<T>.


12. When LINQ Is the Wrong Tool

A tight, hot loop where the overhead of iterator chaining genuinely matters

// In an extremely hot path, a hand-written loop can be measurably faster
// than an equivalent LINQ chain, due to iterator/delegate call overhead
decimal total = 0;
foreach (var p in products) { if (p.InStock) total += p.Price; } // vs. products.Where(...).Sum(...)
Enter fullscreen mode Exit fullscreen mode

For the overwhelming majority of code, this difference is genuinely immaterial and not worth the reduced readability of a hand-written loop — but in a demonstrably hot path (profiled, not assumed), where this specific operation runs millions of times per second, the small per-element overhead LINQ's chained iterators and delegate calls introduce can become a real, measurable cost worth avoiding.

Complex, highly imperative logic that doesn't map cleanly onto declarative operators

Logic genuinely requiring early exits with complex conditions, mutable
  accumulator state touched from multiple branches, or side effects
  interleaved with control flow in ways LINQ's operators don't cleanly
  express, is often clearer as an ordinary loop than as a forced, contorted
  LINQ chain.
Enter fullscreen mode Exit fullscreen mode

LINQ shines for genuinely declarative "filter, transform, aggregate" operations — forcing inherently imperative, stateful, or branch-heavy logic into a LINQ chain purely for style points often produces code that's harder to read than the equivalent loop would have been, which runs directly counter to LINQ's actual value proposition of improving clarity.


13. Common Pitfalls

Pitfall Why it hurts Better approach
Assuming a LINQ query executes at the point it's written A deferred query re-evaluates against the source's CURRENT state at enumeration time, which can differ from when it was defined Understand deferred execution (Section 3); use .ToList() when a stable snapshot is genuinely needed
Enumerating the same deferred query multiple times Each enumeration re-runs the underlying logic from scratch — costly, and for IQueryable<T>, a separate database round trip each time Materialize once with .ToList()/.ToArray() if the result will be used more than once (Section 8)
Calling .ToList() before filtering/sorting an IQueryable<T> source Pulls the entire table into memory first, forcing every subsequent operator to run in .NET instead of translating to SQL Order operators so filtering/sorting/projecting happen before the final .ToList() (Section 9)
Using a custom C# method inside a LINQ to Entities query Many LINQ providers cannot translate an arbitrary method call into SQL, causing a runtime exception or unexpected in-memory fallback Keep IQueryable<T> predicates to expressions the provider can translate (simple property comparisons, standard operators)
Calling OrderBy twice expecting a secondary sort key The second OrderBy completely discards the first sort rather than adding to it Use ThenBy/ThenByDescending to chain a secondary sort key onto an existing OrderBy
Forcing inherently imperative, branch-heavy logic into a LINQ chain Produces code that's harder to read than the equivalent loop, working against LINQ's own clarity goal Use an ordinary loop when the logic doesn't map cleanly onto filter/transform/aggregate operators (Section 12)
Assuming Take(n) after OrderBy avoids sorting the full sequence OrderBy must still process every element before producing any result, regardless of how few are ultimately taken Understand this as an inherent cost of sorting, not a LINQ inefficiency to "fix"
Using synchronous IEnumerable<T>/foreach over a genuinely I/O-bound, streamed data source Blocks the calling thread waiting on I/O for each element, defeating the point of asynchronous data access Use IAsyncEnumerable<T> and await foreach for genuinely asynchronous, streamed sequences (Section 11)

Quick Reference Table

Concept C# Example Purpose
Method syntax products.Where(p => p.Price > 100) The common, chainable form most LINQ code uses
Query syntax from p in products where p.Price > 100 select p SQL-like alternative, compiled to the same method calls
Deferred execution var q = products.Where(...); (nothing runs yet) The query is a description, executed only on enumeration
Immediate execution .ToList(), .Count(), .Sum() Forces execution now, producing a fixed result or snapshot
Filtering .Where(p => ...) Keeps only matching elements
Projection .Select(p => ...), .SelectMany(p => ...) Transforms elements; SelectMany flattens nested sequences
Ordering .OrderBy(...).ThenBy(...) Sorts by a primary key, then optional secondary keys
Grouping .GroupBy(p => p.Category) Produces groups, each with a Key and its own elements
IQueryable<T> + expression trees dbContext.Products.Where(...) Translates the lambda into SQL (or another query language) instead of executing it in .NET
IAsyncEnumerable<T> await foreach (var x in GetStreamAsync()) Asynchronous, streamed enumeration for genuinely I/O-bound sequences

Conclusion

LINQ's real depth isn't in memorizing the standard operator list — it's in understanding the two mechanisms underneath everything this guide covers: deferred execution, which turns a query into a composable description rather than an immediate action, and expression trees, which let that same description be translated into an entirely different query language when the data source demands it. Together, these are what make products.Where(p => p.Price > 100).OrderBy(p => p.Name).ToList() and dbContext.Products.Where(p => p.Price > 100).OrderBy(p => p.Name).ToList() look identical in source code while doing genuinely, fundamentally different work underneath — one filtering in-memory objects with an ordinary compiled delegate, the other building an expression tree that gets translated into a SQL query and executed entirely on a database server.

Everything else — the specific operators, method vs. query syntax, IAsyncEnumerable<T> — is really just vocabulary and surface built on top of that foundation, which is itself built on the generic interfaces, extension methods, and delegates this series has already covered in depth elsewhere. Used well, LINQ makes filter/transform/aggregate logic genuinely more readable than the equivalent hand-written loops, and its deferred, compositional nature lets complex, conditional queries be built incrementally without paying for intermediate results along the way — used without understanding deferred execution and expression trees specifically, it's a steady source of the multiple-enumeration and premature-materialization bugs this guide spends real time on for exactly that reason.


Found this useful? Feel free to star the repo, open an issue with corrections, or share the query-hit-the-database-three-times-because-of-multiple-enumeration debugging session that made deferred execution click far better than any explanation ever could.

Top comments (0)