C# LINQ Aggregation

LINQ aggregation means reducing a sequence of values into one summary value. Instead of writing loops to count records, total prices, find the largest item, or build a custom result, you can use operators such as Count, Sum, Average, Min, Max, and Aggregate. Aggregation matters because most real programs eventually need answers, not just filtered lists: totals, statistics, reports, validation checks, and grouped summaries.

Overview: How Aggregation Works

Most LINQ aggregation methods are terminal operators. A filtering operator such as Where usually returns another deferred sequence, but an aggregate consumes the sequence and returns a single value immediately. Calling numbers.Sum() starts enumeration right away, visits the values, adds them, and returns the total. Calling orders.Count(order => order.IsPaid) scans the sequence and returns an int.

The common numeric aggregates are specialized and strongly typed. Sum has overloads for int, long, float, double, decimal, and nullable versions of those types. Average returns a type suitable for an average: for example, averaging int values returns a double, while averaging decimal values returns a decimal. Min and Max can work directly on simple comparable values or use a selector such as products.Max(product => product.Price).

Count and LongCount count elements. Use Count for normal in-memory collections where the result fits in int; use LongCount when a sequence could contain more than int.MaxValue elements. Any is not an aggregate in the numeric sense, but it is often the better choice when you only need to know whether at least one item exists.

Aggregate is the general-purpose reducer. It receives an accumulator and the next item, then returns the updated accumulator. You can use it for custom totals, building strings, computing several values at once, or applying domain-specific rules. Because it is flexible, it is also easier to make unreadable; use the named aggregate methods when they express the idea clearly.

Internally, LINQ aggregation over IEnumerable<T> is ordinary CLR work: an enumerator is requested, MoveNext advances through each item, selectors and accumulator delegates are invoked, and the final value is returned. No result list is created unless your own code creates one. The cost is usually one pass through the sequence, but each separate aggregate call is a separate pass unless the source has a fast collection-specific path.

Syntax

int[] numbers = { 1, 2, 3, 4 };

int total = numbers.Sum();
int count = numbers.Count();
double average = numbers.Average();
int largest = numbers.Max();
string label = numbers.Aggregate("Values:", (text, n) => $"{text} {n}");
Method Purpose
Count() Returns how many items are in the sequence.
Count(predicate) Counts only items that match a condition.
Sum(selector) Adds numeric values, often selected from objects.
Average(selector) Calculates the arithmetic mean of numeric values.
Min and Max Find the smallest or largest value, or smallest/largest selected value.
Aggregate(seed, func) Starts with a seed value and repeatedly updates an accumulator.

Full programs need the System.Linq namespace. When aggregating objects in lists, you usually also need System.Collections.Generic.

Examples

Example 1: Summarize Scores

using System;
using System.Linq;

class Program
{
    static void Main()
    {
        int[] scores = { 88, 91, 72, 100, 65 };

        Console.WriteLine($"Count: {scores.Count()}");
        Console.WriteLine($"Passing: {scores.Count(score => score >= 70)}");
        Console.WriteLine($"Total: {scores.Sum()}");
        Console.WriteLine($"Average: {scores.Average():F1}");
        Console.WriteLine($"Lowest: {scores.Min()}");
        Console.WriteLine($"Highest: {scores.Max()}");
    }
}

Output:

Count: 5
Passing: 4
Total: 416
Average: 83.2
Lowest: 65
Highest: 100

This example uses the built-in numeric aggregates directly on an array of integers. Average returns a double for integer input, so the format string F1 prints one decimal place. The predicate form of Count counts only scores that satisfy the condition.

Example 2: Aggregate Object Properties

using System;
using System.Collections.Generic;
using System.Linq;

record Product(string Name, string Category, decimal Price, int Stock);

class Program
{
    static void Main()
    {
        var products = new List<Product>
        {
            new Product("Notebook", "Office", 4.50m, 20),
            new Product("Pen", "Office", 1.25m, 80),
            new Product("Mug", "Kitchen", 9.99m, 12),
            new Product("Cable", "Electronics", 12.00m, 7)
        };

        decimal inventoryValue = products.Sum(product => product.Price * product.Stock);
        decimal highestPrice = products.Max(product => product.Price);
        string mostExpensive = products
            .OrderByDescending(product => product.Price)
            .First()
            .Name;

        Console.WriteLine($"Inventory value: {inventoryValue:F2}");
        Console.WriteLine($"Highest price: {highestPrice:F2}");
        Console.WriteLine($"Most expensive item: {mostExpensive}");
    }
}

Output:

Inventory value: 393.88
Highest price: 12.00
Most expensive item: Cable

The selector lambdas tell LINQ which numeric value to aggregate from each Product. Sum(product => product.Price * product.Stock) does not add products; it adds the calculated inventory value for each product. The example uses Max for the highest price and sorting plus First when it needs the object that owns that price.

Example 3: Grouped Aggregation For A Report

using System;
using System.Collections.Generic;
using System.Linq;

record Sale(string Region, string Product, decimal Amount);

class Program
{
    static void Main()
    {
        var sales = new List<Sale>
        {
            new Sale("East", "Keyboard", 120.00m),
            new Sale("West", "Mouse", 45.50m),
            new Sale("East", "Monitor", 210.00m),
            new Sale("West", "Cable", 12.25m),
            new Sale("North", "Keyboard", 115.00m)
        };

        var report = sales
            .GroupBy(sale => sale.Region)
            .Select(group => new
            {
                Region = group.Key,
                Orders = group.Count(),
                Total = group.Sum(sale => sale.Amount),
                Average = group.Average(sale => sale.Amount)
            })
            .OrderByDescending(row => row.Total);

        foreach (var row in report)
        {
            Console.WriteLine($"{row.Region}: {row.Orders} orders, total {row.Total:F2}, avg {row.Average:F2}");
        }
    }
}

Output:

East: 2 orders, total 330.00, avg 165.00
North: 1 orders, total 115.00, avg 115.00
West: 2 orders, total 57.75, avg 28.88

After GroupBy, each group is its own sequence of sales with a Key. Aggregates are then calculated per group, not across the whole list. The final OrderByDescending sorts the report rows by total sales.

Example 4: Custom Reduction With Aggregate

using System;
using System.Linq;

record Transaction(string Label, decimal Amount);

class Program
{
    static void Main()
    {
        Transaction[] transactions =
        {
            new Transaction("Deposit", 250.00m),
            new Transaction("Coffee", -4.75m),
            new Transaction("Book", -18.50m)
        };

        decimal endingBalance = transactions.Aggregate(
            100.00m,
            (balance, transaction) => balance + transaction.Amount);

        string history = transactions.Aggregate(
            "Start: 100.00",
            (text, transaction) => $"{text} | {transaction.Label}: {transaction.Amount:+0.00;-0.00}");

        Console.WriteLine($"Ending balance: {endingBalance:F2}");
        Console.WriteLine(history);
    }
}

Output:

Ending balance: 326.75
Start: 100.00 | Deposit: +250.00 | Coffee: -4.75 | Book: -18.50

Aggregate starts with a seed value and applies the lambda once per transaction. The first call receives the seed and the first transaction; each later call receives the previous result. The decimal example calculates a balance, while the string example builds a readable transaction history.

How It Works Step By Step

  1. The compiler resolves the aggregate as an extension method from System.Linq.Enumerable.
  2. If you provide a selector, such as sale => sale.Amount, the compiler type-checks it and creates a delegate.
  3. When the aggregate is called, LINQ starts enumerating the source immediately.
  4. For Sum, LINQ keeps a running total. For Count, it increments a counter. For Min and Max, it keeps the best value seen so far.
  5. For Average, LINQ tracks both total and count, then divides at the end.
  6. For Aggregate, LINQ stores the accumulator value returned by your lambda and passes it into the next lambda call.
  7. When enumeration ends, the final summary value is returned to your code.

Aggregates usually make one pass through the sequence. However, two separate calls such as query.Count() and query.Sum() usually enumerate twice. For small in-memory lists this is often fine; for expensive iterators, file reads, network-backed sequences, or database queries, repeated aggregation can be a serious cost.

Common Mistakes

Assuming Every Aggregate Accepts An Empty Sequence

using System;
using System.Linq;

class Program
{
    static void Main()
    {
        int[] numbers = Array.Empty<int>();

        Console.WriteLine($"Sum: {numbers.Sum()}");

        try
        {
            Console.WriteLine(numbers.Average());
        }
        catch (InvalidOperationException ex)
        {
            Console.WriteLine(ex.GetType().Name);
        }
    }
}

Output:

Sum: 0
InvalidOperationException

Sum returns zero for an empty non-nullable numeric sequence, but Average, Min, and Max throw because there is no value to return. Check with Any(), use nullable values, or provide a default when an empty result is valid for your domain.

using System;
using System.Linq;

class Program
{
    static void Main()
    {
        int[] numbers = Array.Empty<int>();

        double average = numbers.DefaultIfEmpty(0).Average();
        int largest = numbers.DefaultIfEmpty(-1).Max();

        Console.WriteLine($"Average: {average:F1}");
        Console.WriteLine($"Largest or default: {largest}");
    }
}

Output:

Average: 0.0
Largest or default: -1

DefaultIfEmpty supplies one fallback item only when the source has no items. Choose the fallback carefully; -1 is fine only when it clearly means no real value in your program.

Running Multiple Aggregates Over An Expensive Query

using System;
using System.Linq;

class Program
{
    static void Main()
    {
        int[] numbers = { 1, 2, 3 };
        var query = numbers.Select(number =>
        {
            Console.WriteLine($"Reading {number}");
            return number * 10;
        });

        Console.WriteLine($"Count: {query.Count()}");
        Console.WriteLine($"Total: {query.Sum()}");
    }
}

Output:

Reading 1
Reading 2
Reading 3
Count: 3
Reading 1
Reading 2
Reading 3
Total: 60

The query is deferred, and each terminal aggregate enumerates it. If the source is expensive or has side effects, materialize once with ToList() or write one custom aggregation that collects all needed values in one pass.

Best Practices

  • Use the specific aggregate method when it communicates the intent: Sum for totals, Average for means, Min and Max for boundaries.
  • Use Any() instead of Count() > 0 when you only need to know whether at least one item exists.
  • Think about empty sequences before calling Average, Min, Max, First, or seedless Aggregate.
  • Prefer decimal for money and other base-10 financial totals.
  • Use LongCount when the count could exceed the range of int.
  • Keep Aggregate readable. If the accumulator logic is complex, use a small named record or a normal loop.
  • Do not hide expensive work inside selectors if the same query will be aggregated multiple times.
  • For grouped reports, aggregate inside each GroupBy result, then project to a clear report shape.

Practice Exercises

  1. Create an array of temperatures. Print the count, average, lowest value, and highest value, formatting the average to one decimal place.
  2. Create a Book record with title, genre, and price. Group books by genre and print the number of books and total price per genre.
  3. Use Aggregate with a seed value to multiply all numbers in an array. Then decide what the seed should be for an empty array.

Summary

  • LINQ aggregation reduces a sequence to a single summary value.
  • Count, Sum, Average, Min, and Max cover the most common summaries.
  • Aggregates are terminal operators, so they enumerate the source immediately.
  • Selectors let you aggregate object properties or calculated values.
  • GroupBy plus aggregation is the standard pattern for reports and dashboards.
  • Aggregate handles custom reductions, but it should stay readable.
  • Empty sequences and repeated enumeration are the two most common aggregation pitfalls.