BrandGhost
New LINQ Methods in .NET 6-10: Chunk, MinBy, CountBy, Index, LeftJoin, and More

New LINQ Methods in .NET 6-10: Chunk, MinBy, CountBy, Index, LeftJoin, and More

New LINQ Methods in .NET 6-10: Chunk, MinBy, CountBy, Index, LeftJoin, and More

LINQ hasn't stood still since the .NET Framework days. Every version from .NET 6 through .NET 10 has added methods that eliminate boilerplate, improve performance, and close gaps that previously required third-party libraries or verbose workarounds. New LINQ methods in .NET cover everything from batch processing (Chunk) and key-projected set operations (DistinctBy, ExceptBy) in .NET 6, through ordering shortcuts in .NET 7, to genuinely transformative aggregation methods (CountBy, AggregateBy) in .NET 9, and the first-class outer join operators (LeftJoin, RightJoin) in .NET 10. This article covers every addition in depth with before/after code comparisons so you can start applying them immediately.

Quick Reference -- All New Methods by Version

The table below lists every LINQ method added from .NET 6 through .NET 10 at a glance. Use it as a quick lookup when you know the method name but want to confirm which SDK version you need to target.

Method .NET Version Description
Chunk(size) 6 Split sequence into T[] arrays of at most size elements
DistinctBy(keySelector) 6 Distinct by projected key
MinBy(keySelector) 6 Returns the element with the minimum key
MaxBy(keySelector) 6 Returns the element with the maximum key
ExceptBy(other, keySelector) 6 Set difference by key
IntersectBy(other, keySelector) 6 Set intersection by key
UnionBy(other, keySelector) 6 Set union by key
TryGetNonEnumeratedCount(out count) 6 Count without enumeration when possible
Order() 7 Sort comparable elements without a key selector
OrderDescending() 7 Sort comparable elements descending
CountBy(keySelector) 9 Count per key as IEnumerable<KeyValuePair<TKey, int>>
AggregateBy(keySelector, seedSelector, accumulator) 9 Aggregate per key in a single pass
Index() 9 Enumerate with (index, element) tuples
LeftJoin(inner, outerKey, innerKey, selector) 10 Left outer join
RightJoin(inner, outerKey, innerKey, selector) 10 Right outer join

.NET 6 Additions

.NET 6 was the most significant LINQ release in years. It added eight new methods that eliminate entire categories of boilerplate -- batch processing, key-projected deduplication, element retrieval by key, and set operations with custom selectors. Here's what changed and how to use each addition.

Chunk(size) -- Batch Processing Without Boilerplate

Chunk(size) splits a sequence into T[] arrays of at most size elements. The final chunk may be shorter if the source length isn't evenly divisible.

// Before .NET 6 -- manual batch generator
namespace Email.Legacy;

public static class Batcher
{
    public static IEnumerable<List<T>> InBatches<T>(IEnumerable<T> source, int size)
    {
        var batch = new List<T>(size);
        foreach (var item in source)
        {
            batch.Add(item);
            if (batch.Count == size)
            {
                yield return batch;
                batch = new List<T>(size);
            }
        }

        if (batch.Count > 0)
        {
            yield return batch;
        }
    }
}
// .NET 6 -- Chunk()
namespace Email;

public static class Batcher
{
    public static async Task SendWelcomeEmailsAsync(
        IEnumerable<Subscriber> subscribers,
        IEmailService email,
        CancellationToken ct)
    {
        // Each batch is a Subscriber[] -- safe to pass to array-expecting APIs
        foreach (var batch in subscribers.Chunk(100))
        {
            await email.SendBulkAsync(batch, ct);
        }
    }
}

Chunk returns IEnumerable<T[]>. Each array is materialized as it is yielded, so the source is enumerated exactly once regardless of how many batches you process. This is especially useful in plugin architectures where batch processors are registered as independent handlers.


DistinctBy(keySelector) -- Distinct on a Projection

Returns distinct elements based on a projected key, keeping the first element seen for each unique key.

// Before .NET 6 -- manual hash-based dedup
namespace Catalog.Legacy;

public static class ProductDedup
{
    public static IEnumerable<Product> OnePerCategory(IEnumerable<Product> products)
    {
        var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
        foreach (var p in products)
        {
            if (seen.Add(p.Category))
            {
                yield return p;
            }
        }
    }
}
// .NET 6 -- DistinctBy()
namespace Catalog;

public static class ProductDedup
{
    public static IEnumerable<Product> OnePerCategory(IEnumerable<Product> products)
        => products.DistinctBy(p => p.Category);
}

DistinctBy is deferred and backed by an internal HashSet<TKey>. For case-insensitive key comparisons, you can pass an IEqualityComparer<TKey> as the optional third parameter.


MinBy(keySelector) and MaxBy(keySelector) -- The Element, Not the Value

Min() and Max() return the minimum or maximum value (a scalar). MinBy and MaxBy return the element whose projected key is minimum or maximum -- which is almost always what you actually need.

// Before .NET 6 -- aggregate loop to get the element
namespace Inventory.Legacy;

public static class PriceCheck
{
    public static Product? GetCheapest(IEnumerable<Product> products)
    {
        Product? cheapest = null;
        foreach (var p in products)
        {
            if (cheapest is null || p.Price < cheapest.Price)
            {
                cheapest = p;
            }
        }
        return cheapest;
    }
}
// .NET 6 -- MinBy() / MaxBy()
namespace Inventory;

public static class PriceCheck
{
    public static Product? GetCheapest(IEnumerable<Product> products)
        => products.MinBy(p => p.Price);

    public static Product? GetMostExpensive(IEnumerable<Product> products)
        => products.MaxBy(p => p.Price);

    // Works with any comparable key, including DateTimeOffset
    public static Order? GetEarliestOrder(IEnumerable<Order> orders)
        => orders.MinBy(o => o.PlacedAt);
}

Both return null on an empty sequence rather than throwing, matching the OrDefault semantics of other LINQ operators.


ExceptBy, IntersectBy, and UnionBy -- Key-Projected Set Operations

These are the key-projected variants of Except, Intersect, and Union. Instead of requiring Equals/GetHashCode overrides or a custom IEqualityComparer<T>, you provide a key selector.

// Before .NET 6 -- required a full IEqualityComparer<T> implementation
namespace Catalog.Legacy;

public sealed class ProductBySkuComparer : IEqualityComparer<Product>
{
    public bool Equals(Product? x, Product? y) => x?.Sku == y?.Sku;
    public int GetHashCode(Product obj) => obj.Sku.GetHashCode();
}

// Usage:
// allProducts.Except(discontinuedProducts, new ProductBySkuComparer())
// .NET 6 -- ExceptBy, IntersectBy, UnionBy
namespace Catalog;

public static class ProductSetOps
{
    // Products NOT in the discontinued list -- matched by SKU
    public static IEnumerable<Product> ActiveOnly(
        IEnumerable<Product> allProducts,
        IEnumerable<Product> discontinuedProducts)
    {
        return allProducts.ExceptBy(
            discontinuedProducts.Select(p => p.Sku),
            p => p.Sku);
    }

    // Products that appear in both catalogs -- matched by SKU
    public static IEnumerable<Product> SharedWithPartner(
        IEnumerable<Product> ours,
        IEnumerable<Product> partnerCatalog)
    {
        return ours.IntersectBy(
            partnerCatalog.Select(p => p.Sku),
            p => p.Sku);
    }

    // Merge two catalogs, one entry per SKU (first seen wins)
    public static IEnumerable<Product> Merge(
        IEnumerable<Product> primary,
        IEnumerable<Product> secondary)
    {
        return primary.UnionBy(secondary, p => p.Sku);
    }
}

Key signature difference: ExceptBy and IntersectBy take IEnumerable<TKey> as the second argument (not IEnumerable<T>), while UnionBy takes IEnumerable<T>. All three are deferred and accept an optional IEqualityComparer<TKey>.


TryGetNonEnumeratedCount() -- Count Without Iterating

Returns true and the count via an out parameter when the underlying collection supports O(1) count access. Returns false without touching the sequence when it does not.

namespace Catalog.Optimization;

public static class ListAllocator
{
    public static List<T> SmartToList<T>(IEnumerable<T> source)
    {
        // Pre-allocate exact capacity when possible to eliminate List<T> resizing
        var list = source.TryGetNonEnumeratedCount(out int count)
            ? new List<T>(count)
            : [];

        list.AddRange(source);
        return list;
    }
}

This is most valuable in paged query handlers where you need both a page of results and the total count. TryGetNonEnumeratedCount on a List<T> result avoids a second full scan.


.NET 7 Addition

.NET 7 added two focused ordering methods that remove the redundant identity selector pattern. They're small but meaningful for readability in any code that sorts primitive or naturally-comparable types.

Order() and OrderDescending() -- Sort Without a Key Selector

Before .NET 7, sorting a sequence of IComparable<T> values required .OrderBy(x => x) -- the identity selector was redundant noise.

// Before .NET 7
var sortedPrices = prices.OrderBy(p => p).ToList();
var sortedTagsDesc = tags.OrderByDescending(t => t).ToList();
// .NET 7 -- Order() / OrderDescending()
namespace Catalog;

public static class Sorting
{
    public static IEnumerable<decimal> SortedPrices(IEnumerable<decimal> prices)
        => prices.Order();

    public static IEnumerable<string> SortedTagsDescending(IEnumerable<string> tags)
        => tags.OrderDescending();

    // Composable with ThenBy for multi-key sorts on objects
    public static IEnumerable<int> TopNIds(IEnumerable<int> ids, int n)
        => ids.Order().Take(n);
}

Order() and OrderDescending() work on any T : IComparable<T> -- primitive types, string, DateTimeOffset, Guid, custom structs that implement IComparable<T>, etc. They're fully composable with subsequent .ThenBy calls.


.NET 9 Additions

.NET 9 delivered the biggest batch of LINQ additions since .NET 6 -- three new methods that close long-standing gaps. CountBy and AggregateBy make per-key aggregation dramatically leaner. Index replaces the awkward indexed-Select pattern. All three live in System.Linq and require <TargetFramework>net9.0</TargetFramework>.

CountBy(keySelector) -- Count Per Key in One Pass

CountBy is the dedicated shorthand for the ubiquitous "how many of each?" pattern. It returns IEnumerable<KeyValuePair<TKey, int>>.

// Before .NET 9 -- GroupBy then project
namespace Orders.Legacy;

public static class OrderStats
{
    public static Dictionary<string, int> CountByStatus(IEnumerable<Order> orders)
    {
        return orders
            .GroupBy(o => o.Status)
            .ToDictionary(g => g.Key, g => g.Count());
    }
}
// .NET 9 -- CountBy()
namespace Orders;

public static class OrderStats
{
    public static IEnumerable<KeyValuePair<string, int>> CountByStatus(IEnumerable<Order> orders)
        => orders.CountBy(o => o.Status);
}

// Usage -- deconstruct KeyValuePair naturally:
// foreach (var (status, count) in orders.CountBy(o => o.Status))
//     Console.WriteLine($"{status}: {count}");

CountBy is a single-pass implementation that avoids building intermediate IGrouping<TKey, T> objects, which makes it more memory-efficient than the GroupBy approach on large sequences. It pairs naturally with C# enum status fields -- counting orders per OrderStatus value is a clean one-liner, and combining it with C# enum switch exhaustive checks ensures every status is accounted for in downstream processing.


AggregateBy(keySelector, seedSelector, accumulator) -- Single-Pass Grouped Aggregation

AggregateBy generalizes GroupBy + Aggregate into a single pass. You specify a key selector, a seed factory, and an accumulator. The result is IEnumerable<KeyValuePair<TKey, TAccumulate>>.

// Before .NET 9 -- GroupBy + ToDictionary
namespace Sales.Legacy;

public static class SalesReport
{
    public static Dictionary<string, decimal> TotalByRegion(IEnumerable<Sale> sales)
    {
        return sales
            .GroupBy(s => s.Region)
            .ToDictionary(g => g.Key, g => g.Sum(s => s.Amount));
    }

    // More complex -- build a running summary per salesperson
    public static Dictionary<string, SalesSummary> SummaryBySalesperson(IEnumerable<Sale> sales)
    {
        return sales
            .GroupBy(s => s.SalespersonId)
            .ToDictionary(
                g => g.Key,
                g => new SalesSummary(
                    TotalAmount: g.Sum(s => s.Amount),
                    OrderCount: g.Count(),
                    LargestSale: g.Max(s => s.Amount)));
    }
}
// .NET 9 -- AggregateBy()
namespace Sales;

public record SalesSummary(decimal TotalAmount, int OrderCount, decimal LargestSale);

public static class SalesReport
{
    public static IEnumerable<KeyValuePair<string, decimal>> TotalByRegion(
        IEnumerable<Sale> sales)
    {
        return sales.AggregateBy(
            keySelector: s => s.Region,
            seedSelector: _ => 0m,                          // seed doesn't depend on key here
            accumulator: (total, sale) => total + sale.Amount);
    }

    // The seed factory receives the key -- useful when initial state depends on the key
    public static IEnumerable<KeyValuePair<string, SalesSummary>> SummaryBySalesperson(
        IEnumerable<Sale> sales)
    {
        return sales.AggregateBy(
            keySelector: s => s.SalespersonId,
            seedSelector: id => new SalesSummary(0m, 0, 0m),   // seed could use 'id' here
            accumulator: (summary, sale) => summary with
            {
                TotalAmount = summary.TotalAmount + sale.Amount,
                OrderCount = summary.OrderCount + 1,
                LargestSale = Math.Max(summary.LargestSale, sale.Amount)
            });
    }
}

The critical difference from GroupBy: the seed is a factory (Func<TKey, TAccumulate>), not a fixed value. This lets the initial accumulator depend on the key itself -- something GroupBy doesn't support directly.


Index() -- Enumerate with Position

Index() wraps each element with its zero-based position, returning IEnumerable<(int Index, T Item)>. This replaces the less-obvious Select((item, i) => ...) overload with an explicit, composable operator.

// Before .NET 9 -- overloaded Select with index parameter
namespace Catalog.Legacy;

public static class Ranked
{
    public static IEnumerable<string> RankProducts(IEnumerable<Product> products)
    {
        return products
            .OrderByDescending(p => p.SalesCount)
            .Select((p, i) => $"#{i + 1} {p.Name}");
    }
}
// .NET 9 -- Index()
namespace Catalog;

public static class Ranked
{
    public static IEnumerable<string> RankProducts(IEnumerable<Product> products)
    {
        return products
            .OrderByDescending(p => p.SalesCount)
            .Index()
            .Select(entry => $"#{entry.Index + 1} {entry.Item.Name}");
    }

    // Index() is fully composable -- filter by position after attaching index
    public static IEnumerable<Product> GetPodiumProducts(IEnumerable<Product> products)
    {
        return products
            .OrderByDescending(p => p.SalesCount)
            .Index()
            .Where(entry => entry.Index < 3)   // top 3 only
            .Select(entry => entry.Item);
    }
}

Index() is especially clean inside CQRS query handlers that generate ranked or positional results without needing a loop counter or an external variable. The (Index, Item) tuple destructures cleanly in foreach loops too: foreach (var (i, product) in products.Index()).


Setting Your Target Framework

All of these APIs are in System.Linq with no extra NuGet package required -- they're BCL additions shipped with each SDK. Set <TargetFramework> in your .csproj to unlock each group:

<!-- .NET 9 -- CountBy, AggregateBy, Index -->
<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <TargetFramework>net9.0</TargetFramework>
    <Nullable>enable</Nullable>
    <ImplicitUsings>enable</ImplicitUsings>
  </PropertyGroup>
</Project>
<!-- .NET 10 -- LeftJoin, RightJoin -->
<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <TargetFramework>net10.0</TargetFramework>
  </PropertyGroup>
</Project>
Method group Minimum TargetFramework
Chunk, DistinctBy, MinBy, MaxBy, ExceptBy, IntersectBy, UnionBy, TryGetNonEnumeratedCount net6.0
Order, OrderDescending net7.0
CountBy, AggregateBy, Index net9.0
LeftJoin, RightJoin net10.0

For library projects that must multi-target, use #if NET6_0_OR_GREATER / #if NET9_0_OR_GREATER preprocessor conditionals with polyfilled fallback implementations for older targets.

.NET 10 Additions

.NET 10 added two first-class outer join operators to LINQ, closing a long-standing gap that had forced developers to write verbose GroupJoin/SelectMany/DefaultIfEmpty workarounds. Both live in System.Linq and require <TargetFramework>net10.0</TargetFramework>.

LeftJoin

LeftJoin performs a left outer join -- every outer element appears in the result, with the inner element being null when no match exists:

// .NET 10 -- replaces GroupJoin + SelectMany + DefaultIfEmpty
var result = customers.LeftJoin(
    orders,
    c => c.Id,
    o => o.CustomerId,
    (c, o) => new { c.Name, OrderId = o?.Id });

RightJoin

RightJoin keeps every inner (right-side) element, with null for unmatched outer elements -- the mirror of LeftJoin:

// .NET 10 -- all orders included, customer is null if unassigned
var result = customers.RightJoin(
    orders,
    c => c.Id,
    o => o.CustomerId,
    (c, o) => new { CustomerName = c?.Name ?? "(unassigned)", o.Id });

Both operators target IEnumerable<T> (LINQ to Objects). EF Core 10 adds IQueryable<T> translation support, making them available in database queries without the GroupJoin workaround.

Frequently Asked Questions

Here are the most common questions about the new LINQ methods introduced in .NET 6 through .NET 10.

What .NET version introduced Chunk()?

Chunk was introduced in .NET 6. It requires <TargetFramework>net6.0</TargetFramework> or later. It is not available in .NET Framework, .NET Standard, or any runtime older than .NET 6.

What is the difference between MinBy() and Min() in LINQ?

Min() returns the minimum value (e.g., the smallest decimal price in the collection). MinBy(keySelector) returns the element whose projected key is smallest (e.g., the Product object with the lowest price). When you need the object rather than just the scalar key, MinBy is always the right choice.

Is CountBy() more efficient than GroupBy().ToDictionary() in .NET 9?

Yes. CountBy(key) is functionally equivalent to GroupBy(key).Select(g => KV(g.Key, g.Count())), but it avoids allocating intermediate IGrouping<TKey, T> objects for each key. On large sequences with many groups, this produces noticeably less GC pressure.

Does Index() in .NET 9 replace Select((item, i) ⇒ ...)?

Largely yes. Index() produces (int Index, T Item) tuples via a dedicated, composable operator. The Select overload that takes an (element, index) function still works, but Index() makes the intent explicit and is easier to reason about when combined with further LINQ operators. Both approaches produce the same results.

Are LeftJoin and RightJoin supported by EF Core for SQL generation?

As of the initial .NET 10 release, LeftJoin and RightJoin are LINQ to Objects methods and do not have IQueryable<T> translation support in EF Core. For database-backed outer joins you must still use the GroupJoin + SelectMany + DefaultIfEmpty pattern. Watch the EF Core GitHub milestone pages for translation support in a future release.

Can I use Order() instead of OrderBy() for complex domain objects?

No. Order() only works when T itself is directly comparable (IComparable<T>). For complex types like Product, you still need OrderBy(p => p.Name) or OrderBy(p => p.Price) with an explicit key selector. Use Order() for primitive sequences such as IEnumerable<int>, IEnumerable<string>, IEnumerable<DateTimeOffset>, or custom value types that implement IComparable<T>.

When should I use AggregateBy() instead of GroupBy() + Aggregate()?

Use AggregateBy when: (1) you want a single-pass operation for memory efficiency on large sequences, (2) your seed value depends on the key (the seed factory receives the key as a parameter, which GroupBy doesn't support), or (3) you're computing a complex multi-field accumulation. For simple totals and counts, CountBy and AggregateBy are cleaner. For cases where GroupBy is already readable and the sequence is small, there's no strong reason to change.

Weekly Recap: LINQ in C#, Design Patterns, and AI in Software Engineering [May 2026]

This week takes a deep tour of LINQ in C# covering grouping, aggregation, set operations, joins, deferred execution, and element access, plus complete guides to the proxy, flyweight, and bridge design patterns. The video lineup tackles context switching, generational dynamics on engineering teams, and where AI tooling actually fits in a real developer workflow.

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

Master LINQ grouping in C# with GroupBy, ToLookup, and the powerful .NET 9 CountBy and AggregateBy methods for cleaner data aggregation.

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.

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