LINQ to Objects internals: extension methods over IEnumerable, iterator pipelines, operator fusion and fast paths, query syntax translation, streaming versus buffering, and modern operators such as Chunk, CountBy, Index and LeftJoin.
LINQ to Objects is a library of extension methods on IEnumerable<T>. Where and Select take delegates and return iterator objects that pull items from their source only when you enumerate, so a query is a lazy pipeline. Query syntax is just compiled into those method calls. Operators like Where stream item by item, while OrderBy and GroupBy must buffer the source first. The BCL fuses common chains and uses Count when the source is a collection. Recent .NET versions added Chunk, MinBy, CountBy, Index, and in .NET 10 LeftJoin and RightJoin.
LINQ to Objects is a set of extension methods on IEnumerable<T> in System.Linq.Enumerable. Most operators are implemented as iterators that wrap the source and pull items lazily, one at a time, when you enumerate. I'd explain the implementation, query vs method syntax, streaming vs buffering operators, and the operators added from .NET 6 to .NET 10.
Conceptually, Where is:
public static IEnumerable<T> Where<T>(this IEnumerable<T> source, Func<T, bool> predicate)
{
ArgumentNullException.ThrowIfNull(source); // eager validation...
ArgumentNullException.ThrowIfNull(predicate);
return Iterator(source, predicate); // ...lazy work
static IEnumerable<T> Iterator(IEnumerable<T> source, Func<T, bool> predicate)
{
foreach (var item in source)
if (predicate(item)) yield return item;
}
}
Where does no work except argument validation; it returns an object describing the pipeline.foreach, ToList, Count) pulls items through the chain: Select asks Where for its next item, Where asks the source, and so on — one item flows through the whole pipeline before the next.ArrayWhereIterator, ListWhereSelectIterator, IPartition<T>, etc.) rather than yield, to fuse operators (Where().Select() becomes one iterator), avoid interface calls on arrays/lists, and implement fast paths for Count, First, ToArray, Skip/Take.var q1 = from o in orders
where o.Total > 100
orderby o.CreatedAt descending
select new { o.Id, o.Total };
var q2 = orders.Where(o => o.Total > 100)
.OrderByDescending(o => o.CreatedAt)
.Select(o => new { o.Id, o.Total });
The compiler translates query syntax into method calls (pattern-based — any type with suitable Where/Select/SelectMany methods works). Query syntax shines for joins, multiple from clauses (SelectMany) and let (introduces range variables via anonymous types); method syntax is more common, supports every operator, and reads well for simple chains. Most teams standardise on method syntax and use query syntax for complex joins.
| Category | Operators | Behaviour |
|---|---|---|
| Streaming (deferred, O(1) memory) | Where, Select, SelectMany, Take, Skip, TakeWhile, Concat, Zip, Cast, OfType, Chunk, Index |
Yield items as they arrive; can work on infinite sequences |
| Buffering (deferred, but must read all/most input before yielding) | OrderBy/ThenBy, Reverse, GroupBy, Distinct*, Join (buffers inner), ToLookup |
Store source items in memory first |
| Immediate (execute on call) | ToList, ToArray, ToDictionary, Count, Sum, First, Any, Max, Aggregate |
Consume the sequence immediately |
* Distinct/Union stream results but keep a growing HashSet of seen items.
Buffering operators matter for memory and latency to first item — OrderBy on a 10-million-item stream must read everything before yielding the first result.
| Version | Operators |
|---|---|
| .NET 6 | Chunk(size), MinBy/MaxBy, DistinctBy, UnionBy, IntersectBy, ExceptBy, Take(Range), ElementAt(Index), FirstOrDefault(defaultValue) overloads, TryGetNonEnumeratedCount, Zip with 3 sources |
| .NET 7 | Order(), OrderDescending() (no key selector) |
| .NET 9 | CountBy, AggregateBy, Index() (returns (int Index, T Item)) |
| .NET 10 | LeftJoin, RightJoin (first-class outer joins, also translated by EF Core 10), plus System.Linq.AsyncEnumerable in the BCL bringing LINQ operators to IAsyncEnumerable<T> |
Examples:
var topCustomer = orders.MaxBy(o => o.Total)?.CustomerId;
foreach (var batch in ids.Chunk(1000)) await ProcessBatchAsync(batch, ct);
var perStatus = orders.CountBy(o => o.Status); // IEnumerable<KeyValuePair<OrderStatus,int>>
foreach (var (i, line) in lines.Index()) Console.WriteLine($"{i}: {line}");
A nightly job did customers.OrderBy(c => c.Name).First() and orders.GroupBy(o => o.Status).Select(g => new { g.Key, Count = g.Count() }) over millions of in-memory records, using lots of memory and time. Replacing them with MinBy(c => c.Name) (single pass, no sort) and CountBy(o => o.Status) (.NET 9, no group buffers) cut memory and time substantially. Knowing which operators buffer made the fix obvious.
Checks whether the candidate understands LINQ as a mechanism, not a syntax.
Green flags: extension methods + delegates + iterators; pull model; streaming versus buffering; query syntax translation; BCL fast paths; names recent operators correctly.
Red flags: thinks LINQ runs immediately; cannot say what OrderBy does to memory; unaware of operators newer than .NET Framework.
LINQ to Objects is pull-based: the consumer calls MoveNext() on the outermost enumerator, which calls MoveNext() on the next inner one, down to the source. Each item travels through the entire pipeline before the next is requested. Consequences:
First, Any, Take) stop pulling early — the rest of the source is never read.The .NET team has heavily optimised LINQ, especially in .NET 7–10:
Where(...).Select(...) on a List<T> produces a single ListWhereSelectIterator.Count() on ICollection<T> uses .Count; ToArray() on known-size sources allocates exactly once; Sum, Min, Max, Average on int[]/List<int> use SIMD (Vector<T>).IPartition<T>/IIListProvider<T> internal interfaces let Skip/Take/First/Last/ElementAt avoid enumerating.OrderBy(...).First()-style patterns.So modern LINQ is often close to hand-written loops for simple pipelines, though still not free (delegate calls, iterator objects).
from o in orders where o.Total > 100 select o.Id
→ orders.Where(o => o.Total > 100).Select(o => o.Id)
from c in customers from o in c.Orders select new { c.Name, o.Id }
→ customers.SelectMany(c => c.Orders, (c, o) => new { c.Name, o.Id })
from o in orders let gst = o.Total * 0.1m where gst > 10 select gst
→ orders.Select(o => new { o, gst = o.Total * 0.1m }).Where(t => t.gst > 10).Select(t => t.gst)
let introduces transparent identifiers (anonymous types), adding allocations.
| Operator | Time | Extra memory |
|---|---|---|
Where, Select |
O(n) | O(1) |
OrderBy |
O(n log n) | O(n) |
GroupBy, ToLookup |
O(n) | O(n) |
Distinct |
O(n) | O(distinct) |
Join |
O(n + m) | O(m) (hash of inner) |
Contains on IEnumerable |
O(n) | O(1) |
MinBy/MaxBy |
O(n) | O(1) |
Count() on ICollection<T> |
O(1) | O(1) |
IEnumerable<T> LINQ is synchronous. For IAsyncEnumerable<T>, .NET 10 includes System.Linq.AsyncEnumerable operators (WhereAwait-style APIs previously came from the System.Linq.Async package). EF Core provides its own async terminal operators (ToListAsync, FirstOrDefaultAsync).
Console.WriteLine inside Where and Select lambdas and observe the interleaving during enumeration.OrderBy(x => x).First() with Min() / MinBy on 10 million items for time and memory.Chunk, CountBy, Index and (on .NET 10) LeftJoin.namespace Linq.Objects;
public sealed record Transaction(int Id, string AccountId, decimal Amount, DateOnly Date);
public static class Program
{
public static void Main()
{
List<Transaction> txns =
[
new(1, "A", 120m, new(2026, 10, 1)),
new(2, "B", -40m, new(2026, 10, 2)),
new(3, "A", 75m, new(2026, 10, 3)),
new(4, "C", 300m, new(2026, 10, 3)),
];
// Method syntax: lazy pipeline
var large = txns.Where(t => t.Amount > 100m).Select(t => t.Id);
// Query syntax compiles to the same calls
var byDate = from t in txns
where t.Amount > 0
orderby t.Date descending
select new { t.Id, t.Date };
Console.WriteLine(string.Join(",", large));
Console.WriteLine(string.Join(",", byDate.Select(x => x.Id)));
// .NET 6+: Chunk, MaxBy, DistinctBy
foreach (var batch in txns.Chunk(2)) Console.WriteLine($"batch of {batch.Length}");
Console.WriteLine(txns.MaxBy(t => t.Amount)?.Id);
// .NET 9: CountBy, AggregateBy, Index
foreach (var (account, count) in txns.CountBy(t => t.AccountId)) Console.WriteLine($"{account}: {count}");
foreach (var (account, total) in txns.AggregateBy(t => t.AccountId, 0m, (sum, t) => sum + t.Amount))
Console.WriteLine($"{account} total {total}");
foreach (var (index, t) in txns.Index()) Console.WriteLine($"#{index} -> {t.Id}");
// .NET 10: LeftJoin
Dictionary<string, string> names = new() { ["A"] = "Alice", ["B"] = "Bob" };
var joined = txns.LeftJoin(names, t => t.AccountId, n => n.Key,
(t, n) => $"{t.Id}: {(n.Key is null ? "unknown" : n.Value)}");
Console.WriteLine(string.Join(" | ", joined));
}
}
What happens: expecting Where(...) to filter at the line it's written.
Why it is wrong: execution is deferred until enumeration.
Do instead: materialise with ToList() when you need a snapshot.
What happens: OrderBy(x => x.Price).First().
Why it is wrong: O(n log n) time and O(n) memory for an O(n) problem.
Do instead: MinBy/MaxBy or Min/Max.
GroupBy just to countWhat happens: GroupBy(k).Select(g => (g.Key, g.Count())).
Why it is wrong: buffers all elements per group.
Do instead: CountBy (.NET 9) or a dictionary.
What happens: logging or mutating state inside Select/Where.
Why it is wrong: runs at enumeration time, possibly multiple times or partially.
Do instead: keep lambdas pure; perform side effects in an explicit loop.
What happens: OrderBy or Reverse on very large or unbounded sequences.
Why it is wrong: memory blow-up or never completes.
Do instead: use streaming operators or process in chunks.
What happens: hand-written chunking or distinct-by-key logic.
Why it is wrong: more code and bugs.
Do instead: use Chunk, DistinctBy, Index, LeftJoin etc.
What happens: mixing query and method syntax awkwardly. Why it is wrong: inconsistent readability. Do instead: standardise on method syntax, query syntax for complex joins.
What happens: LINQ inside per-pixel or per-byte loops. Why it is wrong: delegate calls and iterator overhead dominate. Do instead: use spans and plain loops for proven hot paths.
Seniors understand LINQ as a lazy, pull-based pipeline of iterator objects, and that understanding explains most LINQ bugs (multiple enumeration, late exceptions, captured variables) and most LINQ performance issues (buffering operators, allocations per operator). I keep LINQ for clarity in business logic and know the newer operators that replace common hand-written helpers. In hot paths I measure, and often the .NET 9/10 runtime is already fast enough that the clear version wins.
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.
IEnumerable<T> is just a promise that you can get an enumerator. IEnumerator<T> gives MoveNext, Current and Dispose, and that is all: no count, no indexing, and possibly lazy, infinite or single-use. foreach is pattern-based and compiles to a while (MoveNext()) loop with Dispose in a finally. List<T> uses a struct enumerator, which gets boxed if you iterate through the interface. IAsyncEnumerable<T> is the async version, used with await foreach. I accept IEnumerable<T> when I only iterate once, and return IReadOnlyList<T> unless I mean to stream.
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.
How to tell it (STAR)
**S:** Our month-end reconciliation job was crashing from memory exhaustion. **T:** Fix it before the next close. **A:** I found GroupBy buffering every transaction just to count them, replaced it with CountBy, and streamed the input. **R:** Peak memory fell by about [percentage] and the job ran reliably at month-end.
Last updated 30 Oct 2026.