LINQ
Language-Integrated Query (LINQ) is the C# facility for querying collections and other data sources through a uniform expression language. The standard query operators — Where, Select, OrderBy, GroupBy, Join, SelectMany, Aggregate, ToList, ToDictionary — operate on IEnumerable<T> (in-memory collections) and on IQueryable<T> (translated to other forms, classically SQL). The mechanism is one of the largest C# innovations: it admits expressing what older C# code wrote as explicit loops in a declarative form that is more concise, more composable, and (often) more efficient.
LINQ is a coordinated combination of three things: a set of standard query operators implemented as extension methods, a query syntax that compiles to calls of those operators, and the expression-tree infrastructure that admits LINQ providers (such as Entity Framework Core) translating queries into other languages. This page covers the surface a working programmer encounters routinely; the underlying collections are treated in Data structures.
The two surface syntaxes
LINQ is implemented through extension methods on IEnumerable<T> (and on IQueryable<T> for queryable providers). Two equivalent surface syntaxes:
Method syntax
var evens = numbers.Where(n => n % 2 == 0)
.Select(n => n * n)
.ToList();
var byCategory = items.GroupBy(i => i.Category)
.ToDictionary(g => g.Key, g => g.Count());
The method syntax is the conventional contemporary form. Extension methods chain through .; lambda expressions specify per-element behaviour.
Query syntax
var evens = (from n in numbers
where n % 2 == 0
select n * n).ToList();
var byCategory = (from i in items
group i by i.Category into g
select new { Category = g.Key, Count = g.Count() }).ToList();
The query syntax is more SQL-like and is occasionally clearer for joins and groupings. The two forms compile to the same code; the choice is stylistic.
In practice, the method syntax dominates in modern C# code, except for joins and complex group-bys, where the query syntax is often clearer.
Standard query operators
The principal operators:
| Operator | Effect |
|---|---|
Where(pred) | Keep elements where pred(x) is true. |
Select(fn) | Project each element through fn. |
SelectMany(fn) | Project each element to a collection and flatten. |
OrderBy(key) / OrderByDescending(key) | Sort by key. |
ThenBy(key) / ThenByDescending(key) | Secondary sort key. |
GroupBy(key) | Group by key. Result: IEnumerable<IGrouping<TKey, TElement>>. |
Join(other, key1, key2, result) | Inner join on keys. |
GroupJoin(other, key1, key2, result) | Left outer join. |
Distinct() | Remove duplicates. |
DistinctBy(key) | Distinct by a derived key (.NET 6+). |
Union(other), Intersect(other), Except(other) | Set operations. |
Take(n) / Skip(n) | First n / skip n. |
TakeWhile(pred) / SkipWhile(pred) | While predicate is true. |
TakeLast(n) / SkipLast(n) | Last n / skip last n. |
First() / FirstOrDefault() | First element (or default). |
Single() / SingleOrDefault() | Exactly one element. |
Last() / LastOrDefault() | Last element. |
ElementAt(i) / ElementAtOrDefault(i) | Element by index. |
Count() / LongCount() | Element count. |
Sum(fn) / Average(fn) / Min(fn) / Max(fn) | Aggregations. |
MinBy(key) / MaxBy(key) | Element with min/max key (.NET 6+). |
Aggregate(seed, fn) | General reduction. |
Any() / Any(pred) | Existence test. |
All(pred) | Universal test. |
Contains(value) | Membership test. |
Concat(other) | Concatenate sequences. |
Zip(other, fn) | Pair elements from two sequences. |
Chunk(size) | Split into chunks (.NET 6+). |
Reverse() | Reverse order. |
OfType<T>() / Cast<T>() | Filter or cast to type. |
ToList() / ToArray() | Materialise as List<T> / T[]. |
ToDictionary(key) / ToDictionary(key, value) | Materialise as dictionary. |
ToHashSet() | Materialise as HashSet<T>. |
ToLookup(key) | Materialise as one-to-many lookup. |
var orders = LoadOrders();
// Total per customer:
var perCustomer = orders.GroupBy(o => o.CustomerId)
.ToDictionary(
g => g.Key,
g => g.Sum(o => o.Amount)
);
// Top 5 customers by total order value:
var top5 = orders.GroupBy(o => o.CustomerId)
.OrderByDescending(g => g.Sum(o => o.Amount))
.Take(5)
.Select(g => new { CustomerId = g.Key, Total = g.Sum(o => o.Amount) })
.ToList();
// Customer with the largest single order:
var biggest = orders.MaxBy(o => o.Amount);
IEnumerable<T> and the LINQ-to-objects model
Most LINQ usage is LINQ to objects: queries operate on in-memory IEnumerable<T> (lists, arrays, dictionary values, query results). The operators are extension methods on IEnumerable<T>; they execute in the C# runtime, processing each element in turn.
Each operator is implemented as an iterator:
public static class Enumerable {
public static IEnumerable<TResult> Select<TSource, TResult>(
this IEnumerable<TSource> source,
Func<TSource, TResult> selector
) {
foreach (var item in source) {
yield return selector(item);
}
}
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;
}
}
}
Each operator returns an IEnumerable<TResult> that the next operator consumes; the chain is a pipeline of generators. Items are processed lazily, one at a time, only when the result is enumerated.
IQueryable<T> and provider-based LINQ
A LINQ provider adapts the operator surface to a different execution model. The principal example is Entity Framework Core, which translates LINQ queries into SQL:
using var db = new MyDbContext();
var customers = db.Customers
.Where(c => c.Country == "UK")
.OrderBy(c => c.Name)
.Take(10)
.ToList();
// Translated to:
// SELECT TOP 10 * FROM Customers WHERE Country = 'UK' ORDER BY Name
The provider’s IQueryable<T> accepts the same operators but translates them rather than executing them. The lambda is parsed as an expression tree — a structured representation of the lambda’s body — that the provider analyses and converts to SQL.
The mechanism unifies in-memory and external-store queries under one surface: the same Where/Select/OrderBy work against List<T> (executed locally) and DbSet<T> (executed remotely).
The trade-offs:
- The provider must support each operator; some operators (custom methods, complex projections) may not translate.
- The translation may be inefficient if the LINQ pattern does not map cleanly to SQL.
- Debugging the SQL emitted is occasionally necessary.
Common LINQ providers:
| Provider | Translates to |
|---|---|
| LINQ to objects | In-process iteration |
| Entity Framework Core | SQL |
| LINQ to XML | XPath-like XML traversal |
| LINQ to JSON (Newtonsoft.Json, System.Text.Json) | JSON traversal |
| MongoDB driver | MongoDB query language |
The code is the same shape regardless of the provider; the underlying execution differs.
Expression trees
For LINQ-to-objects, lambdas are compiled to delegates and invoked normally. For IQueryable<T>, lambdas are compiled to expression trees — runtime data structures representing the lambda’s source:
using System.Linq.Expressions;
Expression<Func<int, int>> expr = x => x * 2;
// expr is now a tree node:
// LambdaExpression
// Parameters: [x]
// Body: BinaryExpression(Multiply, ParameterExpression(x), ConstantExpression(2))
Func<int, int> compiled = expr.Compile(); // produce a delegate
int result = compiled(5); // 10
The tree may be inspected, traversed, and translated. EF Core’s translation operates on these trees: for each LINQ operator’s lambda, the provider walks the expression tree and emits the corresponding SQL.
For most LINQ-to-objects code, expression trees are invisible; for LINQ providers, they are the foundation.
Deferred execution
LINQ operators on IEnumerable<T> are deferred: the query is not executed when defined, only when enumerated:
var query = numbers.Where(n => n > 0).Select(n => n * 2);
// Nothing has executed yet.
var list = query.ToList();
// Now everything runs.
foreach (var x in query) { /* ... */ }
// Re-runs the entire query.
The principal consequence: enumerating the same query twice runs it twice. For expensive queries, materialise the result with ToList() or ToArray():
var materialised = query.ToList();
foreach (var x in materialised) { /* ... */ } // walks the list once
foreach (var x in materialised) { /* ... */ } // walks the list again, no re-computation
The deferred behaviour is the conventional choice for queries that may not need to materialise; Where(...).Take(10) is efficient because only the first ten elements are evaluated.
Materialisation
Five terminal operators force materialisation:
| Operator | Result |
|---|---|
ToList() | List<T> |
ToArray() | T[] |
ToDictionary(key, value) | Dictionary<TKey, TValue> |
ToHashSet() | HashSet<T> |
ToLookup(key) | ILookup<TKey, TElement> (one-to-many) |
These materialise the entire query into a concrete collection. After materialisation, the resulting collection is independent of the source.
The non-terminal aggregation operators (Sum, Count, Any, All, First, etc.) also force enumeration, but they produce a single value rather than a collection.
Common patterns
Filtering
var active = users.Where(u => u.IsActive);
Mapping
var names = users.Select(u => u.Name);
var pairs = users.Select(u => new { u.Id, u.Name }); // anonymous type
var tuples = users.Select(u => (u.Id, u.Name)); // tuple
Mapping with index
var indexed = users.Select((u, i) => new { Index = i, User = u });
The Select overload that takes Func<T, int, TResult> provides the index. Useful when the position in the source matters.
Grouping
var byCountry = users.GroupBy(u => u.Country);
foreach (var group in byCountry) {
Console.WriteLine($"{group.Key}: {group.Count()} users");
}
Joining
var joined = orders.Join(
customers,
o => o.CustomerId,
c => c.Id,
(o, c) => new { c.Name, o.Amount }
);
The query syntax is often clearer for joins:
var joined = from o in orders
join c in customers on o.CustomerId equals c.Id
select new { c.Name, o.Amount };
Aggregation
int total = orders.Sum(o => o.Amount);
double avg = orders.Average(o => o.Amount);
decimal max = orders.Max(o => o.Amount);
var withMax = orders.MaxBy(o => o.Amount); // .NET 6+
Custom reduction
string concatenated = words.Aggregate("", (acc, w) => acc + " " + w);
Aggregate is the general reduce operator. The conventional alternatives — Sum, Min, Max — handle the common cases.
Lookup
var byId = orders.ToDictionary(o => o.Id);
var idToName = users.ToDictionary(u => u.Id, u => u.Name);
The first form requires unique keys; duplicate keys throw ArgumentException. The second is the key-and-value form.
For one-to-many keys, ToLookup:
var byCountry = users.ToLookup(u => u.Country);
foreach (var country in byCountry) {
Console.WriteLine($"{country.Key}: {country.Count()} users");
}
Pagination
var page = items.OrderBy(i => i.Id).Skip(offset).Take(pageSize).ToList();
Existence and quantification
bool any = orders.Any(o => o.Amount > 1000);
bool all = users.All(u => u.IsActive);
int count = orders.Count(o => o.Date == DateTime.Today);
Set operations
var unique = items.Distinct();
var union = a.Union(b);
var common = a.Intersect(b);
var diff = a.Except(b);
The set operations use Equals and GetHashCode of the element type; for custom types, supply an IEqualityComparer<T>.
Pitfalls
Multiple enumeration
A query enumerated twice is computed twice:
var query = ExpensiveOperation().Where(x => x.IsValid);
int count = query.Count(); // computed
foreach (var x in query) { } // computed again
The defence is ToList() if the query is expensive and will be enumerated multiple times.
Captured variables in deferred queries
A query holds references to its captured variables; modifying them after the query is defined affects subsequent enumerations:
int threshold = 5;
var q = numbers.Where(n => n > threshold);
threshold = 10;
foreach (var n in q) { /* uses threshold = 10 */ }
The behaviour is correct but occasionally surprising; the defence is to either materialise immediately or pass the threshold by value.
First() versus FirstOrDefault()
First() throws InvalidOperationException if the source is empty; FirstOrDefault() returns default(T) (null for reference types, 0 for numeric types). Choose explicitly:
// Throws if no match:
var item = items.First(i => i.Id == id);
// Returns null if no match:
var item = items.FirstOrDefault(i => i.Id == id);
if (item is null) { /* not found */ }
The conventional discipline: use First() when no-match is unexpected (a programming bug), FirstOrDefault() when no-match is part of the contract.
Performance of repeated Count()
Count() on IEnumerable<T> enumerates the entire source. For materialised collections, Count is O(1); for queries, Count() is O(n).
var query = LargeCollection.Where(x => x.Match);
if (query.Count() > 0) /* iterates everything */
if (query.Any()) /* iterates only until the first match */
Any() is the right operator for “is there anything?”; Count() > 0 re-iterates and is slower.
OrderBy and stability
OrderBy is stable: equal elements keep their relative order. Custom comparers must be careful: returning zero from a comparer must mean “actually equal”, not “I don’t care about the order”. Inconsistent comparers can produce unpredictable results in OrderBy.
Provider-specific limitations
For IQueryable<T> providers, not every operator translates. Calling a custom method or referencing a non-translatable expression typically throws at execution time:
// May fail at runtime if MyHelper is not translatable to SQL:
var results = db.Items.Where(i => MyHelper.Matches(i.Name)).ToList();
The defence is to materialise the queryable portion first, then apply the in-memory operations:
var queryable = db.Items.Where(i => i.IsActive).ToList(); // SQL
var inMemory = queryable.Where(i => MyHelper.Matches(i.Name)).ToList(); // C#
The boundary between the queryable and the in-memory parts must be drawn explicitly.
A note on Span<T> and high-performance alternatives
LINQ allocates: each operator returns an enumerable that may itself be an iterator object; chained operators allocate per stage. For most application code the cost is negligible; for hot paths it is meaningful.
The high-performance alternatives:
- Manual loops: an explicit
foreachwith accumulators avoids the enumerator allocations. Span<T>: span-based operations on contiguous memory avoid allocation.MemoryExtensions: methods likeSum,Min,Max,CountonSpan<T>(and arrays).
// LINQ:
int total = arr.Where(x => x > 0).Sum();
// Manual:
int total = 0;
foreach (var x in arr) if (x > 0) total += x;
The difference is rarely worth the readability cost in application code; for library and performance-critical code, the manual form is conventional.