BrandGhost
LINQ Grouping in C#: GroupBy, ToLookup, CountBy, and AggregateBy

LINQ Grouping in C#: GroupBy, ToLookup, CountBy, and AggregateBy

Grouping data is one of the most common data-processing tasks you'll face as a .NET developer, and LINQ grouping in C# gives you a remarkably expressive toolkit to handle it. Whether you're bucketing orders by status, counting products per category, or building hierarchical summaries, understanding the full range of grouping operators -- from the classic GroupBy through to the .NET 9 additions CountBy and AggregateBy -- will make your code shorter, faster to read, and easier to maintain.

This article walks through every major grouping API with real-world domain examples, before-and-after comparisons across .NET versions, and honest guidance on when to reach for each tool.

What Is Grouping in LINQ?

Grouping partitions a sequence into sub-sequences called groups, where each group shares a common key. In LINQ, every group is represented as IGrouping<TKey, TElement>, which is itself an IEnumerable<TElement> with an attached Key property.

The two primary mechanisms are:

  • GroupBy -- deferred execution, returns IEnumerable<IGrouping<TKey, TElement>>
  • ToLookup -- immediate execution, returns a ILookup<TKey, TElement> that supports random access by key

Knowing which one to use matters more than most developers realize, and we'll cover the trade-offs in detail below.

GroupBy: The Foundation

The simplest overload accepts only a key selector:

namespace DevLeader.LinqGrouping;

public record Order(int Id, string Status, decimal Total, string CustomerId);

// Basic grouping by status
IEnumerable<Order> orders = GetOrders();

IEnumerable<IGrouping<string, Order>> grouped = orders.GroupBy(o => o.Status);

foreach (IGrouping<string, Order> group in grouped)
{
    Console.WriteLine($"Status: {group.Key}  Count: {group.Count()}");
    foreach (Order order in group)
    {
        Console.WriteLine($"  Order {order.Id} -- ${order.Total}");
    }
}

The key insight here is that GroupBy is deferred -- no work happens until you enumerate grouped. If you call GroupBy twice without materializing the result, the source sequence is iterated twice. That is why knowing when to use ToLookup is important.

GroupBy with Element Selector

When you don't need the full element in each group, pass an element selector as the second argument:

namespace DevLeader.LinqGrouping;

// Only keep the order totals per customer
IEnumerable<IGrouping<string, decimal>> totalsByCustomer =
    orders.GroupBy(o => o.CustomerId, o => o.Total);

foreach (IGrouping<string, decimal> group in totalsByCustomer)
{
    decimal sum = group.Sum();
    Console.WriteLine($"Customer {group.Key}: ${sum:F2}");
}

GroupBy with Result Selector

The result selector overload lets you project each group into a final shape in one pass:

namespace DevLeader.LinqGrouping;

public record OrderSummary(string Status, int Count, decimal TotalRevenue);

IEnumerable<OrderSummary> summaries = orders.GroupBy(
    o => o.Status,
    (status, groupedOrders) => new OrderSummary(
        status,
        groupedOrders.Count(),
        groupedOrders.Sum(o => o.Total)));

foreach (OrderSummary summary in summaries)
{
    Console.WriteLine($"{summary.Status}: {summary.Count} orders, ${summary.TotalRevenue:F2}");
}

This is clean and avoids the nested foreach. When you already know exactly what shape you need, use this overload instead of iterating IGrouping objects directly.

ToLookup: Eager Grouping with Random Access

ToLookup executes immediately and builds an internal dictionary-like structure. Use it when you need to look up groups by key multiple times after building the grouping:

namespace DevLeader.LinqGrouping;

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

IEnumerable<Product> products = GetProducts();

// ToLookup executes NOW and stores all groups
ILookup<string, Product> byCategory = products.ToLookup(p => p.Category);

// Random access -- no re-enumeration of products
IEnumerable<Product> electronics = byCategory["Electronics"];
IEnumerable<Product> clothing    = byCategory["Clothing"];

// Accessing a missing key returns an empty sequence, not an exception
IEnumerable<Product> missing = byCategory["DoesNotExist"]; // safe, empty

GroupBy vs ToLookup -- When to Choose Which

Concern GroupBy ToLookup
Execution Deferred Immediate
Re-enumeration cost Iterates source again each time Built once, accessed many times
Random key access Not supported directly lookup[key] -- O(1)
Missing key KeyNotFoundException (if converted to dict) Empty sequence
Memory Low until enumerated Holds all data in memory

Use ToLookup when you build the grouping once and then query it repeatedly. Use GroupBy when you only need to iterate the groups once and want deferred execution.

Nested Grouping

Real-world data often has multi-level hierarchies. You can compose GroupBy calls to achieve this:

namespace DevLeader.LinqGrouping;

public record Employee(string Department, string Team, string Name, decimal Salary);

IEnumerable<Employee> employees = GetEmployees();

// Group by department, then by team within each department
var nested = employees
    .GroupBy(e => e.Department)
    .Select(dept => new
    {
        Department = dept.Key,
        Teams = dept.GroupBy(e => e.Team)
                    .Select(team => new
                    {
                        Team = team.Key,
                        Members = team.ToList(),
                        AverageSalary = team.Average(e => e.Salary)
                    })
                    .ToList()
    });

foreach (var dept in nested)
{
    Console.WriteLine($"Department: {dept.Department}");
    foreach (var team in dept.Teams)
    {
        Console.WriteLine($"  Team: {team.Team}  Avg Salary: ${team.AverageSalary:F0}");
    }
}

Keep nested groupings shallow -- more than two levels usually signals that a different data model would serve you better.

.NET 9: CountBy -- Replacing the Verbose GroupBy Pattern

Before .NET 9, counting elements per key required GroupBy followed by a Select to project the count:

// Before .NET 9 -- verbose
Dictionary<string, int> countsBefore = orders
    .GroupBy(o => o.Status)
    .ToDictionary(g => g.Key, g => g.Count());

foreach (KeyValuePair<string, int> kvp in countsBefore)
{
    Console.WriteLine($"{kvp.Key}: {kvp.Value}");
}

.NET 9 introduces CountBy, which does exactly this in a single method call:

// .NET 9 -- concise
IEnumerable<KeyValuePair<string, int>> counts = orders.CountBy(o => o.Status);

foreach (KeyValuePair<string, int> kvp in counts)
{
    Console.WriteLine($"{kvp.Key}: {kvp.Value}");
}

CountBy returns IEnumerable<KeyValuePair<TKey, int>>, which is lazily evaluated. In LINQ to Objects, it preserves first-seen order -- the first key encountered appears first. This matters if you're building a sorted leaderboard or frequency table where order is meaningful.

Real-world example -- product inventory by category:

namespace DevLeader.LinqGrouping;

IEnumerable<Product> catalog = GetProductCatalog();

// How many products per category?
foreach (KeyValuePair<string, int> entry in catalog.CountBy(p => p.Category))
{
    Console.WriteLine($"{entry.Key}: {entry.Value} products");
}

.NET 9: AggregateBy -- Per-Key Accumulation

CountBy covers the "how many?" case. But what about "what's the total per key?" or "build a list per key without loading everything into memory at once?" That's where AggregateBy comes in.

Before .NET 9:

// Before .NET 9 -- revenue per status
Dictionary<string, decimal> revenueBefore = orders
    .GroupBy(o => o.Status)
    .ToDictionary(g => g.Key, g => g.Sum(o => o.Total));

With AggregateBy in .NET 9:

namespace DevLeader.LinqGrouping;

// .NET 9 -- AggregateBy(keySelector, seedSelector, accumulator)
IEnumerable<KeyValuePair<string, decimal>> revenueByStatus =
    orders.AggregateBy(
        keySelector: o => o.Status,
        seedSelector: _ => 0m,
        func: (sum, order) => sum + order.Total);

foreach (KeyValuePair<string, decimal> kvp in revenueByStatus)
{
    Console.WriteLine($"{kvp.Key}: ${kvp.Value:F2}");
}

The seedSelector receives the key and returns the initial accumulator value for that group. This is more flexible than a fixed seed because different keys can start with different initial states.

More complex example -- building per-category product name lists:

namespace DevLeader.LinqGrouping;

// Collect product names per category as a comma-separated string
IEnumerable<KeyValuePair<string, string>> namesByCategory =
    GetProductCatalog().AggregateBy(
        keySelector:  p => p.Category,
        seedSelector: _ => string.Empty,
        func: (names, product) =>
            string.IsNullOrEmpty(names) ? product.Name : $"{names}, {product.Name}");

foreach (KeyValuePair<string, string> entry in namesByCategory)
{
    Console.WriteLine($"{entry.Key}: {entry.Value}");
}

Note that AggregateBy is deferred, just like GroupBy and CountBy. Call .ToDictionary(...) on the result if you need O(1) key access afterward.

Real-World Scenario: Order Dashboard

Here's a complete example showing the evolution from pre-.NET 9 to modern code for an order dashboard summary:

namespace DevLeader.LinqGrouping;

public record Order(int Id, string Status, decimal Total, DateTimeOffset PlacedAt);

public sealed class OrderDashboard
{
    public static void PrintSummary(IEnumerable<Order> orders)
    {
        // .NET 9: CountBy for per-status order counts
        Console.WriteLine("--- Order Counts by Status ---");
        foreach (KeyValuePair<string, int> entry in orders.CountBy(o => o.Status))
        {
            Console.WriteLine($"  {entry.Key,12}: {entry.Value,5} orders");
        }

        // .NET 9: AggregateBy for per-status revenue
        Console.WriteLine("
--- Revenue by Status ---");
        foreach (KeyValuePair<string, decimal> entry in
            orders.AggregateBy(o => o.Status, _ => 0m, (acc, o) => acc + o.Total))
        {
            Console.WriteLine($"  {entry.Key,12}: ${entry.Value,10:F2}");
        }

        // ToLookup for repeated key lookups (e.g., UI rendering each section)
        ILookup<string, Order> byStatus = orders.ToLookup(o => o.Status);
        Console.WriteLine("
--- Latest Order Per Status ---");
        foreach (IGrouping<string, Order> group in byStatus)
        {
            Order? latest = group.MaxBy(o => o.PlacedAt);
            Console.WriteLine($"  {group.Key}: Order #{latest?.Id} at {latest?.PlacedAt:g}");
        }
    }
}

This is the kind of code that makes a pull request reviewer happy -- each operator does exactly one thing, the intent is obvious, and there are no intermediate collections unless they're needed.

Performance Considerations

A few patterns to keep in mind:

  • Don't call GroupBy and then .Count() on the outer sequence twice -- you'll enumerate the source twice. Materialize with .ToList() first if you need multiple passes.
  • ToLookup allocates all groups upfront. For very large sequences, decide whether the random-access benefit is worth the memory cost.
  • .NET 9's CountBy and AggregateBy stream the source -- they don't buffer the full sequence. They do maintain an internal dictionary keyed by the grouping key, so memory is proportional to distinct key count, not total element count.
  • For plugin-driven architectures where the grouping logic varies at runtime, consider wrapping GroupBy calls behind strategy interfaces rather than spreading key selector lambdas across the codebase.

FAQ

What is the difference between GroupBy and ToLookup in C#?

GroupBy uses deferred execution -- the source is not enumerated until you iterate the result. ToLookup executes immediately and stores all groups in memory. Use ToLookup when you need to access groups by key multiple times. Use GroupBy when you only iterate once and want lazy evaluation.

How does CountBy work in .NET 9?

CountBy(keySelector) returns IEnumerable<KeyValuePair<TKey, int>>, where each pair contains a distinct key and the number of elements with that key. It replaces the common pattern GroupBy(k).ToDictionary(g => g.Key, g => g.Count()) with a single, readable call.

What is AggregateBy and when should I use it?

AggregateBy(keySelector, seedSelector, accumulator) performs a per-key fold (like Aggregate) across the sequence. Use it when you want to reduce elements per group into a single value -- such as summing totals, concatenating strings, or building per-key collections -- without the overhead of materializing full IGrouping objects.

Can I group by multiple keys in LINQ?

Yes. The simplest approach is to project multiple properties into an anonymous type or record: GroupBy(x => new { x.Category, x.Region }). Anonymous types implement structural equality, so two elements will share a group as long as all key fields match.

Is LINQ GroupBy equivalent to SQL GROUP BY?

They are conceptually similar but differ in execution. SQL GROUP BY requires aggregate functions for non-key columns. LINQ GroupBy gives you the full IGrouping<TKey, TElement> to work with, so you can apply any aggregation after the fact. LINQ also does not require a HAVING clause -- you just chain .Where() after the grouping.

Why does GroupBy enumerate the source multiple times?

It doesn't always -- GroupBy itself enumerates the source once when you first iterate the result. However, if you enumerate the IEnumerable<IGrouping<...>> result multiple times, the source is iterated again each time because GroupBy is deferred. Call .ToList() on the grouped result to prevent repeated enumeration.

When should I use the result selector overload of GroupBy?

Use GroupBy(keySelector, resultSelector) when you know the final projected shape ahead of time and want to avoid holding IGrouping objects in memory. The result selector runs per group and immediately projects each group to your target type, so you get a clean IEnumerable<TResult> back rather than IEnumerable<IGrouping<...>>.


Summary

LINQ grouping in C# has evolved substantially across .NET versions:

  • GroupBy is the deferred, flexible foundation -- use the element and result selector overloads to keep pipelines clean.
  • ToLookup gives you immediate, random-access grouping when you need to query groups by key multiple times.
  • Nested grouping handles hierarchical data, but keep it to two levels to preserve readability.
  • .NET 9's CountBy eliminates the GroupBy().ToDictionary() boilerplate for frequency counts.
  • .NET 9's AggregateBy handles any per-key accumulation cleanly and without intermediate collection allocation.

If you're thinking about how grouping fits into a larger system design, see how feature slicing organizes code by feature -- the same "partition by concern" thinking applies at the architecture level too. And if you're using CQRS with feature slices, query handlers are a natural home for these grouping pipelines.

For more on .NET patterns that complement LINQ, the observer pattern pairs well with streamed grouping results in reactive pipelines, and the factory method pattern can help you inject configurable key selectors when the grouping logic needs to vary per context.

LINQ Ordering in C#: OrderBy, ThenBy, Order, and Custom Comparers

Master LINQ ordering in C# with OrderBy, ThenBy, and the .NET 7 Order() method. Covers custom IComparer, stable sort, StringComparer, and real-world examples.

LINQ in C#: Complete Guide to Language Integrated Query (.NET 6-9)

Master LINQ in C# with this complete guide covering filtering, projection, ordering, grouping, joins, and every new operator added in .NET 6 through .NET 10.

LINQ Filtering in C#: Where, Any, All, Contains, and OfType

Learn LINQ filtering in C# with Where, Any, All, Contains, and OfType. Covers compound predicates, null handling, and performance tips with .NET 6-9 examples.

An error has occurred. This application may no longer respond until reloaded. Reload