LINQ
|
This section documents C# 14 on .NET 10 (LTS), as published at learn.microsoft.com/dotnet/csharp, which is the reference these pages are written and verified against. Features introduced by C# 15 / .NET 11 are still in preview and are always flagged as such — never presented as baseline. This content was generated with the assistance of AI and should be verified against learn.microsoft.com before being relied on in production. This section’s bibliography lists the reference material consulted while preparing these pages. |
LINQ — Language Integrated Query — is a set of extension methods over IEnumerable<T> plus a query syntax the
compiler translates into calls to them. The result is that filtering, projecting, grouping and joining read the
same whether the data is an in-memory list, an XML document or a SQL table.
Two Syntaxes, One Meaning
Query syntax looks like SQL with the clauses reordered; method syntax is plain extension-method calls. They compile to the same thing:
public sealed record Product(string Name, string Category, decimal Price, int Stock);
public static class TwoSyntaxes
{
private static readonly Product[] Catalogue =
[
new Product("Keyboard", "Peripherals", 49.99m, 12),
new Product("Mouse", "Peripherals", 24.99m, 0),
new Product("Monitor", "Displays", 189.00m, 4),
new Product("Cable", "Peripherals", 7.50m, 130),
];
public static void Run()
{
// Query syntax -- always starts with `from`, always ends with `select` or `group`.
IEnumerable<string> viaQuery =
from product in Catalogue
where product.Stock > 0
orderby product.Price descending
select product.Name;
// Method syntax -- exactly the same query.
IEnumerable<string> viaMethods = Catalogue
.Where(product => product.Stock > 0)
.OrderByDescending(product => product.Price)
.Select(product => product.Name);
Console.WriteLine(string.Join(",", viaQuery)); // Monitor,Keyboard,Cable
Console.WriteLine(viaQuery.SequenceEqual(viaMethods)); // True
}
}
Query syntax exists for where, select, orderby, group, join, let, into and from — roughly the
relational core. Everything else (Count, Any, Distinct, Take…) has no query-syntax keyword, so mixed
queries are normal:
public static class MixedSyntax
{
public static int CountOfCheap(IEnumerable<Product> products) =>
(from p in products where p.Price < 50m select p).Count();
}
Convention: use query syntax when a query has joins, groupings or several let bindings; use method syntax for
short pipelines.
How Query Expressions Translate
The compiler rewrites each clause mechanically, before any type checking:
| Query clause | Translates to |
|---|---|
|
the source itself |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
public static class Translation
{
public static void Run()
{
int[] numbers = [1, 2, 3, 4, 5];
// `let` introduces a computed value reused downstream…
var withLet =
from n in numbers
let square = n * n
where square > 4
select $"{n}^2 = {square}";
// …which the compiler turns into a projection carrying both values along.
var translated = numbers
.Select(n => new { n, square = n * n })
.Where(pair => pair.square > 4)
.Select(pair => $"{pair.n}^2 = {pair.square}");
Console.WriteLine(string.Join(" | ", withLet));
Console.WriteLine(withLet.SequenceEqual(translated)); // True
}
}
Because the translation is purely syntactic, query syntax works on any type offering suitably shaped
Where/Select/… methods — it is not tied to IEnumerable<T> at all.
Deferred versus Immediate Execution
Most operators return a lazy sequence: calling them builds a pipeline, and no element moves until something enumerates it.
Catalogue[]")] W["Where
Stock > 0"] S["Select
p => p.Name"] T["Take(2)"] SINK["ToList()
or foreach"] SRC -->|"MoveNext()"| W -->|"MoveNext()"| S -->|"MoveNext()"| T -->|"MoveNext()"| SINK SINK -.->|"pulls one element at a time,
right to left"| SRC NOTE["Nothing runs until the sink pulls.
Take(2) stops the whole pipeline
after two elements -- the source
is never fully enumerated."] T -.-> NOTE style SRC fill:#f7f9fc,stroke:#8fa4bd style SINK fill:#eef6f0,stroke:#6fa383 style NOTE fill:#fdf6ec,stroke:#c8a46a
public static class Deferral
{
public static void Run()
{
var source = new List<int> { 1, 2, 3 };
IEnumerable<int> doubled = source.Select(n =>
{
Console.WriteLine($" projecting {n}");
return n * 2;
});
Console.WriteLine("query built -- nothing projected yet");
source.Add(4); // the query has not run, so it sees this
Console.WriteLine(string.Join(",", doubled)); // projects 1,2,3,4 -> 2,4,6,8
// Enumerating again re-runs the projection from scratch.
Console.WriteLine(doubled.Count());
}
}
Operators returning a value or a new collection execute immediately: ToList, ToArray, ToDictionary,
ToLookup, ToHashSet, Count, Sum, Min, Max, Average, Aggregate, First, Single, Any, All,
Contains, ElementAt, SequenceEqual.
The two practical rules:
-
Materialise when you will enumerate more than once, or when the source is expensive (a database, a file, a network call).
ToList()once beats three lazy walks. -
Do not materialise prematurely in the middle of a pipeline — that defeats short-circuiting operators such as
Take,FirstandAny, which otherwise stop the source early.
The Standard Query Operators
Filtering
public static class Filtering
{
public static void Run()
{
object[] mixed = [1, "two", 3, "four", 5.0];
Console.WriteLine(string.Join(",", Enumerable.Range(1, 10).Where(n => n % 3 == 0))); // 3,6,9
// The indexed overload passes the position alongside the element.
Console.WriteLine(string.Join(",", "abcdef".Where((c, i) => i % 2 == 0))); // a,c,e
// OfType filters by type and casts; Cast throws on a mismatch.
Console.WriteLine(string.Join(",", mixed.OfType<int>())); // 1,3
}
}
Projection
public static class Projection
{
public static void Run()
{
string[] sentences = ["the quick fox", "jumped over"];
// Select maps one to one.
Console.WriteLine(string.Join(",", sentences.Select(s => s.Length))); // 13,11
// SelectMany flattens one to many -- the single most useful operator to learn well.
string[] words = sentences.SelectMany(s => s.Split(' ')).ToArray();
Console.WriteLine(string.Join(",", words)); // the,quick,fox,jumped,over
// The result-selector overload keeps the outer element in scope.
var pairs = sentences.SelectMany(
s => s.Split(' '),
(sentence, word) => $"{word} ({sentence.Length} chars)");
Console.WriteLine(string.Join(" | ", pairs.Take(2)));
// In query syntax, a second `from` IS SelectMany.
var viaQuery =
from sentence in sentences
from word in sentence.Split(' ')
select word;
Console.WriteLine(viaQuery.SequenceEqual(words)); // True
}
}
Ordering
public static class Ordering
{
public static void Run()
{
string[] names = ["Charlie", "alice", "Bob", "dave"];
Console.WriteLine(string.Join(",", names.OrderBy(n => n.Length).ThenBy(n => n)));
Console.WriteLine(string.Join(",", names.OrderBy(n => n, StringComparer.OrdinalIgnoreCase)));
Console.WriteLine(string.Join(",", names.OrderDescending())); // .NET 7+ shorthand
Console.WriteLine(string.Join(",", names.Reverse()));
// OrderBy is a STABLE sort: equal keys keep their original relative order.
var byLength = names.OrderBy(n => n.Length).ToArray();
Console.WriteLine(string.Join(",", byLength));
}
}
OrderBy returns IOrderedEnumerable<T>, which is what makes ThenBy available — chaining a second
OrderBy instead would discard the first ordering.
Grouping
public static class Grouping
{
public static void Run()
{
string[] words = ["apple", "avocado", "banana", "blueberry", "cherry"];
foreach (IGrouping<char, string> group in words.GroupBy(w => w[0]))
{
Console.WriteLine($"{group.Key}: {string.Join(", ", group)}");
}
// The result-selector overload projects each group without materialising it.
var counts = words.GroupBy(w => w[0], (key, items) => new { Letter = key, Count = items.Count() });
Console.WriteLine(string.Join(" ", counts.Select(c => $"{c.Letter}={c.Count}")));
// Query syntax with a continuation.
var longestPerLetter =
from word in words
group word by word[0] into byLetter
select new { byLetter.Key, Longest = byLetter.MaxBy(w => w.Length) };
foreach (var entry in longestPerLetter)
{
Console.WriteLine($"{entry.Key} -> {entry.Longest}");
}
}
}
GroupBy is not lazy in the way Where is: it must read the entire source before it can yield the first
group.
Joining
public sealed record Customer(int Id, string Name);
public sealed record Invoice(int CustomerId, decimal Amount);
public static class Joining
{
private static readonly Customer[] Customers = [new Customer(1, "Ada"), new Customer(2, "Alan")];
private static readonly Invoice[] Invoices =
[new Invoice(1, 100m), new Invoice(1, 50m), new Invoice(3, 75m)];
public static void Run()
{
// Inner join: only matching pairs survive.
var inner =
from customer in Customers
join invoice in Invoices on customer.Id equals invoice.CustomerId
select $"{customer.Name}: {invoice.Amount:0.00}";
Console.WriteLine(string.Join(" | ", inner)); // Ada: 100.00 | Ada: 50.00
// Group join: every outer element, with its (possibly empty) group of matches.
var grouped =
from customer in Customers
join invoice in Invoices on customer.Id equals invoice.CustomerId into theirInvoices
select $"{customer.Name}: {theirInvoices.Sum(i => i.Amount):0.00}";
Console.WriteLine(string.Join(" | ", grouped)); // Ada: 150.00 | Alan: 0.00
// Left outer join: group join + DefaultIfEmpty.
var leftOuter =
from customer in Customers
join invoice in Invoices on customer.Id equals invoice.CustomerId into theirInvoices
from invoice in theirInvoices.DefaultIfEmpty()
select $"{customer.Name}: {invoice?.Amount.ToString("0.00") ?? "none"}";
Console.WriteLine(string.Join(" | ", leftOuter));
}
}
Join requires equality on the keys (equals, not an arbitrary predicate). A non-equi join is expressed as a
cross join plus a where — correct, but quadratic.
Set Operations, Partitioning, Quantifiers
public static class OtherFamilies
{
public static void Run()
{
int[] a = [1, 2, 3, 4];
int[] b = [3, 4, 5];
Console.WriteLine(string.Join(",", a.Distinct()));
Console.WriteLine(string.Join(",", a.Union(b))); // 1,2,3,4,5
Console.WriteLine(string.Join(",", a.Intersect(b))); // 3,4
Console.WriteLine(string.Join(",", a.Except(b))); // 1,2
Console.WriteLine(string.Join(",", a.Concat(b))); // 1,2,3,4,3,4,5
// ...By overloads (.NET 6+) key on a projection without a custom comparer.
string[] words = ["apple", "avocado", "banana"];
Console.WriteLine(string.Join(",", words.DistinctBy(w => w[0]))); // apple,banana
// Partitioning.
Console.WriteLine(string.Join(",", a.Take(2))); // 1,2
Console.WriteLine(string.Join(",", a.Take(1..3))); // 2,3 -- range overload (.NET 6+)
Console.WriteLine(string.Join(",", a.Skip(2))); // 3,4
Console.WriteLine(string.Join(",", a.TakeWhile(n => n < 3)));
Console.WriteLine(string.Join(",", a.SkipWhile(n => n < 3)));
Console.WriteLine(a.Chunk(3).Count()); // 2 chunks of up to 3
// Quantifiers -- all short-circuit.
Console.WriteLine(a.Any()); // True
Console.WriteLine(a.Any(n => n > 3)); // True
Console.WriteLine(a.All(n => n > 0)); // True
Console.WriteLine(a.Contains(2)); // True
}
}
Aggregation and Element Operators
public static class AggregationAndElements
{
public static void Run()
{
int[] values = [3, 1, 4, 1, 5];
Console.WriteLine($"{values.Count()} {values.Sum()} {values.Min()} {values.Max()} {values.Average()}");
Console.WriteLine(values.Aggregate((acc, n) => acc * n)); // 60 -- product
Console.WriteLine(values.Aggregate(100, (acc, n) => acc + n)); // 114 -- with a seed
string[] words = ["pear", "fig", "banana"];
Console.WriteLine(words.MaxBy(w => w.Length)); // banana (.NET 6+)
Console.WriteLine(words.MinBy(w => w.Length)); // fig
// Element operators: the ...OrDefault variants return default instead of throwing.
Console.WriteLine(values.First()); // 3
Console.WriteLine(values.FirstOrDefault(n => n > 10)); // 0
Console.WriteLine(values.FirstOrDefault(n => n > 10, -1)); // -1 (.NET 6+ default value)
Console.WriteLine(values.Last()); // 5
Console.WriteLine(values.ElementAtOrDefault(99)); // 0
// Single asserts there is EXACTLY one match -- use it to encode that expectation.
Console.WriteLine(values.Single(n => n == 4)); // 4
}
}
Choose Single over First when "more than one" would be a bug: it turns a silent wrong answer into an
exception.
Conversion
public static class Conversion
{
public static void Run()
{
Product[] products =
[
new Product("Keyboard", "Peripherals", 49.99m, 12),
new Product("Cable", "Peripherals", 7.50m, 130),
new Product("Monitor", "Displays", 189.00m, 4),
];
List<string> list = products.Select(p => p.Name).ToList();
string[] array = products.Select(p => p.Name).ToArray();
HashSet<string> set = products.Select(p => p.Category).ToHashSet();
// ToDictionary: keys must be unique, or it throws.
Dictionary<string, decimal> byName = products.ToDictionary(p => p.Name, p => p.Price);
// ToLookup: a dictionary of GROUPS -- duplicates are expected, and a missing key
// yields an empty sequence rather than throwing.
ILookup<string, Product> byCategory = products.ToLookup(p => p.Category);
Console.WriteLine($"{list.Count} {array.Length} {set.Count} {byName["Cable"]}");
Console.WriteLine(byCategory["Peripherals"].Count()); // 2
Console.WriteLine(byCategory["Nonexistent"].Count()); // 0 -- no exception
}
}
IEnumerable<T> versus IQueryable<T>
The same query text means two different things depending on the static type of the source:
public static class EnumerableVersusQueryable
{
public static void Describe(IEnumerable<Product> inMemory, IQueryable<Product> remote)
{
// IEnumerable<T>: the lambda is a DELEGATE. It runs in this process, on every element.
IEnumerable<Product> local = inMemory.Where(p => p.Price > 50m);
// IQueryable<T>: the lambda is an EXPRESSION TREE. The provider inspects it and
// translates it -- e.g. into `WHERE Price > 50` in SQL. Nothing runs here.
IQueryable<Product> translated = remote.Where(p => p.Price > 50m);
Console.WriteLine($"{local.GetType().Name} {translated.ElementType.Name}");
}
}
Two consequences matter in practice:
-
Calling
AsEnumerable()(orToList()) mid-query ends translation. Everything after it runs locally, which can quietly turn aWHEREclause into "fetch the whole table, then filter". -
Not every expression is translatable. A provider throws when a query calls a method it cannot map. Keep provider-facing queries to what the provider understands, and do the rest after materialising.
See Expression Trees and Dynamic for what the provider is actually reading, and ASP.NET Reference for EF Core usage in web applications.
LINQ to XML, and JSON
System.Xml.Linq gives XML the same query surface:
public static class LinqToXml
{
public static void Run()
{
var document = XElement.Parse("""
<catalogue>
<product name="Keyboard" price="49.99" />
<product name="Cable" price="7.50" />
</catalogue>
""");
var cheap =
from product in document.Elements("product")
let price = (decimal)product.Attribute("price")!
where price < 10m
select (string)product.Attribute("name")!;
Console.WriteLine(string.Join(",", cheap)); // Cable
// Constructing XML is just as declarative.
var built = new XElement("products",
from name in new[] { "a", "b" }
select new XElement("product", new XAttribute("name", name)));
Console.WriteLine(built.Elements().Count()); // 2
}
}
System.Text.Json has no dedicated query syntax, but its DOM (JsonNode, JsonElement) enumerates, so LINQ
applies directly:
public static class LinqOverJson
{
public static void Run()
{
using JsonDocument document = JsonDocument.Parse("""
[ { "name": "Keyboard", "price": 49.99 }, { "name": "Cable", "price": 7.50 } ]
""");
var names = document.RootElement
.EnumerateArray()
.Where(element => element.GetProperty("price").GetDecimal() < 10m)
.Select(element => element.GetProperty("name").GetString());
Console.WriteLine(string.Join(",", names)); // Cable
}
}
Parallel LINQ
AsParallel() partitions a query across the thread pool. It pays off only for CPU-bound work over a
substantial sequence, and it does not preserve order unless you ask:
public static class ParallelLinq
{
public static void Run()
{
int[] source = Enumerable.Range(1, 1000).ToArray();
int total = source.AsParallel().Select(Expensive).Sum();
Console.WriteLine(total > 0);
// AsOrdered costs throughput but restores source order.
var firstFew = source.AsParallel().AsOrdered().Select(Expensive).Take(3);
Console.WriteLine(firstFew.Count());
}
private static int Expensive(int n) => n * n % 97;
}
Writing Your Own Operators
A LINQ operator is just an extension method on IEnumerable<T> — usually an iterator, so it composes lazily
with everything else:
public static class CustomOperators
{
// Lazy: yields pairs of adjacent elements as they are pulled.
public static IEnumerable<(T Previous, T Current)> Pairwise<T>(this IEnumerable<T> source)
{
ArgumentNullException.ThrowIfNull(source);
return Iterate(source);
static IEnumerable<(T, T)> Iterate(IEnumerable<T> source)
{
using IEnumerator<T> enumerator = source.GetEnumerator();
if (!enumerator.MoveNext())
{
yield break;
}
T previous = enumerator.Current;
while (enumerator.MoveNext())
{
yield return (previous, enumerator.Current);
previous = enumerator.Current;
}
}
}
public static void Run()
{
int[] readings = [10, 13, 12, 20];
var deltas = readings.Pairwise().Select(pair => pair.Current - pair.Previous);
Console.WriteLine(string.Join(",", deltas)); // 3,-1,8
}
}
Note the eager-validation wrapper around the iterator, for the reason described in Collections and Iterators.
Performance Guidance
-
Do not enumerate twice.
if (query.Any()) return query.First();runs the pipeline twice;FirstOrDefaultonce. Materialise, or pick an operator that answers in one pass. -
Order matters. Filter before you project and before you sort —
Where().OrderBy()sorts fewer elements thanOrderBy().Where(). -
Prefer the
…Byand default-value overloads (DistinctBy,MaxBy,FirstOrDefault(pred, fallback)) over hand-rolled equivalents; they are single-pass and allocation-light. -
LINQ allocates. Each operator allocates an enumerator, and each lambda that captures allocates a closure. In a hot loop, a plain
forover aSpan<T>is meaningfully faster — see Unsafe Code, Spans and Performance. -
Beware the deferred-capture trap. A query capturing a loop variable, or a
Whereclosing over mutable state, sees that state at enumeration time, not at definition time. -
Measure before optimising. For the overwhelming majority of code, LINQ’s clarity is worth far more than the nanoseconds; reach for the manual loop only where a benchmark says so.
See Also
-
Collections and Iterators — the
IEnumerable<T>contract LINQ builds on. -
Delegates, Lambdas and Events — the lambdas every operator takes.
-
Expression Trees and Dynamic — how
IQueryableproviders read a query. -
Extension Members — why the operators appear on every sequence.
-
Async and Await —
IAsyncEnumerable<T>and the async LINQ operators.