Aggregation operators reduce a whole sequence down to a single value: a count, a sum, the largest item, the first match. They sit at the end of a LINQ pipeline and force the query to run, which makes them the punctuation marks of LINQ. This lesson walks through every aggregation operator in the standard library: counting, summing, min/max, averaging, folding with Aggregate, the quantifier operators (Any, All, Contains), and the element operators (First, Single, Last, ElementAt).
Most LINQ operators are lazy. Where, Select, and OrderBy build up a description of work without doing any of it. The work only starts when something asks for the actual data: a foreach loop, a materializer like ToList, or an aggregation operator like Count or Sum.
The cyan node is the input sequence. The orange nodes describe operations that haven't run yet. The green node is the aggregation operator. The teal node is the scalar result that comes out the other end. Everything to the left of the green box is description; the green box is what turns description into execution.
Every operator in this lesson is a terminal operator. Calling any of them forces the pipeline to enumerate. This lesson stays focused on what each operator returns.
Count() returns the number of elements in a sequence. Count(predicate) returns the number of elements that match a condition.
The predicate overload is shorthand for orders.Where(o => o.Status == "delivered").Count(). The result is identical; the predicate form just reads better and saves an intermediate operator.
Count() Depends on the SourceCount() looks the same everywhere, but the cost is not the same everywhere.
When the source implements ICollection<T> (List<T>, arrays, Dictionary<TKey, TValue> values, etc.), Count() is O(1) because it reads the Count property directly. On a pure IEnumerable<T> (like the result of Where or Select), Count() has to walk the entire sequence and is O(n).
This matters more than it looks. customers.Count() on a List<Customer> is one property read. customers.Where(c => c.IsActive).Count() enumerates every customer to find the matching ones, even if you only wanted the number.
| Source | Cost of Count() |
|---|---|
T[] (array) | O(1) |
List<T> | O(1) |
Dictionary<TKey, TValue> | O(1) |
HashSet<T> | O(1) |
Queryable.Where(...) against a database | depends on the provider (often translates to SELECT COUNT(*)) |
Enumerable.Where(...) against an in-memory list | O(n) |
Enumerable.Range(...) | O(1) (specialized) |
Custom iterator using yield return | O(n) |
Count Property versus Count() MethodList<T> and arrays expose a Count property (or Length for arrays) that's always O(1). When you have a concrete collection, prefer the property over the LINQ method. They give the same number, but the property doesn't go through the LINQ machinery.
In .NET 8+, Enumerable.Count() checks at runtime whether the source is an ICollection<T> and falls back to the property, so the cost difference is small. Still, using the property when you have it makes the intent clearer.
LongCount for Very Large SequencesCount() returns int. If you're counting something that might exceed int.MaxValue (about 2.1 billion), use LongCount(), which returns long.
For typical e-commerce data (orders, products, customers), Count() is fine. LongCount() matters for analytics pipelines processing billions of events.
Sum adds numbers together. It has overloads for every built-in numeric type: int, long, decimal, double, float, plus the nullable forms (int?, decimal?, etc.).
The selector item => item.Price * item.Quantity projects each cart item to a decimal, and Sum adds them up. The return type matches the selector's output type: project to decimal, get back decimal; project to int, get back int.
For money, use decimal. It avoids the rounding errors that double introduces when adding values like 0.1 + 0.2.
The double result is off by one bit because binary floating-point can't represent 0.1 exactly. decimal uses base-10 internally and gives the exact answer. For prices, totals, and anything customer-facing, stick with decimal.
The nullable overloads ignore null entries instead of treating them as zero or throwing.
Sum() on int? skips nulls and returns int? (which here happens to be non-null because at least one rating was a real number). The behavior matches what most analytics queries expect: "add up what's there, ignore what's missing."
Sum on an empty sequence returns the zero value for its type: 0 for int, 0m for decimal, 0.0 for double. It does not throw.
That's different from Min and Max, which throw on empty sequences for non-nullable types. We'll see that next.
Min and Max return the smallest and largest values in a sequence. Both have selector overloads that project before comparing.
Note what Min(p => p.Price) returns: it's the price, not the product. If you wanted the actual cheapest product (the whole Product record), you'd need a different operator. That's where MinBy comes in.
MinBy and MaxBy (Added in .NET 6)MinBy(keySelector) returns the element whose key is the smallest. MaxBy(keySelector) returns the element whose key is the largest. The difference from Min(selector) is what they return: Min returns the key value; MinBy returns the original element.
The return type of MinBy is T? (nullable), because the sequence might be empty. On an empty sequence, MinBy returns null for reference types and the default value for value types. That's different from Min, which throws InvalidOperationException on an empty sequence of non-nullable values.
The fix for non-nullable types is either to use DefaultIfEmpty() before Min, or to check Any() first, or to switch to MinBy (which uses null for "no elements"). The nullable overloads (Min on IEnumerable<int?>, IEnumerable<decimal?>, etc.) also avoid the exception by returning null for empty sequences.
MinBy Compares KeysMinBy and MaxBy use the default comparer for the key type. For decimal, int, string, and DateTime, that's the natural ordering. To use a custom comparer, pass an IComparer<TKey> as the second argument.
Without the custom comparer, MinBy(p => p.Name) would use ordinal string comparison, where uppercase letters sort before lowercase. KEYBOARD would come first, not Headphones.
Average(selector) computes the arithmetic mean. Like Sum, it has overloads for every numeric type.
Average on IntegersThis trips up readers. Average on IEnumerable<int> does not return int. It returns double.
The reason is that averaging integers usually produces a non-integer result, and silently truncating would lose information. The framework chooses to return the precise answer in double. The same goes for long (returns double). decimal and double averages return their own type.
| Source type | Average return type |
|---|---|
IEnumerable<int> | double |
IEnumerable<long> | double |
IEnumerable<float> | float |
IEnumerable<double> | double |
IEnumerable<decimal> | decimal |
IEnumerable<int?> | double? |
Like Min and Max, Average on an empty sequence of non-nullable values throws InvalidOperationException. The nullable overloads return null for empty sequences.
DefaultIfEmpty(0m) injects a single 0m if the sequence is empty, which gives Average something to chew on. The alternative is a guard with Any() first.
AggregateAggregate is the most general operator in the list. It folds a sequence into a single value by running an accumulator function for each element. The other operators (Sum, Min, Max, Count) can all be expressed in terms of Aggregate; they exist as named operators because they're common enough to deserve their own names.
The two main shapes are:
Aggregate(seed, accumulator): start with seed, fold every element using accumulator, return the final accumulator value.Aggregate(seed, accumulator, resultSelector): same as above, then run resultSelector over the final accumulator to produce the answer.A classic use of Aggregate is reducing a sequence of strings to a single joined string.
The accumulator starts as "". For each item, it either becomes the item (if accumulator is empty) or appends ", item". After all three items, it holds the full joined string.
In practice, you'd use string.Join(", ", cart) for this exact case because it's clearer and faster. Use Aggregate when the reduction logic is non-trivial.
A more interesting use: compute a weighted loyalty score where each order contributes based on its total and a per-status weight.
Delivered orders count fully (29.99 + 14.50 = 44.49). Shipped orders count half (49.99 * 0.5 = 24.995, rounded to 25.00 in display but kept precise internally). Cancelled orders count zero. The total works out to 69.49 (44.49 + 24.995 plus the rounding the formatter applies).
resultSelectorThe three-argument form is useful when the accumulator type doesn't match the final answer. A common pattern is accumulating a tuple and projecting the final result.
The accumulator carries a running (count, sum) tuple through the sequence. After every element has been folded, resultSelector divides sum by count to produce the average. You'd normally just call .Average(), but this shape generalizes to more complex reductions where no built-in operator fits.
Aggregate walks the sequence exactly once, like every other terminal operator. The cost is the accumulator function cost times the number of elements. Keep the accumulator function cheap; expensive work inside an aggregator multiplies fast.
Any, All, ContainsThese three operators return bool. They answer questions about whether the sequence contains anything that matches a condition.
Any() and Any(predicate)Any() returns true if the sequence has at least one element. Any(predicate) returns true if at least one element satisfies the predicate. Both short-circuit: they stop at the first match.
Any() Instead of Count() > 0You'll see code like if (orders.Count() > 0) in older codebases. It works, but it's wasteful.
Count() > 0 walks the entire sequence to compute the count. Any() stops at the first element. On a large filtered sequence, Any() can be many orders of magnitude faster.
The rule is simple: if you want to know "is there anything?", use Any(). If you want to know "how many?", use Count(). Don't ask for a count when you only care about presence.
All(predicate)All returns true if every element satisfies the predicate. It short-circuits as soon as it finds an element that doesn't satisfy the predicate.
There's one wrinkle: All on an empty sequence returns true. This is called the "vacuous truth" rule. If there are no elements, no element fails the predicate, so the universal "all of them satisfy it" is technically true. It can surprise readers who expect "no elements" to mean false. Guard with Any() first if you specifically want "non-empty AND all satisfy."
Contains(value)Contains returns true if the sequence contains the specified value. The comparison uses EqualityComparer<T>.Default, which means Equals for reference types and value equality for value types.
For case-insensitive string comparison, pass an IEqualityComparer<T>:
Contains on a HashSet<T> is O(1) because HashSet overrides the method to use its internal hash table. On a List<T> or array, it's O(n) because it scans linearly. The LINQ Contains extension picks the right implementation when the source is a HashSet<T> or Dictionary<TKey, TValue> keys.
| Source | Cost of Contains |
|---|---|
HashSet<T> | O(1) |
Dictionary<TKey, TValue>.Keys | O(1) |
List<T> | O(n) |
T[] | O(n) |
IEnumerable<T> (custom iterator) | O(n) |
These operators pull out a specific element: the first, the last, the only one, the one at a given index. They come in pairs: a strict version that throws if no match, and a *OrDefault version that returns the default value.
First and FirstOrDefaultFirst() returns the first element. First(predicate) returns the first element matching the predicate. Both throw InvalidOperationException if no match.
FirstOrDefault returns default(T) instead of throwing. For reference types, that's null. For value types, it's the zero value (0, false, default(DateTime)).
Since .NET 6, FirstOrDefault(defaultValue) lets you supply your own default instead of relying on the type's zero value:
Single and SingleOrDefaultSingle is stricter than First. It returns the only element. If the sequence has zero elements or more than one, it throws.
Use Single only when uniqueness is part of your invariant. "Find the customer by email" is a good fit, because email is unique. "Find the first delivered order" is a poor fit, because there can be many.
SingleOrDefault allows zero or one, but still throws on two or more.
| Operator | 0 elements | 1 element | 2+ elements |
|---|---|---|---|
First | throws | returns it | returns first |
FirstOrDefault | returns default | returns it | returns first |
Single | throws | returns it | throws |
SingleOrDefault | returns default | returns it | throws |
The table is worth memorizing because the throw conditions are easy to mix up.
Last and LastOrDefaultLast returns the last element. Last(predicate) returns the last matching element. Same throw/default behavior as First/FirstOrDefault.
On a pure IEnumerable<T>, Last has to enumerate the whole sequence to find the final element. On an IList<T> (like List<T> or an array), Last checks the indexer and runs in O(1). The IList<T> shortcut is built into the LINQ extensions.
ElementAt and ElementAtOrDefaultElementAt(index) returns the element at a given zero-based index. ElementAtOrDefault(index) returns the default value if the index is out of range instead of throwing.
Like Last, ElementAt uses the indexer on IList<T> and runs in O(1). On a non-indexable IEnumerable<T>, it walks the sequence and is O(n). Since .NET 6, ElementAt(Index) also accepts the Index struct, so ElementAt(^1) returns the last element (the same as Last()).
First, Single, and ElementAtThe right operator says what you mean:
First says "give me the first one, there might be more."Single says "there should be exactly one; tell me loudly if not."ElementAt(0) says "give me the one at index zero, by position."First() and ElementAt(0) return the same value on a non-empty sequence, but the intent differs. First reads as "pick one"; ElementAt(0) reads as "address it by position." Use the one whose meaning matches your code.
An end-to-end example that uses several aggregation operators on the same shopping cart:
Each aggregation operator runs an independent pass over the cart. If you needed many at once and the cart were huge, you could fold them into a single Aggregate pass using a tuple accumulator. For a four-item cart, the readable form wins.
9 quizzes