LINQ

Compose type-safe queries with LINQ; understand deferred execution, materialization, operator contracts, ordering, and provider boundaries.

level intermediate time 13 min at Standard depth
version C# 14 / .NET 10
what

LINQ is a set of C# language features and operators for querying object sequences and remote data sources. It composes filtering, projection, ordering, grouping, and aggregation into type-safe pipelines.

trap

Most sequence-returning operators use deferred execution , so saving a query doesn’t save its results. Repeated enumeration can reread data, repeat side effects, or produce different results.

fix

Define the source, execution boundary, result cardinality, and ordering first. Materialize once when you need a stable snapshot, and verify which operations a remote provider translates.

What it is and why it exists

LINQ stands for Language Integrated Query. It supplies common query patterns for filtering, transforming, joining, grouping, and summarizing data with static type checking. Each query step remains an ordinary expression, so the compiler can check property names, lambda parameter types, and result types.

LINQ to Objects is the form you meet most often. Extension methods on System.Linq.Enumerable accept IEnumerable<T> and use delegates to process arrays, lists, and other enumerable sources in the current process. Where retains matching elements, Select changes each element’s shape, OrderBy establishes order, and GroupBy forms groups by key.

LINQ can also describe remote queries. Operators on System.Linq.Queryable accept expression trees, which an IQueryable<T> query provider interprets or translates. A database is only one possible source; the specific provider defines the available operations and execution semantics.

LINQ solves query composition, not the problem of squeezing every loop onto one line. Short, side-effect-free data transformations usually fit a pipeline. An ordinary loop is often clearer when the work includes complex state transitions, per-item error recovery, or several branches. Choose based on whether the behavior is clear, not on character count.

Query syntax and method syntax belong to the same LINQ model. Query syntax supplies clauses such as from, where, orderby, join, group, and select, which the compiler translates to method calls. Method syntax exposes every standard query operator directly, and the two forms can be combined in one expression.

How it works

A pipeline can store operations instead of results

Calling Where or Select usually gives you an object representing query steps, not a populated result collection. When code actually requests elements, the query pulls from its source and applies each operator in order. This is deferred execution: the caller composes a query first, then decides when and how much of the result to consume.

The flow below shows the pull relationship when a foreach first consumes a LINQ to Objects pipeline. Each request from the consumer makes the pipeline search upstream for the next matching value.

foreach, ToList, Count, First, and Sum all demand results, so they trigger the relevant query work. They consume differently: First can stop after finding an element, Count usually has to determine the whole result’s size, and ToList stores all results in a new list. Don’t mistake “triggers execution” for “always builds a collection.”

Streaming and buffering steps

Where and Select can process and yield one element at a time. Take can stop reading upstream once it reaches its limit, and Any can return when it finds its first match. Such operations support short-circuiting, though they still depend on whether upstream can produce values incrementally.

OrderBy must obtain all input before it knows the first sorted result. GroupBy also reads the source and builds groups. Both can remain deferred until enumeration starts, but they buffer after that point. “Deferred” and “streaming” are separate properties.

Operator shapeExamplesMain behavior when results are requested
Deferred and streamingWhere, Select, TakePull enough upstream data to produce the next result
Deferred and bufferingOrderBy, GroupByRead and organize input after enumeration begins
Immediate scalarAny, Count, SumConsume enough input to calculate the scalar
Immediate materializationToArray, ToList, ToDictionaryComplete enumeration and store the results

Materialization writes query results into a concrete container. It fixes that container’s membership and permits repeated reads without rerunning the upstream query, but it doesn’t recursively copy element objects. If the source and result refer to the same mutable object, both can still observe mutations to that object.

The compiler translates query syntax

A query expression must begin with from and end with select or group. The compiler translates its clauses into calls such as Where, Select, OrderBy, Join, or GroupJoin according to language rules. Query syntax isn’t a separate runtime query engine, and looking like SQL doesn’t make it execute remotely.

A let clause introduces a computed value for later clauses, while multiple ordering keys map to an initial ordering followed by subsequent ordering. Query syntax doesn’t cover every standard operator; common operators such as Take and Distinct are normally appended as method calls. Query syntax is often readable with several range variables or joins, while a method chain is compact for ordinary filtering and projection.

Generic types connect the steps

An IEnumerable<Product> still produces Product after Where. Select(product => product.Name) changes the element type to string, so the next step receives string. Anonymous types, records, and tuples can all serve as intermediate projections as long as they don’t cross API boundaries where those types are unsuitable.

The operator determines the target signature of a lambda. Enumerable.Where needs a Func<T, bool>, so its predicate must return a Boolean. A SelectMany selector returns a nested sequence, and the operator flattens those sequences into one result sequence. Compile-time type relationships prevent many shape errors, but they can’t tell whether a business predicate filters the wrong field.

Result cardinality is part of the contract

First says at least one element must exist but allows more; an empty result throws InvalidOperationException. Single requires exactly one and throws for either zero or multiple results. The OrDefault variants only turn “no element” into a default value; SingleOrDefault still rejects multiple elements.

The default can be valid data. When FirstOrDefault over IEnumerable<int> returns 0, the caller can’t tell from that value alone whether the first item is zero or there was no item. If the distinction belongs to the business contract, use a single-pass TryGet-style helper, project to a nullable value, or return a domain result that represents presence.

Aggregates also have individual empty-sequence rules. Count and integer Sum can return zero, while Average, Min, and Max throw on many non-nullable numeric sequences with no elements. Don’t infer boundary behavior from an operator’s name; put empty, single-item, and multi-item inputs in tests.

Enumerable and Queryable form an execution boundary

Enumerable operators accept delegates and execute ordinary .NET code. Queryable operators accept expression trees and hand the query description to a provider. The same lambda source can become a delegate or an expression tree depending on the receiver’s static type.

AsEnumerable doesn’t materialize data by itself. It binds subsequent extension calls to Enumerable, so later predicates run in the current process; ToList is what immediately enumerates and stores results. Calling either too early on a remote source has different effects, but both can change which work happens remotely.

A provider doesn’t have to support every legal C# expression. A delegate that runs correctly over an array doesn’t prove that the same structure translates to a target query language. Remote queries need verification against the real provider for translation, parameterization, null semantics, ordering, and round trips. List<T>.AsQueryable() proves only an in-memory path.

Examples

These four examples use inventory and order data to build from filtering and projection to deferred execution, flattening and grouping, and a query-syntax join. All were compiled and run with .NET SDK 10.0.400, and every output came from the actual process.

Filter, order, and project

The first pipeline keeps products that are in stock and cost at least 100m, orders them by descending price and then ascending name, and projects only the two fields needed for output.

InventoryQuery.cs
using System;
using System.Globalization;
using System.Linq;

Product[] products =
[
    new("Book", 18m, true),
    new("Desk", 240m, true),
    new("Lamp", 80m, false),
    new("Chair", 120m, true)
];

var offers = products
    .Where(product => product.InStock && product.Price >= 100m)
    .OrderByDescending(product => product.Price)
    .ThenBy(product => product.Name)
    .Select(product => new { product.Name, product.Price });

foreach (var offer in offers)
{
    string price = offer.Price.ToString("F2", CultureInfo.InvariantCulture);
    Console.WriteLine($"{offer.Name}: {price}");
}

public sealed record Product(string Name, decimal Price, bool InStock);
Desk: 240.00
Chair: 120.00

Where leaves the element type unchanged, while Select projects Product into an anonymous type. ThenBy supplies a deterministic secondary order for equal prices. Calling another OrderBy instead would replace the previous primary ordering with a new one.

This query doesn’t execute until foreach. The loop only reads the results and performs no source mutation or external side effect, so its timing is clear. If a caller must see exactly the same members later, call ToArray or ToList at the agreed boundary.

Compare a deferred query with a materialized snapshot

This example defines a query and immediately takes an array snapshot, then changes the source list. Enumerating the original query again sees the new order, while the array retains its membership from materialization time.

DeferredSnapshot.cs
using System;
using System.Collections.Generic;
using System.Linq;

var orders = new List<Order>
{
    new("A-100", 20m),
    new("B-200", 75m)
};

IEnumerable<string> largeOrderIds = orders
    .Where(order => order.Total >= 50m)
    .Select(order => order.Id);

string[] snapshot = largeOrderIds.ToArray();

Console.WriteLine($"snapshot: {string.Join(", ", snapshot)}");

orders.Add(new("C-300", 80m));

Console.WriteLine($"deferred: {string.Join(", ", largeOrderIds)}");
Console.WriteLine($"snapshot: {string.Join(", ", snapshot)}");

public sealed record Order(string Id, decimal Total);
snapshot: B-200
deferred: B-200, C-300
snapshot: B-200

The array fixes a set of element references; it isn’t a deep copy of the elements. Order is an immutable record here, so only the list addition creates a difference. With mutable elements, the snapshot and source could still observe changes to the same element object.

The query variable’s static type is IEnumerable<string>, which doesn’t promise that results already reside in memory. An API returning such a deferred query should state source ownership, whether enumeration is repeatable, and whether the source remains valid until consumption finishes.

Flatten with SelectMany, then group

Each order contains several lines. SelectMany retains the customer name while flattening, GroupBy applies an explicit case rule for SKUs, and the remaining steps calculate units and customers for each SKU.

FlattenAndGroup.cs
using System;
using System.Linq;

Order[] orders =
[
    new("Ana",
    [
        new("BK-1", 2),
        new("PN-2", 3)
    ]),
    new("Bo",
    [
        new("bk-1", 1)
    ])
];

var totals = orders
    .SelectMany(
        order => order.Lines,
        (order, line) => new { order.Customer, line.Sku, line.Quantity })
    .GroupBy(row => row.Sku, StringComparer.OrdinalIgnoreCase)
    .Select(group => new
    {
        Sku = group.Key,
        Units = group.Sum(row => row.Quantity),
        Customers = string.Join("/", group
            .Select(row => row.Customer)
            .Distinct()
            .OrderBy(name => name))
    })
    .OrderBy(total => total.Sku, StringComparer.OrdinalIgnoreCase);

foreach (var total in totals)
{
    Console.WriteLine($"{total.Sku}: {total.Units} units ({total.Customers})");
}

public sealed record Order(string Customer, Line[] Lines);
public sealed record Line(string Sku, int Quantity);
BK-1: 3 units (Ana/Bo)
PN-2: 3 units (Ana)

The result selector places order context and line data in one row object, so flattening doesn’t lose the customer. A group retains the first encountered spelling as its Key, which is why the output shows "BK-1". Case-insensitive equality isn’t a display-normalization policy.

GroupBy reads its input and builds groups after enumeration begins. If input can be enormous or infinite, that buffering requirement changes which sources the API can accept. A Take after grouping doesn’t reduce the input that grouping must read.

Express a group join with query syntax

Query syntax makes the customer, order group, and subtotal easy to name together. A group join retains customers with no orders, and where then removes results whose total is too small.

CustomerTotals.cs
using System;
using System.Globalization;
using System.Linq;

Customer[] customers =
[
    new(1, "Alice"),
    new(2, "Bob"),
    new(3, "Cara")
];

Order[] orders =
[
    new(101, 1, 40m),
    new(102, 1, 65m),
    new(103, 2, 30m)
];

var summaries =
    from customer in customers
    join order in orders on customer.Id equals order.CustomerId into customerOrders
    let total = customerOrders.Sum(order => order.Amount)
    where total >= 50m
    orderby total descending, customer.Name
    select new { customer.Name, Total = total };

foreach (var summary in summaries)
{
    string total = summary.Total.ToString("F2", CultureInfo.InvariantCulture);
    Console.WriteLine($"{summary.Name}: {total}");
}

public sealed record Customer(int Id, string Name);
public sealed record Order(int Id, int CustomerId, decimal Amount);
Alice: 105.00

The compiler translates join ... into to a group join and constructs suitable projections for let and the later clauses. Sum over an empty decimal sequence returns zero, so Cara is removed by where rather than failing during aggregation.

The sources here are arrays, so delegates execute in memory. If a range variable comes from IQueryable<T>, the same syntax binds to Queryable; you must then verify that the provider can translate the group join, aggregate, and ordering combination.

Pitfalls

Treating a query variable as a snapshot

Fix: decide whether callers need a live view or a stable snapshot. For a snapshot, materialize exactly once at an explicit boundary and say whether it is a container snapshot or a deep element copy. For a deferred query, keep the source valid through the entire consumption period.

Enumerating a source with work or side effects more than once

Fix: if the contract needs one stable result set, materialize once and use the collection’s Count and iterator. If the source deliberately represents a stream, design a single-pass consumer instead of probing for emptiness first. One enumeration can handle the first item and all following items.

Hiding a cardinality error with an element operator

Fix: choose First, Single, and their OrDefault variants from the domain constraint, then test zero, one, and multiple results separately. When a default value is valid data, don’t let one bare value represent both “missing” and a real result.

Losing primary order with a second OrderBy

Fix: use OrderBy or OrderByDescending for the first key, followed by ThenBy or ThenByDescending for later keys. Assert the full sequence with data that has equal primary keys and different secondary keys, plus different primary keys with equal secondary keys.

Mutating a source collection during enumeration

Fix: collect the intended changes separately, then apply them after enumeration, or use a collection’s dedicated bulk operation. If an algorithm truly needs mutation while reading, choose a data structure and traversal whose contract explicitly supports it.

Falling back to memory at a provider boundary

Fix: mark the IQueryable<T> to IEnumerable<T> boundary and keep translatable filters, projections, and limits before it. Inspect the generated query and results with the real provider. Rewrite untranslatable logic, or switch explicitly only after applying a well-defined data bound.

Deep Enumeration protocol and execution timing

Enumeration protocol and execution timing

IEnumerable<T>.GetEnumerator() creates an enumerator, MoveNext() advances it, and Current exposes the value at the current position. Compiler-generated foreach works around this protocol and disposes an enumerator that implements IDisposable on completion or exceptional exit. Deferred LINQ to Objects operators compose a chain by wrapping upstream enumerators.

Each call to a query object’s GetEnumerator() normally starts a new traversal. Reusing the query object doesn’t mean it cached the previous result. Whether traversal is repeatable and whether each pass returns the same result depend on the source. An array is usually stable; an iterable generator, network pager, or one-shot reader can have a different contract.

Execution exceptions move with enumeration too. Property access, parsing, or user code in a predicate might be written where the query is defined but not throw until a later foreach or ToList. Put the exception boundary around actual consumption, not only around the method call that returns the query object.

Disposing an iterator doesn’t turn its results into a snapshot. It only ends the current enumeration and releases resources owned by that enumerator. If a method returns a deferred query backed by a disposed database context, stream, or reader, later enumeration still fails. Either consume within the resource scope or materialize the required data there.

Short-circuiting, buffering, and infinite sequences

Take(5) emits at most five items downstream, but a preceding filter might inspect more inputs to find five matches. Any(predicate) can stop on its first match, while All(predicate) stops on its first nonmatch. Short-circuiting describes a stopping condition, not a fixed read count.

A buffering operator must see enough input to organize output. Calling OrderBy on an infinite source and then Take(5) can’t produce the first sorted item because sorting never sees the input end. Limit input first only when a business rule makes that bound semantically valid; changing operator order can change results.

The order of Take and Where is itself semantic. source.Where(valid).Take(10) asks for ten valid items, while source.Take(10).Where(valid) checks only the first ten. Their result counts and members can differ. Don’t reorder operators mechanically under the slogan “reduce data first.”

Equality and ordering

Operations such as Distinct, GroupBy, Join, and ToDictionary depend on equality. Without an explicit comparer, LINQ to Objects uses the element or key type’s default equality. Supply an IEqualityComparer<T> when the domain differs, and include case, normalization, and hash-collision examples in tests.

OrderBy establishes a new primary ordering; only ThenBy adds a secondary key within equal existing keys. .NET OrderBy is stable, so elements with equal keys retain their relative source order. A hash-based source might not have meaningful business order in the first place, so deterministic output should spell out every business ordering key.

Set-like operators apply equality; they don’t implement a domain merge policy. If two objects compare equal, Distinct retains one, but callers shouldn’t treat the retained object as a defined reconciliation result. Selecting the newest record, merging fields, or reporting conflicts needs explicit grouping and selection logic.

Materialization boundaries and ownership

ToList and ToArray enumerate the source and create a new outer container. Later membership changes to the source collection don’t alter that container’s count or positions, but reference-type elements can still be shared with the source. A true deep snapshot requires an explicit copy of every element and its nested state.

Materializing too early cuts off later composition, while materializing too late can leak a volatile source and its side effects to an uncontrolled caller. A useful boundary is often an ownership transition: leaving a resource scope, handing data to another thread, publishing a cache, or fulfilling an API promise of stable results. Preserve composition before the boundary and use a concrete collection afterward to express that data has been obtained.

When returning IEnumerable<T>, say whether it is a repeatable query, a one-shot stream, or a read-only view over a concrete collection. The interface promises only enumeration. It doesn’t promise cheap counts, indexing, thread safety, or snapshot semantics, so callers shouldn’t infer those properties from the return type.

Provider translation isn’t delegate execution

Queryable.Where receives query structure, not an ordinary predicate to invoke immediately. A provider walks the expression tree and converts supported nodes to its target operation. Translation failures, target-system null rules, string comparison, and collation can all make a remote path differ from LINQ to Objects.

Captured variables commonly appear as readable values in the expression tree and are parameterized by database providers, but exact behavior still belongs to the provider. Concatenating user input into dynamic query text isn’t the same as ordinary parameter capture; it needs a separate design for grammar, security, and resource limits. Calling Compile() to bypass a translation limit moves work back to delegate execution.

AsQueryable() doesn’t grant database capabilities to an in-memory list either. It can only return a query-interface wrapper; the actual Provider determines available translation. Continue with csharp/expression-trees for query structure and csharp/ef-core for database loading, tracking, and query shape.

Nulls and aggregate types

Nullable-reference annotations don’t change LINQ runtime behavior. If the source actually contains null, Select(item => item.Name) can still throw NullReferenceException; compiler warnings depend on static annotations and null-state analysis. Validate external data at its boundary, then state whether a query excludes, replaces, or retains nulls.

The particular overload determines a numeric aggregate’s result type. Sum over IEnumerable<int> returns int, so intermediate additions remain constrained to that range. Assigning the final result to long can’t retroactively widen earlier arithmetic. Project to the target numeric type before aggregation when you need a wider range, and choose checked behavior from the contract.

Average also changes empty-sequence behavior with nullability. It throws for an empty non-nullable numeric sequence but can return null for the corresponding nullable sequence. Adding DefaultIfEmpty introduces a real default element and changes the semantics, so use it only when the domain truly defines an empty aggregate as that default.

Side effects, concurrency, and testing

Predicates and selectors are easiest to reason about when the result depends only on the input. Hiding a counter increment, network call, or collection mutation inside a lambda makes its execution count depend on enumeration count, short-circuiting, and upstream operators. When side effects are required, an explicit loop usually states order, failure, and retry behavior more clearly.

A deferred pipeline doesn’t become thread-safe automatically. Mutating an ordinary collection during enumeration can invalidate its enumerator, and consuming one stateful enumerator from several threads has no general safety guarantee. Even separate enumerations need a source that permits concurrent reads and lambdas whose captured state isn’t raced.

Tests should assert full query results, not only counts. Cover empty input, one item, duplicate keys, case variants, equal ordering keys, and source mutation. For deferred behavior, place mutations both before and after query definition. For provider queries, add real-backend tests that verify the intended agreement with in-memory tests.

Further reading

checkpoint

5 questions · 2 predict-the-output · 1 spot-the-bug

next up Expression trees Ef core soon Async soon Parallel soon
Copy as Markdown Interview bank Edit on GitHub Report an error Was this clear?