LINQ (Language-Integrated Query) is the part of C# and .NET that lets you filter, project, join and aggregate data with composable operators, whether the data lives in memory, in XML or in a database. Most developers use it daily, yet many production bugs and performance problems come from misunderstanding when a LINQ query actually runs and where. This guide explains deferred execution, the difference between IEnumerable<T> and IQueryable<T>, the operators added from .NET 6 through .NET 10, what the runtime optimizes for you, when to avoid LINQ in hot paths, and how to write your own operators.

What Is LINQ?#

LINQ arrived with C# 3.0 in 2007, and several language features of that release exist mainly to support it: lambda expressions, extension methods, anonymous types, implicitly typed locals and expression trees. At its core LINQ is a set of standard query operators, such as Where, Select, GroupBy, Join and Sum, implemented once for each kind of data source:

  • LINQ to Objects (System.Linq.Enumerable) works on any IEnumerable<T>: arrays, lists, dictionaries and iterators.
  • LINQ providers (System.Linq.Queryable) work on IQueryable<T> and translate the query into another language, most commonly SQL through Entity Framework Core.
  • LINQ to XML and other libraries add domain-specific operators on top of the same model.

The query syntax (from ... where ... select) is purely a compiler feature. The compiler rewrites it into calls to methods named Where, Select, Join and so on, which is why any type with suitably shaped methods can participate.

How LINQ Works: Iterators, Delegates and Expression Trees#

LINQ to Objects operators are extension methods on IEnumerable<T> that take delegates such as Func<T, bool>. Most of them return a new enumerable object that wraps the previous one, so a query is a chain of decorators. Nothing happens until someone calls GetEnumerator() and MoveNext(), typically through foreach. Each MoveNext call pulls one element through the entire chain, which is why LINQ can process an unbounded stream in constant memory.

Queryable operators look the same but take Expression<Func<T, bool>> instead of Func<T, bool>. The compiler then turns your lambda into an expression tree, a data structure describing the code, rather than into IL. The provider walks the tree when the query executes and translates it. The same lambda text can therefore run as compiled C# or become a SQL WHERE clause, depending only on the static type of the source.

Getting Started: Query Syntax vs Method Syntax#

Given orders and customers collections, the two syntaxes express the same query:

C#
// Query syntax: reads like SQL and supports join, let, group and into.
var topSpenders =
    from o in orders
    join c in customers on o.CustomerId equals c.Id
    where c.Country == "DE"
    group o by c.Name into g
    let total = g.Sum(o => o.Total)
    orderby total descending
    select new { Customer = g.Key, Total = total };

// Equivalent method syntax: what the compiler effectively produces.
var sameResult = orders
    .Join(customers, o => o.CustomerId, c => c.Id, (o, c) => new { o, c })
    .Where(x => x.c.Country == "DE")
    .GroupBy(x => x.c.Name, x => x.o)
    .Select(g => new { Customer = g.Key, Total = g.Sum(o => o.Total) })
    .OrderByDescending(x => x.Total);

Query syntax shines for joins, let bindings and multiple from clauses because it hides the transparent identifiers the compiler creates. Method syntax is more common in modern code because many operators, including Count, Distinct, Take, MinBy, Chunk and every operator added since .NET 6, have no query keyword. Mixing them is fine: wrap the query in parentheses and continue with method calls.

Deferred vs Immediate Execution#

The documentation classifies every standard operator by when it runs. Immediate operators return a single value or a materialized collection and execute on the spot: Count, Sum, First, Any, ToList, ToArray and ToDictionary. Deferred operators return a sequence and do nothing until enumerated. Deferred operators are further divided into streaming operators, such as Where, Select, Take and Concat, which process one element at a time, and nonstreaming operators, such as OrderBy, GroupBy and Reverse, which must read the entire source before yielding their first element.

C#
decimal threshold = 100m;
List<decimal> totals = [50m, 120m, 300m];

IEnumerable<decimal> large = totals.Where(t => t > threshold);   // nothing runs yet

threshold = 200m;     // the lambda captured the variable, not its current value
totals.Add(500m);     // the source is read at enumeration time

Console.WriteLine(string.Join(", ", large));   // 300, 500
Console.WriteLine(large.Count());               // enumerates again: 2
decimal[] snapshot = [.. large];                // materialize once

Deferred execution is a feature: queries compose without intermediate collections, and a query variable always reflects current data. It becomes a bug when you assume the query ran where you declared it. Exceptions surface at enumeration, possibly far away; captured variables are read late; and a disposed DbContext or closed file makes the deferred query fail.

Multiple Enumeration and When to Materialize#

Because a deferred query re-runs on every enumeration, calling Any(), Count() and foreach on the same IEnumerable<T> can triple the work. When the source is a database query or an iterator with side effects, it can also return inconsistent results between calls:

C#
// Risky: each call re-runs the underlying query.
IEnumerable<Order> overdue = repository.StreamOverdueOrders();
if (overdue.Any())
{
    logger.LogWarning("{Count} overdue orders", overdue.Count());   // second enumeration
    await notifier.SendAsync(overdue, ct);                          // third enumeration
}

// Better: materialize once and expose a type that promises a stable collection.
IReadOnlyList<Order> overdueList = [.. repository.StreamOverdueOrders()];
if (overdueList.Count > 0)
{
    logger.LogWarning("{Count} overdue orders", overdueList.Count);
    await notifier.SendAsync(overdueList, ct);
}

The code analysis rule CA1851, introduced with .NET 7, flags possible multiple enumerations. It is not enabled by default, so turn it on in .editorconfig for codebases that pass IEnumerable<T> around. As a design rule, accept IEnumerable<T> when you enumerate once, and ask for IReadOnlyCollection<T> or IReadOnlyList<T> when you need a count or several passes.

IEnumerable vs IQueryable#

The static type of your source decides where the query runs, and one careless AsEnumerable() can move a filter from the database into memory:

C#
// IQueryable<Order>: the whole pipeline becomes one SQL statement.
List<OrderSummary> recent = await db.Orders
    .Where(o => o.CustomerId == customerId && o.PlacedOn >= since)
    .OrderByDescending(o => o.PlacedOn)
    .Take(20)
    .Select(o => new OrderSummary(o.Id, o.Total))
    .ToListAsync(ct);

// AsEnumerable switches to LINQ to Objects: every row is loaded, then filtered.
List<Order> slow = db.Orders
    .AsEnumerable()
    .Where(o => o.CustomerId == customerId)
    .ToList();

// A method EF Core cannot translate throws at run time outside the final Select.
var fails = await db.Orders.Where(o => IsVip(o.CustomerId)).ToListAsync(ct);
AspectIEnumerable<T> (LINQ to Objects)IQueryable<T> (LINQ provider)
Lambdas compile toDelegates (IL)Expression trees (data)
Where the query runsIn your processWherever the provider sends it, such as the database
Supported methodsAny C# codeOnly what the provider can translate
Failure modeSlow when data is largeTranslation exceptions at run time
Typical useIn-memory collections and streamsEF Core, OData and other remote sources

Since EF Core 3.0, client evaluation is allowed only in the final projection; an untranslatable expression anywhere else throws instead of silently downloading the table. Expression trees also explain query caching: EF Core caches compiled queries by tree shape, and a captured variable such as customerId becomes a SQL parameter, while a literal constant produces a different tree and a separately compiled query for every value. The EF Core performance guide covers projection, tracking and split queries in depth.

Grouping, Lookups and Sort Stability#

Grouping and sorting have semantics that matter for correctness, not just speed. GroupBy is deferred and nonstreaming: it buffers the entire source on first enumeration and rebuilds the groups every time you enumerate it again. Groups appear in the order their keys first occur, and elements keep their source order within each group. ToLookup produces the same structure immediately and keeps it, which makes it the better choice when you query groups repeatedly:

C#
// Built once; each lookup is a hash probe instead of a re-run of GroupBy.
ILookup<string, Order> ordersByCustomer = orders.ToLookup(o => o.CustomerId);

foreach (var customer in customers)
{
    IEnumerable<Order> mine = ordersByCustomer[customer.Id];   // empty, never null or throwing
    Console.WriteLine($"{customer.Name}: {mine.Sum(o => o.Total):C}");
}

// OrderBy is a stable sort, so ties keep their original relative order.
var ranked = orders
    .OrderByDescending(o => o.Total)
    .ThenBy(o => o.PlacedOn);   // ThenBy refines; a second OrderBy would replace the ordering

Stability means that sorting by total keeps equally valued orders in their existing order, which is useful for paging and for predictable test output. Calling OrderBy twice is a common bug: the second call starts a new primary ordering and discards the first, so use ThenBy for secondary keys.

Iterators and Writing Custom LINQ Operators#

The standard operators are ordinary iterator methods, and you can write your own the same way with yield return. One subtlety matters: code in an iterator body does not run until the first MoveNext, so argument validation inside it is deferred too. The standard fix is to validate eagerly and delegate to a local iterator function. C# 14 extension blocks make a family of operators tidy:

C#
public static class SequenceExtensions
{
    extension<T>(IEnumerable<T> source)
    {
        // [1, 2, 3] -> (1, 2), (2, 3)
        public IEnumerable<(T Previous, T Current)> Pairwise()
        {
            ArgumentNullException.ThrowIfNull(source);   // runs at the call site
            return Iterate(source);

            static IEnumerable<(T, T)> Iterate(IEnumerable<T> items)
            {
                using var e = items.GetEnumerator();
                if (!e.MoveNext()) yield break;

                T previous = e.Current;
                while (e.MoveNext())
                {
                    yield return (previous, e.Current);
                    previous = e.Current;
                }
            }
        }
    }
}

Good custom operators follow the conventions of the built-in ones: they stream when possible, enumerate the source at most once, dispose the enumerator (which foreach and using guarantee) and avoid side effects. Operators for IQueryable<T> are different: they must build and compose expression trees that the provider understands, so prefer combining existing Queryable operators or reusable Expression<Func<T, bool>> predicates. The delegates, lambdas and expression trees guide explains the underlying types.

New LINQ Operators in .NET 6 Through .NET 10#

LINQ was relatively static for a decade, but recent releases added operators that replace common hand-written patterns. The versions below were checked against the reference assemblies of each release:

VersionNew operatorsReplaces
.NET 6Chunk, MinBy, MaxBy, DistinctBy, ExceptBy, IntersectBy, UnionBy, TryGetNonEnumeratedCountManual batching, OrderBy().First(), GroupBy().Select(g => g.First())
.NET 7Order, OrderDescendingOrderBy(x => x)
.NET 8Parameterless ToDictionary() for key-value pairsToDictionary(kv => kv.Key, kv => kv.Value)
.NET 9CountBy, AggregateBy, IndexGroupBy plus Count, manual index counters
.NET 10LeftJoin, RightJoin, Shuffle, Sequence, InfiniteSequenceGroupJoin plus SelectMany plus DefaultIfEmpty
C#
// .NET 6: batching, arg-max and distinct by key
foreach (Order[] batch in orders.Chunk(100))
    await bulkApi.SendAsync(batch, ct);

Order? largest = orders.MaxBy(o => o.Total);
IEnumerable<Order> firstPerCustomer = orders.DistinctBy(o => o.CustomerId);

// .NET 9: aggregate by key without allocating groups
foreach (var (country, count) in customers.CountBy(c => c.Country))
    Console.WriteLine($"{country}: {count}");

Dictionary<string, decimal> revenue = orders
    .AggregateBy(o => o.CustomerId, seed: 0m, (sum, o) => sum + o.Total)
    .ToDictionary();

foreach (var (index, order) in orders.Index())
    Console.WriteLine($"{index + 1}. {order.Id}");

The .NET 10 additions finally give outer joins a first-class form, and EF Core 10 translates LeftJoin and RightJoin to SQL LEFT JOIN and RIGHT JOIN:

C#
// .NET 10: every customer appears, with or without orders.
var totals = customers.LeftJoin(
    orders,
    c => c.Id,
    o => o.CustomerId,
    (c, o) => new { c.Name, Total = o?.Total ?? 0m });

// The pre-.NET 10 equivalent in query syntax.
var oldStyle =
    from c in customers
    join o in orders on c.Id equals o.CustomerId into matches
    from o in matches.DefaultIfEmpty()
    select new { c.Name, Total = o?.Total ?? 0m };

IEnumerable<Question> quiz = questionBank.Shuffle().Take(10);
IEnumerable<int> evens = Enumerable.Sequence(0, 20, 2);   // 0, 2, 4, ..., 20

C# query syntax has no keyword for the new outer joins yet. Looking ahead, the .NET 11 release candidate adds a FullJoin operator and overloads of Join, LeftJoin and RightJoin that return tuples instead of requiring a result selector. .NET 10 also brought LINQ to IAsyncEnumerable<T> into the base libraries through the System.Linq.AsyncEnumerable class.

LINQ Performance: What the Runtime Optimizes for You#

Modern LINQ is much smarter than its reputation. The implementation in System.Linq recognizes common shapes and takes shortcuts:

  • Span fast paths. Aggregates such as Sum, Min and Max detect exact T[] and List<T> sources, operate on a span directly, and use SIMD vectorization for primitive numeric types when the hardware supports it.
  • Smarter ordering. OrderBy(...).First() performs a single linear scan instead of sorting the whole sequence, and MinBy states the intent directly.
  • Count shortcuts. Count() returns ICollection<T>.Count without enumerating, and TryGetNonEnumeratedCount lets you ask for that fast path explicitly.
  • Specialized iterators. Chains such as Where followed by Select over arrays and lists use combined iterators, and ToArray or ToList pre-size their buffers when the length is known.

C# 14 also affects performance indirectly. With first-class span conversions, calls such as array.Contains(value) can bind to the vectorized MemoryExtensions.Contains instead of Enumerable.Contains, as described in the C# 14 features guide. Upgrading the runtime is often the cheapest LINQ optimization available, because each release improves these internals without code changes.

LINQ in Hot Paths#

LINQ's costs are predictable. Each operator in a chain allocates an iterator object, each lambda that captures local variables allocates a closure and a delegate per call, and enumerating a List<T> through IEnumerable<T> boxes its otherwise allocation-free struct enumerator. On request paths that run a few times per second this is noise. In a tight loop that runs millions of times, it shows up as GC pressure and lost throughput:

C#
// Idiomatic, but allocates a closure, a delegate and an enumerator per call.
static int CountAboveLinq(List<int> values, int limit) => values.Count(v => v > limit);

// Hot path version: no allocations, direct access to the list's backing array.
static int CountAboveLoop(List<int> values, int limit)
{
    int count = 0;
    foreach (int v in CollectionsMarshal.AsSpan(values))
        if (v > limit) count++;
    return count;
}

Measure before rewriting, using a benchmark harness such as BenchmarkDotNet with its memory diagnoser. Cheap improvements that keep LINQ include marking lambdas static so accidental captures become compile errors, hoisting query construction out of loops, replacing Where(p).Count() with Count(p), and choosing Any() over Count() > 0 for sequences that are not collections.

Best Practices#

  • Materialize deliberately. Call ToList, ToArray or a collection expression at the boundary where you need a stable snapshot, and not after every step.
  • Keep IQueryable<T> composable until the last moment, then project to exactly the columns you need with Select.
  • Prefer the newer operators. MinBy, DistinctBy, CountBy, Chunk and LeftJoin are clearer and usually faster than hand-rolled equivalents.
  • Keep lambdas pure. Side effects inside Select or Where run lazily, repeatedly or not at all.
  • Enable CA1851 in libraries that accept IEnumerable<T>.
  • Use loops or spans in measured hot paths, and LINQ everywhere else.

Common Pitfalls#

  • Assuming a query runs where it is declared. Exceptions, captured variables and disposed resources are all evaluated at enumeration time.
  • Silent client-side filtering. AsEnumerable(), ToList() or an IEnumerable<T> parameter in the middle of a database query moves the rest of the work into memory.
  • Using First() when absence is expected. It throws on empty sequences; use FirstOrDefault() with a default value (available since .NET 6), and do not use Single() merely to fetch one row.
  • Mutating a collection during enumeration. Adding to a List<T> while a deferred query enumerates it throws InvalidOperationException.
  • Deferred task creation. ids.Select(id => LoadAsync(id)) enumerated twice starts every call twice; materialize the tasks first.

LINQ vs Loops vs PLINQ: When to Use Each#

ApproachBest forWatch out for
LINQ to ObjectsReadable transformations over in-memory dataAllocations in very hot loops
foreach or span loopsHot paths, early exits, complex stateMore code to maintain and review
IQueryable<T> with EF CoreFiltering and projecting data at the databaseUntranslatable expressions and hidden client evaluation
PLINQ (AsParallel)CPU-heavy work per element over large inputsOrdering, thread-safety and overhead on small inputs
System.Linq.AsyncEnumerableStreams that arrive asynchronously (.NET 10)Package conflicts with System.Linq.Async when upgrading

Frequently Asked Questions#

Is LINQ slower than a foreach loop?#

For small and medium collections the difference is usually negligible, and some LINQ operators are faster than naive loops because they use vectorized span-based code. In very hot paths LINQ adds per-call allocations for iterators, delegates and closures, so a hand-written loop over a span can be measurably faster. Profile with a benchmark before rewriting readable code.

What is the difference between IEnumerable and IQueryable?#

IEnumerable<T> operators run in your process and take compiled delegates. IQueryable<T> operators take expression trees that a provider such as EF Core translates into another language, typically SQL, so filtering happens at the data source. The static type of the source decides which set of operators the compiler binds to.

When should I call ToList or ToArray?#

Call them when you need a stable snapshot: before enumerating a result several times, before the underlying resource such as a DbContext is disposed, or before returning data across a layer boundary. Avoid materializing between every operator, because that defeats streaming and allocates intermediate collections.

Which new LINQ methods should I know in .NET 10?#

.NET 10 adds LeftJoin and RightJoin for outer joins, which EF Core 10 translates to SQL, plus Shuffle, Sequence and InfiniteSequence. It also ships System.Linq.AsyncEnumerable for IAsyncEnumerable<T>. Combined with CountBy, AggregateBy and Index from .NET 9, these remove most hand-written grouping and indexing code.

Does query syntax perform differently from method syntax?#

No. The compiler translates query syntax into the same method calls, so the resulting code and performance are identical. Choose whichever reads better for the query at hand.

Summary#

  • LINQ operators are composable iterators (for IEnumerable<T>) or expression-tree builders (for IQueryable<T>).
  • Most operators are deferred; they run at enumeration time and re-run on every enumeration.
  • The static source type decides whether a query runs in memory or at the database.
  • .NET 6 through .NET 10 added Chunk, MinBy, CountBy, AggregateBy, Index, LeftJoin and more, and .NET 11 adds FullJoin.
  • The runtime optimizes common shapes, but hot loops still benefit from spans and plain loops.
  • Custom operators should validate eagerly, stream lazily and enumerate once.

Further Reading#