Filtering and grouping still leave you with sequences. Aggregation is how you collapse one down to a single answer.
"How many orders were placed?" "What's our total revenue?" "What's the most expensive product in the catalog?" None of these questions want a list back — they want one number, one value, one answer. LINQ's aggregation operators exist for exactly this: reducing a whole sequence down to a single summarizing result.
This lesson covers Count, Sum, Average, Min, Max, and Aggregate for fully custom accumulation — plus how to decide when to reach for Aggregate versus one of the specific, purpose-built aggregate methods.
Every operator so far in this module — Where, Select, OrderBy, GroupBy, Join — takes a sequence and gives you back another sequence. Aggregation is different: it takes a sequence and gives you back one value. Count, Sum, Average, Min, and Max each answer one specific, common summarizing question. Aggregate is the general-purpose tool underneath all of them — a way to build your own custom reduction when none of the specific methods fit.
public static int Count<TSource>(this IEnumerable<TSource> source);
public static int Count<TSource>(this IEnumerable<TSource> source, Func<TSource, bool> predicate);
public static decimal Sum<TSource>(this IEnumerable<TSource> source, Func<TSource, decimal> selector);
public static double Average<TSource>(this IEnumerable<TSource> source, Func<TSource, int> selector);
public static TSource Min<TSource>(this IEnumerable<TSource> source);
public static TSource Max<TSource>(this IEnumerable<TSource> source);
public static TAccumulate Aggregate<TSource, TAccumulate>(
this IEnumerable<TSource> source,
TAccumulate seed,
Func<TAccumulate, TSource, TAccumulate> func)Notice the return types: none of these return IEnumerable<T> — they return a plain int, decimal, double, TSource, or (for Aggregate) whatever accumulator type you choose. That's the defining trait of every aggregation operator, and it has a real consequence covered in Under the Hood: these methods can't be deferred the way Where or Select are — there's no sequence left to hand back, only a finished answer.
Before these operators, computing a total meant a manual accumulator variable and a loop:
// Manual accumulation
decimal total = 0m;
foreach (var order in orders)
{
total += order.Total;
}// Sum
decimal total = orders.Sum(o => o.Total);The manual version isn't wrong, exactly — it's just five common patterns (count, sum, average, min, max) each reimplemented from scratch, every time, with room for an off-by-one or an uninitialized variable to sneak in. Naming the intent directly — Sum, Average, Max — makes the code both shorter and unmistakably clear about what it computes.
Aggregate, STEP BY STEPseed value you provided — this is the starting point before any item has been processed.func is called with the current accumulator value and the next item, and returns the new accumulator value.public record Product(int Id, string Name, string Category, decimal Price, int Stock);
List<Product> products =
[
new(1, "Wireless Mouse", "Electronics", 24.99m, 120),
new(2, "Mechanical Keyboard", "Electronics", 89.99m, 0),
new(3, "Standing Desk", "Furniture", 349.00m, 15),
new(4, "Desk Lamp", "Furniture", 19.50m, 60),
new(5, "Noise-Cancelling Headphones", "Electronics", 199.99m, 8),
];
int totalProducts = products.Count(); // 5
int inStockCount = products.Count(p => p.Stock > 0); // 4
decimal totalValue = products.Sum(p => p.Price * p.Stock); // total inventory value
decimal averagePrice = products.Average(p => p.Price); // (decimal) — averaging decimals stays decimal
decimal cheapest = products.Min(p => p.Price); // 19.50
decimal priciest = products.Max(p => p.Price); // 349.00
// Min/Max can also return the whole item, not just the compared value, via MinBy/MaxBy
Product cheapestProduct = products.MinBy(p => p.Price)!; // Desk Lamp
Product priciestProduct = products.MaxBy(p => p.Price)!; // Standing Desk
Console.WriteLine($"{totalProducts} products, {inStockCount} in stock");
Console.WriteLine($"Average price: ${averagePrice:F2}, cheapest: {cheapestProduct.Name}, priciest: {priciestProduct.Name}");
// 5 products, 4 in stock
// Average price: $136.69, cheapest: Desk Lamp, priciest: Standing DeskCode → Meaning → Result: Min/Max with a selector return the smallest or largest value the selector produced; MinBy/MaxBy (added in .NET 6) return the whole item that produced that extreme value — genuinely useful when you need "which product" rather than just "how cheap."
Aggregate — Custom AccumulationThe five methods above cover the vast majority of everyday needs. Aggregate exists for the rest — any custom reduction that doesn't map onto count, sum, average, min, or max directly:
// Building a running total isn't a built-in operator — Aggregate handles it
decimal[] amounts = [120.50m, 75.00m, 200.00m, 50.25m];
decimal total = amounts.Aggregate(0m, (running, amount) => running + amount);
Console.WriteLine(total); // 445.75 — equivalent to Sum(), shown here just to illustrate the mechanics
// A genuinely custom reduction: find the largest single jump between consecutive prices
decimal[] prices = [24.99m, 89.99m, 349.00m, 19.50m, 199.99m];
var (_, largestJump) = prices.Aggregate(
(Previous: prices[0], LargestJump: 0m),
(acc, price) =>
{
decimal jump = Math.Abs(price - acc.Previous);
return (Previous: price, LargestJump: Math.Max(acc.LargestJump, jump));
});
Console.WriteLine($"Largest jump between consecutive prices: ${largestJump}");
// Largest jump between consecutive prices: $259.01 (from $89.99 to $349.00)Here the accumulator is a tuple carrying two pieces of running state (the previous price, and the largest jump seen so far) — something none of Sum/Average/Min/Max could express, because Aggregate's accumulator can be any type you need, not just a running number.
A sales dashboard needs a handful of summary statistics computed from the same underlying order data — exactly the kind of "headline numbers" every admin dashboard shows at the top of the page:
public record Order(int Id, int CustomerId, DateOnly OrderDate, decimal Total);
List<Order> orders =
[
new(101, 1, new(2026, 1, 5), 120.50m),
new(102, 2, new(2026, 1, 18), 75.00m),
new(103, 1, new(2026, 2, 2), 200.00m),
new(104, 3, new(2026, 2, 14), 50.25m),
new(105, 1, new(2026, 2, 20), 99.99m),
new(106, 2, new(2026, 3, 1), 310.00m),
];
var dashboard = new
{
TotalOrders = orders.Count(),
TotalRevenue = orders.Sum(o => o.Total),
AverageOrder = orders.Average(o => o.Total),
LargestOrder = orders.Max(o => o.Total),
SmallestOrder = orders.Min(o => o.Total),
BigSpenderOrders = orders.Count(o => o.Total > 100m)
};
Console.WriteLine($"""
Orders: {dashboard.TotalOrders}
Revenue: ${dashboard.TotalRevenue}
Avg order: ${dashboard.AverageOrder:F2}
Range: ${dashboard.SmallestOrder} – ${dashboard.LargestOrder}
Orders over $100: {dashboard.BigSpenderOrders}
""");
// Orders: 6
// Revenue: $855.74
// Avg order: $142.62
// Range: $50.25 – $310.00
// Orders over $100: 3This pattern — several aggregation calls against the same source, each answering a different specific question — is exactly how most dashboard and report "headline numbers" are computed in real applications.
Every other LINQ operator in this module hands you back another drawer full of items — still a collection, just reshaped. Aggregation is different: it's like feeding a stack of receipts through an adding machine. What comes out the other end isn't a stack of receipts anymore — it's a single printed total on the tape. Aggregate is that adding machine with a custom program: you decide exactly how each receipt updates the running tape, not just "add it up."
Where, Select, and GroupBy can all return a lazy IEnumerable<T> that does nothing until enumerated — that's what deferred execution means. Aggregation methods return a plain int, decimal, or similar — there's nothing left to hand back except the fully-computed answer, so the entire source must be walked immediately, the moment you call Sum(), Count(), or Aggregate().// Conceptually, Sum(selector) is:
source.Aggregate(0m, (total, item) => total + selector(item));
Aggregate exposes generally.Count() and Sum() return 0 for an empty sequence — a sensible default. Average(), Min(), and Max() instead throw an InvalidOperationException, because there's genuinely no meaningful average, minimum, or maximum of nothing. This distinction is a real, common source of runtime bugs — covered next.Count() the LINQ method vs a .Count propertyCount() — with parentheses — is a LINQ extension method that, for a general IEnumerable<T>, may need to enumerate the whole sequence to count it. Many concrete collection types (List<T>, arrays via .Length) already track their count, and calling the property directly is cheaper than the LINQ method. This distinction matters enough to get its own deeper treatment in the LINQ Performance lesson at the end of this module.
It's easy to assume all the aggregate methods behave consistently on an empty sequence. They don't: Sum and Count return 0; Average, Min, and Max throw. Always check Any() first (or use the safer overloads discussed in Common Mistakes) before calling Average, Min, or Max on a sequence that might be empty.
Average/Min/Max on a sequence that might be empty orders.Where(o => o.CustomerId == 999).Average(o => o.Total) throws InvalidOperationException the moment there are zero matching orders. Guard with Any() first, or reach for the nullable-returning overloads that exist for exactly this: orders.Where(...).Select(o => o.Total).DefaultIfEmpty().Average(), or simply check if (filtered.Any()) { ... } before aggregating.
Aggregate when a specific method already exists orders.Aggregate(0m, (sum, o) => sum + o.Total) works, but it's a longer, less self-documenting way to write orders.Sum(o => o.Total). Always prefer the specific, named method when your reduction maps directly onto count, sum, average, min, or max — reserve Aggregate for reductions those methods genuinely can't express.
Sum/Count return 0 for an empty sequence; Average/Min/Max throw.Aggregate when one already fits.
Count, Sum, Average, Min, and Max each collapse a sequence into one specific, well-known summary value.Aggregate generalizes the same idea for custom accumulation logic that the specific methods can't express.Where/Select/GroupBy, there's no lazy sequence left to defer.Sum/Count handle an empty sequence gracefully (returning 0); Average/Min/Max throw — guard with Any() first.Aggregate for genuinely custom reductions.You've learned how to collapse sequences into summary values. Let's check your understanding.
1. What happens when you call emptyOrders.Average(o => o.Total) on a sequence with zero items?
Correct: C
Why C is correct: As covered in Under the Hood and Common Confusion, Average (along with Min and Max) throws on an empty sequence because there's no mathematically meaningful average of zero items.
Why A is incorrect: Returning 0 for an empty sequence is the behavior of Sum and Count, not Average.
Why B is incorrect: The standard Average overload doesn't return a nullable type or silently produce null — it throws.
Why D is incorrect: LINQ operators don't block or wait for more items — an empty in-memory sequence is fully known and processed immediately, resulting in an exception.
Reinforcement: Always confirm a sequence is non-empty (with Any()) before calling Average, Min, or Max.
2. Why can't Sum() be deferred the way Where() is?
Correct: B
Why B is correct: As explained in Under the Hood, deferred execution works because operators like Where return an object describing future work. Sum() has nothing like that to return — its return type is a plain number, which can only exist once the computation has actually finished.
Why A is incorrect: This is exactly the misconception the lesson corrects — aggregation methods are eager precisely because Where's deferral trick isn't available to them.
Why C is incorrect: This behavior doesn't depend on the concrete source type — it's inherent to what Sum() returns, regardless of whether the source is a List<T>, an array, or any other IEnumerable<T>.
Why D is incorrect: Deferred execution has nothing to do with the return type being bool — it's about whether the method can return a lazy, not-yet-evaluated sequence at all.
Reinforcement: Return type is the giveaway: IEnumerable<T> can be deferred; a scalar value cannot.
3. You need to reduce a sequence of temperature readings into both the highest reading and the number of times the temperature dropped compared to the previous reading — two pieces of running state that no single built-in aggregate method computes together. Which operator is the right fit?
Correct: B
Why B is correct: This is exactly the shape of problem Aggregate is built for — as shown in the "largest jump" example, an accumulator can be any type, including a tuple carrying multiple pieces of running state through the whole sequence in a single pass.
Why A is incorrect: Sum only ever tracks a single running numeric total — it has no way to carry a second piece of state like a drop counter.
Why C is incorrect: Calling Max() twice would give you the highest reading, but nothing at all about how many times the temperature dropped between consecutive readings.
Why D is incorrect: This is precisely the situation Aggregate exists to avoid a manual loop for — it's a fully LINQ-native way to express custom, stateful accumulation.
Reinforcement: Whenever a reduction needs more than one running value, reach for Aggregate with a tuple or custom accumulator type.
4. Why is orders.Sum(o => o.Total) generally preferred over orders.Aggregate(0m, (sum, o) => sum + o.Total) even though both produce the same result?
Correct: B
Why B is correct: As covered in Common Mistakes, reaching for Aggregate when a specific named method already exists is a real mistake — it produces the same answer with less clarity. Naming your intent directly (Sum) is preferred whenever it's available.
Why A is incorrect: Aggregate works perfectly well with decimals, as the example shows — it's a fully general tool, not restricted by type.
Why C is incorrect: Aggregate is a standard, actively supported part of LINQ — it's simply the wrong choice for this particular case, not deprecated.
Why D is incorrect: As shown in Under the Hood, Sum is conceptually just Aggregate with a fixed accumulator function — for this exact case, they are functionally equivalent.
Reinforcement: Reach for the specific method first; fall back to Aggregate only when no specific method expresses what you need.
You can now summarize sequences into meaningful numbers. Next up: the crucial idea underneath every LINQ operator you've used so far — deferred execution.
dotnetmadeeasy.com — Learn C# and .NET, the right way.