Mert Özen Further With Every Line
Backend LINQ — #07

LINQ Aggregation Operators: Count, Sum, Average, Min, Max, Aggregate and MinBy/MaxBy

Mert Özen Nov 7, 2026 11 min 2 views
LINQ Aggregation Operators: Count, Sum, Average, Min, Max, Aggregate and MinBy/MaxBy

Why Sum returns 0 and Max throws on an empty sequence, and the nullable selector and DefaultIfEmpty fixes. Count and .NET 6 TryGetNonEnumeratedCount, the return type of Average, the checked overflow of Sum, MinBy/MaxBy returning the element instead of the value, the three overloads of Aggregate, and how many times you enumerate the same query.

Version note: The code in this post uses C# 10 and the .NET 6 APIs; it was built and run with the .NET 10 SDK.

Guess First

You're writing a screen that summarizes a shopping cart: how many items, what the total is, how much the most expensive one costs, what the average price is. While testing, you left the cart empty. What does this code print?

List<decimal> cartPrices = new();

Console.WriteLine($"Count: {cartPrices.Count()}");
Console.WriteLine($"Sum: {cartPrices.Sum()}");
Console.WriteLine($"Max: {cartPrices.Max()}");
Console.WriteLine($"Average: {cartPrices.Average()}");

Empty Sequence: Sum Is Zero, Max Throws

Output (I trimmed the rest of the stack trace; since there's no .NET 6 runtime on this machine, the examples target net6.0 and ran on the .NET 8 runtime with DOTNET_ROLL_FORWARD=Major, so message texts may differ slightly between runtime versions):

Count: 0
Sum: 0
Unhandled exception. System.InvalidOperationException: Sequence contains no elements
   at System.Linq.ThrowHelper.ThrowNoElementsException()
   at System.Linq.Enumerable.Max(IEnumerable`1 source)

Count said 0, Sum said 0, and Max crashed the program. Average never got its turn; if it had, it would have thrown the same exception. There's logic behind this split. The total of an empty cart really is 0: adding nothing gives you the identity element of addition. But an empty cart has no most expensive item and no average; saying 0 would be a lie. Since decimal can't be null, LINQ has no answer left other than an exception.

This post covers LINQ's aggregation operators: Count, Sum, Average, Min and Max, which reduce a sequence to a single value, Aggregate, the general form of all of them, and MinBy/MaxBy from .NET 6. They all use immediate execution: the moment you call them, they walk the sequence and return the result.

The examples from here on use the bookstore data from the partitioning post. Put the file below next to Program.cs as Data.cs:

record Book(string Isbn, string Title, string Author, decimal Price, int Stock, int Year);

static class Store
{
    public static List<Book> Books { get; } = new()
    {
        new("978-0441172719", "Dune", "Frank Herbert", 12.99m, 4, 1965),
        new("978-0553293357", "Foundation", "Isaac Asimov", 9.99m, 0, 1951),
        new("978-0451524935", "1984", "George Orwell", 8.99m, 7, 1949),
        new("978-0261102217", "The Hobbit", "J.R.R. Tolkien", 10.99m, 2, 1937),
        new("978-0547928210", "The Fellowship of the Ring", "J.R.R. Tolkien", 15.99m, 3, 1954),
        new("978-0553382563", "I, Robot", "Isaac Asimov", 7.99m, 5, 1950),
        new("978-0060850524", "Brave New World", "Aldous Huxley", 11.49m, 1, 1932),
        new("978-0451526342", "Animal Farm", "George Orwell", 6.99m, 9, 1945),
        new("978-0441569595", "Neuromancer", "William Gibson", 10.49m, 0, 1984),
        new("978-0441478125", "The Left Hand of Darkness", "Ursula K. Le Guin", 9.49m, 6, 1969),
    };
}

There are two common ways to solve the empty cart problem:

List<Book> noBooks = new();

decimal? maxOrNull = noBooks.Max(b => (decimal?)b.Price);
decimal maxOrZero = noBooks.Select(b => b.Price).DefaultIfEmpty(0m).Max();
decimal? averageOrNull = noBooks.Average(b => (decimal?)b.Price);
decimal? sumOfNullable = noBooks.Sum(b => (decimal?)b.Price);

Console.WriteLine($"Max as decimal?: {maxOrNull?.ToString() ?? "null"}");
Console.WriteLine($"DefaultIfEmpty(0).Max(): {maxOrZero}");
Console.WriteLine($"Average as decimal?: {averageOrNull?.ToString() ?? "null"}");
Console.WriteLine($"Sum as decimal?: {sumOfNullable?.ToString() ?? "null"}");
Max as decimal?: null
DefaultIfEmpty(0).Max(): 0
Average as decimal?: null
Sum as decimal?: 0

When you cast the selector to a nullable type ((decimal?)b.Price), a different overload runs. On an empty sequence Max and Average no longer throw, they return null; the "no value" information travels inside the type. Watch out: even the nullable Sum returned 0, not null. The second way is to turn the empty sequence into a one-element sequence with DefaultIfEmpty(0m). It's short and readable, but it erases the difference between "the cart is empty" and "the most expensive item costs 0". If you're going to show an "empty cart" message on the screen, pick the nullable route; if a report just needs a number, pick DefaultIfEmpty.

Count, LongCount and TryGetNonEnumeratedCount

List<Book> books = Store.Books;
IEnumerable<Book> cheap = books.Where(b => b.Price < 10m);

Console.WriteLine($"Count(): {books.Count()}, out of stock: {books.Count(b => b.Stock == 0)}, LongCount(): {books.LongCount()}");

Console.WriteLine($"List: {books.TryGetNonEnumeratedCount(out int listCount)} ({listCount})");
Console.WriteLine($"Select over List: {books.Select(b => b.Title).TryGetNonEnumeratedCount(out int selectCount)} ({selectCount})");
Console.WriteLine($"Where: {cheap.TryGetNonEnumeratedCount(out int whereCount)} ({whereCount})");
Count(): 10, out of stock: 2, LongCount(): 10
List: True (10)
Select over List: True (10)
Where: False (0)

If the source is an ICollection<T> (List<T>, an array, HashSet<T>), Count() reads its Count property directly and doesn't count the elements. Count(b => b.Stock == 0), which takes a predicate, always walks the whole sequence. LongCount does the same job and returns a long; it's there for the rare cases where you count more than 2 billion elements.

TryGetNonEnumeratedCount, added in .NET 6, asks this question: "Can I learn the count without walking the elements?" On a list the answer is yes. On a Select over the list it's also yes, because Select doesn't change the number of elements and LINQ knows it. On Where the answer is no: the only way to find out how many elements match the condition is to look at all of them. This method is useful in code that wants to allocate capacity before processing a sequence; take the count if it's cheap, don't force it if it isn't. And if you want to know whether a sequence has any elements at all, write Any(), not Count() > 0; we'll measure why in the series' Any/All post.

Sum and Average: Return Type and Overflow

using System.Globalization;

CultureInfo.CurrentCulture = CultureInfo.InvariantCulture;

int totalStock = Store.Books.Sum(b => b.Stock);
decimal stockValue = Store.Books.Sum(b => b.Price * b.Stock);
double averageStock = Store.Books.Average(b => b.Stock); // the average of ints is a double
decimal averagePrice = Store.Books.Average(b => b.Price);

Console.WriteLine($"Total stock: {totalStock}, stock value: {stockValue}");
Console.WriteLine($"Average stock: {averageStock}, average price: {averagePrice}");

int[] pageViews = { int.MaxValue, 1 };

int loopTotal = 0;
foreach (int v in pageViews)
{
    loopTotal += v; // unchecked by default
}
Console.WriteLine($"foreach: {loopTotal}");

try
{
    Console.WriteLine(pageViews.Sum());
}
catch (OverflowException ex)
{
    Console.WriteLine($"Sum(): {ex.GetType().Name}: {ex.Message}");
}

Console.WriteLine($"Sum as long: {pageViews.Sum(v => (long)v)}");
Total stock: 37, stock value: 356.13
Average stock: 3.7, average price: 10.54
foreach: -2147483648
Sum(): OverflowException: Arithmetic operation resulted in an overflow.
Sum as long: 2147483648

Sum keeps the type of the selector: the stocks are int, the stock value is decimal. Average is different. The average of an int sequence returns a double, because 37 divided by 10 is 3.7, not 3; LINQ doesn't do integer division for you. The average of a decimal sequence stays decimal, which is exactly what you want for money.

The real surprise is overflow. The hand-written foreach loop silently turned int.MaxValue + 1 into -2147483648; arithmetic in C# is unchecked by default. Sum() threw an OverflowException instead, because LINQ's int and long sums run checked. That's a good thing: crashing beats silently printing a wrong total in a report. If you're adding large numbers, convert to long in the selector, as in Sum(v => (long)v).

Min, Max and MinBy/MaxBy: Value or Element?

"Which is the most expensive book?" can actually be two different questions: what the highest price is, or which book has that price.

using System.Globalization;

CultureInfo.CurrentCulture = CultureInfo.InvariantCulture;

decimal highestPrice = Store.Books.Max(b => b.Price);
Book sortedFirst = Store.Books.OrderByDescending(b => b.Price).First();
Book? mostExpensive = Store.Books.MaxBy(b => b.Price);
Book? oldest = Store.Books.MinBy(b => b.Year);
Book? firstOutOfStock = Store.Books.MinBy(b => b.Stock); // two books have a stock of 0

Console.WriteLine($"Max: {highestPrice}");
Console.WriteLine($"OrderByDescending().First(): {sortedFirst.Title}");
Console.WriteLine($"MaxBy: {mostExpensive?.Title}, MinBy(Year): {oldest?.Title} ({oldest?.Year})");
Console.WriteLine($"MinBy(Stock): {firstOutOfStock?.Title}");

List<Book> noBooks = new();
Console.WriteLine($"MaxBy on empty List<Book>: {noBooks.MaxBy(b => b.Price)?.Title ?? "null"}");

try
{
    int[] noNumbers = Array.Empty<int>();
    noNumbers.MaxBy(n => n);
}
catch (InvalidOperationException ex)
{
    Console.WriteLine($"MaxBy on empty int[]: {ex.Message}");
}

try
{
    Book? max = Store.Books.Max();
}
catch (ArgumentException ex)
{
    Console.WriteLine($"Max() on Book: {ex.Message}");
}
Max: 15.99
OrderByDescending().First(): The Fellowship of the Ring
MaxBy: The Fellowship of the Ring, MinBy(Year): Brave New World (1932)
MinBy(Stock): Foundation
MaxBy on empty List<Book>: null
MaxBy on empty int[]: Sequence contains no elements
Max() on Book: At least one object must implement IComparable.

Max(b => b.Price) gives you a decimal, not the book itself. Before .NET 6, getting the book meant either writing OrderByDescending(...).First() or finding the maximum price first and then searching again by that price. The first sorts the whole list (O(n log n)), the second walks the list twice. MaxBy and MinBy return the element in a single pass, without sorting. On a tie they return the first one: of the two books with a stock of 0, Foundation, which comes first in the list, was picked.

On an empty sequence these two behave according to the type. For a reference type like Book they return null, which is why the return type is Book?. For a value type like int they throw, just like Max. The last line shows a common mistake too: if you call Max() without a selector, LINQ tries to compare the elements to each other. Book is a record and has value equality, but no ordering; since it doesn't implement IComparable, you get an exception. Always say which field to compare by.

Aggregate: Writing Your Own Aggregation

Sum, Max and the rest are really special cases of the same idea: start from an initial value, update it for each element, and end up with a single result. Aggregate is that idea itself. In functional programming it's called fold or reduce:

using System.Globalization;

CultureInfo.CurrentCulture = CultureInfo.InvariantCulture;

string titles = Store.Books
    .Take(3)
    .Select(b => b.Title)
    .Aggregate((joined, title) => joined + ", " + title); // no seed: the first element is the starting value
Console.WriteLine(titles);

decimal[] discounts = { 0.10m, 0.05m };
decimal finalPrice = discounts.Aggregate(100m, (price, discount) => price * (1 - discount));
Console.WriteLine($"100 after discounts: {finalPrice}");

(decimal Min, decimal Max) range = Store.Books.Aggregate(
    (Min: decimal.MaxValue, Max: decimal.MinValue),
    (acc, b) => (Math.Min(acc.Min, b.Price), Math.Max(acc.Max, b.Price)));
Console.WriteLine($"Price range: {range.Min} - {range.Max}");

string initials = Store.Books.Aggregate(
    "",
    (acc, b) => acc + b.Title[0],
    acc => acc.ToLowerInvariant()); // transform the result one last time
Console.WriteLine($"Initials: {initials}");

try
{
    new List<string>().Aggregate((a, b) => a + b);
}
catch (InvalidOperationException ex)
{
    Console.WriteLine($"Aggregate without seed on empty: {ex.Message}");
}
Dune, Foundation, 1984
100 after discounts: 85.5000
Price range: 6.99 - 15.99
Initials: df1ttibant
Aggregate without seed on empty: Sequence contains no elements

There are three overloads. The one without a seed uses the first element as the starting value; on an empty sequence there's nothing to start from, so it throws. The one with a seed (100m) returns the seed as is on an empty sequence. The third applies one last transformation to the result. The discount example shows why Aggregate is needed: a 10 percent discount followed by a 5 percent one isn't 15 percent, the result is 85.50. The 85.5000 in the output is a decimal trait as well: in multiplication the decimal places (scale) of the two numbers add up, and ToString shows those places.

The example with the tuple seed found the lowest and highest price in a single pass. As for the string joining example, write it with string.Join in real code; concatenating strings with Aggregate creates a new string at every step. Reach for Aggregate for a calculation that no ready-made operator covers. Writing Aggregate where Sum would do tires the reader.

How Many Times Are You Enumerating?

Aggregation operators run immediately; each one walks the source from start to end the moment it's called. If the source is a deferred query, every call runs the query again:

using System.Globalization;

CultureInfo.CurrentCulture = CultureInfo.InvariantCulture;

int reads = 0;
IEnumerable<Book> inStock = Store.Books.Where(b =>
{
    reads++;
    return b.Stock > 0;
});

decimal total = inStock.Sum(b => b.Price);
int count = inStock.Count();
decimal max = inStock.Max(b => b.Price);
Console.WriteLine($"Three aggregates on the query: {reads} reads");

reads = 0;
List<Book> snapshot = inStock.ToList();
total = snapshot.Sum(b => b.Price);
count = snapshot.Count;
max = snapshot.Max(b => b.Price);
Console.WriteLine($"ToList, then three aggregates: {reads} reads");

reads = 0;
(int Count, decimal Total, decimal Max) stats = inStock.Aggregate(
    (Count: 0, Total: 0m, Max: 0m),
    (acc, b) => (acc.Count + 1, acc.Total + b.Price, Math.Max(acc.Max, b.Price)));
Console.WriteLine($"One Aggregate: {reads} reads -> {stats}");
Three aggregates on the query: 30 reads
ToList, then three aggregates: 10 reads
One Aggregate: 10 reads -> (8, 84.92, 15.99)

Three aggregations over the same Where query ran the condition 30 times: each operator filtered the ten books separately. For ten books in memory that doesn't matter. But if the query reads a file, goes to a database or does an expensive calculation, you pay for the same work three times. Materializing the query once with ToList() brought it down to 10. On top of that, snapshot.Count now reads the list's property without walking anything. Computing all three values with a single Aggregate also finishes in one pass; on a very large streaming source (like a file with millions of lines), that's how you build a summary without loading it into a list. We'll measure multiple enumeration and when to materialize with BenchmarkDotNet in the series' performance post.

Common Pitfalls

Calling Max, Min or Average on a sequence that may be empty. The day nothing passes the filter, you get an InvalidOperationException. Use a nullable selector or DefaultIfEmpty, or check with Any() first.

Expecting the average of ints to be an int. Average returns a double; if you cast the result to int, you throw away the fraction with your own hands.

Forgetting about overflow in large int sums. Sum runs checked and throws an OverflowException; where the numbers can grow, convert to long in the selector.

Sorting to find an element. OrderByDescending(x => x.Price).First() sorts the whole list; since .NET 6, MaxBy does the same job in a single pass.

Calling Max() without a selector on your own types. If the type isn't IComparable, you get an exception; state explicitly which field to compare by.

Calling Count, Sum and Max one after another on the same deferred query. Each one runs the query from scratch. Materialize first, or compute everything with a single Aggregate.

A Small Experiment

★☆☆ Find the average price of the books that are out of stock. Then change the condition to one no book matches (b.Stock > 100) and write two different solutions that keep the code from crashing.

★★☆ Without using MinBy and MaxBy, find the oldest and the newest book (with their titles) in a single pass using only Aggregate.

★★★ Compute each book's stock value (Price * Stock) and print what percentage of the total stock value each book makes up. Measure how many times you walk the source with a counter and get that number as low as you can.

Aggregation operators reduced the whole sequence to a single number. Most of the time, though, we ask "for each author" or "for each genre". In the next post we move on to GroupBy and ToLookup, which split a sequence by key.