.NET 9 and .NET 10 LINQ Enhancements
This post walks through all five with a working console application. The demo uses an in-memory data set of 50 customers, 50 products and 50 orders, so every number printed below can be traced back to the data. Everything here targets net10.0 and runs on the .NET 10 SDK.
In this post
What was added
| Version | Method | Replaces |
|---|---|---|
| .NET 9 | CountBy() | GroupBy().Select(g => g.Count()) |
| .NET 9 | AggregateBy() | GroupBy().Select(g => g.Sum(...)) and friends |
| .NET 9 | Index() | Select((x, i) => ...) and manual counters |
| .NET 10 | LeftJoin() | GroupJoin() + SelectMany() + DefaultIfEmpty() |
| .NET 10 | RightJoin() | The same chain with the sequences swapped |
There is a theme running through the first three. GroupBy() has to materialise every element of every group into a list before you can count or sum it, because an IGrouping is itself a sequence you are expected to walk. CountBy() and AggregateBy() never hold the elements at all. They keep one accumulator per key and fold each element into it as it streams past. For a large sequence with few distinct keys, that is a very different memory profile.
The theme in the .NET 10 additions is different: it is about saying what you mean. A left join is one idea, and it used to take three chained operators to express.
The demo project
A plain console application, no NuGet packages:
dotnet new console -n CS_LINQ_9_10 -f net10.0
cd CS_LINQ_9_10
dotnet run
The project file, with nullable reference types enabled, which matters a lot for the join demos:
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
</Project>
The finished layout:
CS_LINQ_9_10/
|
+-- CS_LINQ_9_10.csproj
+-- Program.cs
|
+-- Models/
| +-- Customer.cs
| +-- Product.cs
| +-- Order.cs
|
+-- Data/
| +-- SeedData.cs
|
+-- Demos/
+-- ConsoleHelper.cs
+-- Net9LinqDemos.cs
+-- Net10LinqDemos.cs
The model classes
Three classes, deliberately boring. Customer is the outer sequence in the join demos, Order is the inner one, and Product gives the grouping demos a second data set to work on.
Models/Customer.cs
namespace CS_LINQ_9_10.Models;
/// <summary>
/// A customer that places orders.
/// Used as the OUTER sequence in the LeftJoin() / RightJoin() demos.
/// </summary>
public class Customer
{
public int Id { get; init; }
public string Name { get; init; } = string.Empty;
/// <summary>Grouping key for the CountBy() demo.</summary>
public string City { get; init; } = string.Empty;
public string Country { get; init; } = string.Empty;
/// <summary>Retail / Corporate / Government. Second grouping key for CountBy().</summary>
public string Segment { get; init; } = string.Empty;
public DateOnly JoinedOn { get; init; }
public override string ToString() => $"#{Id} {Name} ({City}, {Segment})";
}
Models/Product.cs
namespace CS_LINQ_9_10.Models;
/// <summary>
/// A product that can appear on an order line.
/// </summary>
public class Product
{
public int Id { get; init; }
public string Name { get; init; } = string.Empty;
/// <summary>Grouping key for CountBy() and AggregateBy() demos.</summary>
public string Category { get; init; } = string.Empty;
public decimal UnitPrice { get; init; }
public bool Discontinued { get; init; }
public override string ToString() => $"#{Id} {Name} ({Category}) {UnitPrice:C}";
}
Models/Order.cs
namespace CS_LINQ_9_10.Models;
/// <summary>
/// An order placed by a customer for a product.
/// Used as the INNER sequence in the LeftJoin() / RightJoin() demos.
/// </summary>
/// <remarks>
/// The seed data deliberately contains a few orders whose <see cref="CustomerId"/>
/// does not exist in the customer list. Those "orphan" rows are what makes the
/// RightJoin() demo produce a visible result.
/// </remarks>
public class Order
{
public int Id { get; init; }
/// <summary>Foreign key to <see cref="Customer.Id"/>. May not match any customer.</summary>
public int CustomerId { get; init; }
/// <summary>Foreign key to <see cref="Product.Id"/>.</summary>
public int ProductId { get; init; }
public int Quantity { get; init; }
/// <summary>Line total. Aggregated by the AggregateBy() demos.</summary>
public decimal Amount { get; init; }
public DateOnly OrderDate { get; init; }
/// <summary>Placed / Shipped / Delivered / Cancelled. Grouping key for CountBy().</summary>
public string Status { get; init; } = string.Empty;
public override string ToString() => $"Order {Id} (Cust {CustomerId}) {Amount:C} [{Status}]";
}
Order.CustomerId is a plain int with no navigation property and no guarantee that it points at a customer who exists. That is on purpose, and the next section explains why.
The seed data
Two decisions in the seed data are worth calling out before you read it.
First, nothing is random. Every value is computed arithmetically from the loop index, so each run prints exactly the same numbers and you can verify any query result by hand. Using Random with a fixed seed would have been shorter, but the sequence it produces is not guaranteed to stay the same across .NET versions.
Second, the data has two deliberate holes, and they are the entire reason the join demos produce anything interesting. Only customers 1 to 30 are given orders, which leaves 20 customers with no orders at all, and that is what LeftJoin() surfaces. Orders 1046 to 1050 point at customer ids 945 to 949, which do not exist, and those orphans are what RightJoin() surfaces. A perfectly clean data set would make both methods look identical to a plain Join().
Data/SeedData.cs
using CS_LINQ_9_10.Models;
namespace CS_LINQ_9_10.Data;
/// <summary>
/// In-memory seed data: 50 Customers, 50 Products, 50 Orders.
/// </summary>
/// <remarks>
/// Everything is generated arithmetically (no <see cref="Random"/>) so every run
/// produces identical output. That matters for a demo: the numbers printed by the
/// LINQ queries can be checked by hand against the data below.
///
/// Two shapes in this data exist purely to make the .NET 10 joins interesting:
/// * Only customers 1-30 have orders, so 20 customers have NO orders -> LeftJoin() shows nulls.
/// * Orders 1046-1050 point at customer ids 945-949 that do not exist -> RightJoin() shows "Unknown".
/// </remarks>
public static class SeedData
{
private static readonly string[] FirstNames =
["Asha", "Rohan", "Priya", "Daniel", "Meera", "Thomas", "Kavya", "Elena", "Arjun", "Sofia"];
private static readonly string[] LastNames =
["Kulkarni", "Fernandes", "Whitfield", "Rao", "Mendez", "Osei", "Lindqvist"];
private static readonly (string City, string Country)[] Locations =
[
("Mumbai", "India"),
("Pune", "India"),
("Chicago", "USA"),
("New York", "USA"),
("London", "UK"),
("Manchester", "UK"),
("Berlin", "Germany"),
("Sydney", "Australia")
];
private static readonly string[] Segments = ["Retail", "Corporate", "Government"];
private static readonly string[] ProductNames =
["Thermal Printer", "Barcode Scanner", "Docking Station", "Mesh Router", "Label Roll",
"USB-C Hub", "Office Chair", "Standing Desk", "Noise Headset", "Webcam Pro"];
private static readonly string[] Categories =
["Peripherals", "Networking", "Consumables", "Furniture", "Audio", "Imaging"];
private static readonly string[] Statuses = ["Placed", "Shipped", "Delivered", "Cancelled"];
/// <summary>50 customers, ids 1..50.</summary>
public static List<Customer> Customers { get; } = BuildCustomers();
/// <summary>50 products, ids 101..150.</summary>
public static List<Product> Products { get; } = BuildProducts();
/// <summary>50 orders, ids 1001..1050.</summary>
public static List<Order> Orders { get; } = BuildOrders();
private static List<Customer> BuildCustomers()
{
var customers = new List<Customer>(50);
for (var i = 0; i < 50; i++)
{
// i % 10 and i % 7 are co-prime enough over 0..49 to give 50 distinct names.
var (city, country) = Locations[i % Locations.Length];
customers.Add(new Customer
{
Id = i + 1,
Name = $"{FirstNames[i % FirstNames.Length]} {LastNames[i % LastNames.Length]}",
City = city,
Country = country,
Segment = Segments[i % Segments.Length],
JoinedOn = new DateOnly(2022, 1, 1).AddDays(i * 11)
});
}
return customers;
}
private static List<Product> BuildProducts()
{
var products = new List<Product>(50);
for (var i = 0; i < 50; i++)
{
var id = 101 + i;
products.Add(new Product
{
Id = id,
Name = $"{ProductNames[i % ProductNames.Length]} M{id}",
Category = Categories[i % Categories.Length],
UnitPrice = 19.99m + (i % 20) * 35.25m,
Discontinued = i % 11 == 0 // roughly 1 in 11 products retired
});
}
return products;
}
private static List<Order> BuildOrders()
{
var orders = new List<Order>(50);
var productsById = Products.ToDictionary(p => p.Id);
for (var i = 0; i < 50; i++)
{
// First 45 orders spread across customers 1..30 (so customers 31..50 stay order-free).
// Last 5 orders point at customer ids 945..949, which do not exist at all.
var customerId = i < 45 ? (i % 30) + 1 : 900 + i;
var productId = 101 + (i * 7) % 50; // step of 7 scatters products across orders
var quantity = (i % 5) + 1;
var unitPrice = productsById[productId].UnitPrice;
orders.Add(new Order
{
Id = 1001 + i,
CustomerId = customerId,
ProductId = productId,
Quantity = quantity,
Amount = unitPrice * quantity,
OrderDate = new DateOnly(2025, 1, 1).AddDays(i * 7),
Status = Statuses[i % Statuses.Length]
});
}
return orders;
}
}
BuildOrders: the product is picked with 101 + (i * 7) % 50. Since 7 and 50 share no common factor, stepping by 7 visits all 50 residues before repeating, so every product is used exactly once across the 50 orders.The .NET 9 operators
CountBy()
Counting how many items fall into each bucket is probably the single most common grouping task, and it used to need two operators and an anonymous type.
// .NET 8 and earlier
var result = customers
.GroupBy(c => c.City)
.Select(g => new
{
City = g.Key,
Count = g.Count()
});
In .NET 9 it is one call:
// .NET 9
var result = customers.CountBy(c => c.City);
foreach (var (city, count) in result)
{
Console.WriteLine($"{city}: {count}");
}
The signature:
public static IEnumerable<KeyValuePair<TKey, int>> CountBy<TSource, TKey>(
this IEnumerable<TSource> source,
Func<TSource, TKey> keySelector,
IEqualityComparer<TKey>? keyComparer = null);
Each result element is a KeyValuePair<TKey, int>, so you can deconstruct it into (key, count) in a foreach, which reads better than item.Key and item.Value. The result is not ordered, so add your own OrderBy if order matters.
The key selector can return anything with sensible equality. A ValueTuple already implements Equals and GetHashCode, so a composite key costs nothing extra:
// ValueTuple already implements Equals/GetHashCode, so no custom comparer is needed.
var byCountryAndSegment = customers.CountBy(c => (c.Country, c.Segment));
If your key needs case-insensitive matching or any other custom equality, that is what the optional keyComparer parameter is for.
AggregateBy()
CountBy() is really just a special case of something more general. AggregateBy() is GroupBy and Aggregate collapsed into a single pass: you give it a starting accumulator and a function that folds one element into it, and you get one accumulator per key.
// .NET 8 and earlier
var totals = orders
.GroupBy(o => o.CustomerId)
.Select(g => new
{
CustomerId = g.Key,
Total = g.Sum(x => x.Amount)
});
// .NET 9
var totals = orders.AggregateBy(
o => o.CustomerId, // keySelector
0m, // seed (starting accumulator)
(total, order) => total + order.Amount); // func (fold)
There are two overloads, and the difference between them is the one thing in this whole post most likely to bite you:
// Overload 1 - a single shared seed VALUE
public static IEnumerable<KeyValuePair<TKey, TAccumulate>>
AggregateBy<TSource, TKey, TAccumulate>(
this IEnumerable<TSource> source,
Func<TSource, TKey> keySelector,
TAccumulate seed,
Func<TAccumulate, TSource, TAccumulate> func,
IEqualityComparer<TKey>? keyComparer = null);
// Overload 2 - a seed FACTORY, called once per key
public static IEnumerable<KeyValuePair<TKey, TAccumulate>>
AggregateBy<TSource, TKey, TAccumulate>(
this IEnumerable<TSource> source,
Func<TSource, TKey> keySelector,
Func<TKey, TAccumulate> seedSelector,
Func<TAccumulate, TSource, TAccumulate> func,
IEqualityComparer<TKey>? keyComparer = null);
The first overload takes a seed value. That value is shared by every group. For a decimal or an int that is completely fine, because the fold returns a new value each time and never mutates the seed. For a reference type it is a bug, and a quiet one:
// WRONG - every category shares ONE list, so all names land in the same bucket
var broken = products.AggregateBy(
p => p.Category,
new List<string>(), // evaluated ONCE
(names, p) => { names.Add(p.Name); return names; });
// RIGHT - seedSelector runs once per key, so each category gets its own list
var correct = products.AggregateBy(
p => p.Category,
_ => new List<string>(), // evaluated PER KEY
(names, p) => { names.Add(p.Name); return names; });
The second overload takes a seed selector, which is invoked once per key. Use it whenever the accumulator is mutable, or whenever the starting value depends on the key.
The accumulator does not have to be a number or a collection either. Making it a tuple lets a single pass compute several statistics at once, where GroupBy would walk each group once per statistic:
// Count, sum and max for every status, in ONE pass over the orders.
var stats = orders.AggregateBy(
o => o.Status,
_ => (Count: 0, Sum: 0m, Max: decimal.MinValue),
(acc, o) => (acc.Count + 1, acc.Sum + o.Amount, Math.Max(acc.Max, o.Amount)));
Index()
Numbering a sequence always had two awkward options: the Select overload that hands you an index but forces you to project right there, or a counter variable declared outside a foreach.
// .NET 8 and earlier - projection is forced on you
customers.Select((c, i) => $"{i}: {c.Name}");
// ...or the hand-rolled counter
var i = 0;
foreach (var c in customers)
{
Console.WriteLine($"{i}: {c.Name}");
i++;
}
// .NET 9
foreach (var (index, customer) in customers.Index())
{
Console.WriteLine($"{index}: {customer.Name}");
}
public static IEnumerable<(int Index, TSource Item)> Index<TSource>(
this IEnumerable<TSource> source);
The tuple elements are named, so you write .Index and .Item rather than relying on a positional lambda parameter called i. More usefully, Index() attaches the position and leaves the element intact, so it composes with the rest of the query instead of terminating it. Ranking the top ten of something and printing the rank becomes a single readable pipeline.
It is lazy, like the rest of LINQ, so it costs nothing over the loop it replaces.
Complete listing: Demos/Net9LinqDemos.cs
All three operators, with the reasoning in the comments:
using CS_LINQ_9_10.Data;
using CS_LINQ_9_10.Models;
using static CS_LINQ_9_10.Demos.ConsoleHelper;
namespace CS_LINQ_9_10.Demos;
/// <summary>
/// LINQ methods added in .NET 9: CountBy(), AggregateBy(), Index().
///
/// The common theme: all three replace a GroupBy()/Select() pipeline that allocated
/// an intermediate IGrouping (and therefore buffered every element of every group)
/// with a single streaming pass that keeps only one accumulator per key.
/// </summary>
public static class Net9LinqDemos
{
public static void Run()
{
Title(".NET 9 LINQ ENHANCEMENTS");
CountByDemo();
CountByOnMultipleKeysDemo();
AggregateBySumDemo();
AggregateByWithSeedSelectorDemo();
AggregateByNonNumericDemo();
IndexDemo();
IndexInsteadOfManualCounterDemo();
}
// ------------------------------------------------------------------ CountBy
/// <summary>
/// CountBy(keySelector) -> IEnumerable<KeyValuePair<TKey, int>>
///
/// BEFORE (.NET 8 and earlier):
/// customers.GroupBy(c => c.City)
/// .Select(g => new { City = g.Key, Count = g.Count() });
///
/// GroupBy has to materialise every Customer into a per-city list just so Count()
/// can walk it. CountBy never holds the elements at all: it keeps one int per city.
/// </summary>
private static void CountByDemo()
{
Section("CountBy() - customers per city");
// Each result item is a KeyValuePair<string, int>: .Key is the city, .Value is the count.
var customersPerCity = SeedData.Customers.CountBy(c => c.City);
foreach (var (city, count) in customersPerCity.OrderByDescending(x => x.Value))
{
Console.WriteLine($" {city,-12} {count,3}");
}
Note($"Total customers: {SeedData.Customers.Count}");
}
/// <summary>
/// The key selector can return anything with sensible equality, so a tuple works
/// and gives you a composite group key for free.
/// </summary>
private static void CountByOnMultipleKeysDemo()
{
Section("CountBy() - composite key (Country + Segment)");
// ValueTuple already implements Equals/GetHashCode, so no custom comparer is needed.
var byCountryAndSegment = SeedData.Customers
.CountBy(c => (c.Country, c.Segment))
.OrderBy(x => x.Key.Country)
.ThenBy(x => x.Key.Segment);
foreach (var (key, count) in byCountryAndSegment)
{
Console.WriteLine($" {key.Country,-10} {key.Segment,-12} {count,3}");
}
Section("CountBy() - orders per status");
foreach (var (status, count) in SeedData.Orders.CountBy(o => o.Status))
{
Console.WriteLine($" {status,-10} {count,3}");
}
}
// -------------------------------------------------------------- AggregateBy
/// <summary>
/// AggregateBy(keySelector, seed, func) -> IEnumerable<KeyValuePair<TKey, TAccumulate>>
///
/// BEFORE:
/// orders.GroupBy(o => o.CustomerId)
/// .Select(g => new { CustomerId = g.Key, Total = g.Sum(x => x.Amount) });
///
/// AggregateBy folds each element straight into the running accumulator for its key.
/// Think of it as "GroupBy + Aggregate" collapsed into one pass with no buffering.
/// </summary>
private static void AggregateBySumDemo()
{
Section("AggregateBy() - order total per customer (top 10)");
var totalsPerCustomer = SeedData.Orders.AggregateBy(
keySelector: o => o.CustomerId, // which bucket this order belongs to
seed: 0m, // starting accumulator value for every bucket
func: (total, order) => total + order.Amount); // fold one order into the bucket
var customerNames = SeedData.Customers.ToDictionary(c => c.Id, c => c.Name);
foreach (var (customerId, total) in totalsPerCustomer.OrderByDescending(x => x.Value).Take(10))
{
// Orphan orders (customer ids 945-949) have no matching name in the dictionary.
var name = customerNames.GetValueOrDefault(customerId, "<unknown customer>");
Console.WriteLine($" {customerId,4} {name,-22} {total,12:C}");
}
}
/// <summary>
/// Overload: AggregateBy(keySelector, seedSelector, func).
///
/// Use this when the starting value depends on the key, or when the accumulator is a
/// mutable object. A plain `seed` value would be SHARED across every group, which is a
/// real bug if the accumulator is a reference type such as a List. The seedSelector
/// runs once per key and hands each group its own fresh accumulator.
/// </summary>
private static void AggregateByWithSeedSelectorDemo()
{
Section("AggregateBy() - seedSelector overload: product names per category");
var productsPerCategory = SeedData.Products.AggregateBy(
keySelector: p => p.Category,
seedSelector: _ => new List<string>(), // a NEW list per category
func: (names, product) => { names.Add(product.Name); return names; });
foreach (var (category, names) in productsPerCategory.OrderBy(x => x.Key))
{
Console.WriteLine($" {category,-13} {names.Count,2} products");
Console.WriteLine($" {string.Join(", ", names.Take(3))}, ...");
}
}
/// <summary>
/// The accumulator does not have to be a number or a collection. Here it is a tuple,
/// which lets a single pass compute several statistics per group at once, something
/// GroupBy would need three separate traversals of each group to do.
/// </summary>
private static void AggregateByNonNumericDemo()
{
Section("AggregateBy() - count, sum and max in ONE pass, per order status");
var stats = SeedData.Orders.AggregateBy(
keySelector: o => o.Status,
seedSelector: _ => (Count: 0, Sum: 0m, Max: decimal.MinValue),
func: (acc, o) => (acc.Count + 1, acc.Sum + o.Amount, Math.Max(acc.Max, o.Amount)));
Console.WriteLine($" {"Status",-11}{"Count",6}{"Sum",14}{"Average",12}{"Largest",12}");
foreach (var (status, acc) in stats.OrderBy(x => x.Key))
{
var average = acc.Sum / acc.Count;
Console.WriteLine($" {status,-11}{acc.Count,6}{acc.Sum,14:C}{average,12:C}{acc.Max,12:C}");
}
}
// ------------------------------------------------------------------- Index
/// <summary>
/// Index() -> IEnumerable<(int Index, TSource Item)>
///
/// BEFORE:
/// customers.Select((c, i) => $"{i}: {c.Name}");
///
/// The Select-with-index overload forces you to project right there. Index() just
/// attaches the position and leaves the element intact, so it composes with foreach
/// and with the rest of the query. The tuple elements are NAMED (.Index / .Item),
/// which reads far better than a positional lambda parameter called `i`.
/// </summary>
private static void IndexDemo()
{
Section("Index() - rank the 10 highest-value orders");
var topOrders = SeedData.Orders
.OrderByDescending(o => o.Amount)
.Take(10)
.Index(); // tuple: (Index, Item)
foreach (var (index, order) in topOrders)
{
Console.WriteLine($" #{index + 1,-3} Order {order.Id} {order.Amount,10:C} {order.Status}");
}
}
/// <summary>
/// Index() also removes the hand-rolled counter variable that creeps into foreach
/// loops, and because it is lazy it costs nothing extra over the loop it replaces.
/// </summary>
private static void IndexInsteadOfManualCounterDemo()
{
Section("Index() - numbered list of the newest 8 customers");
// Before: var i = 0; foreach (var c in ...) { ...; i++; }
foreach (var (position, customer) in SeedData.Customers
.OrderByDescending(c => c.JoinedOn)
.Take(8)
.Index())
{
Console.WriteLine($" {position + 1,2}. {customer.Name,-22} joined {customer.JoinedOn:yyyy-MM-dd} [{customer.City}]");
}
Section("Index() + CountBy() combined - position of each category by product count");
var ranked = SeedData.Products
.CountBy(p => p.Category)
.OrderByDescending(x => x.Value)
.Index();
foreach (var (rank, entry) in ranked)
{
Console.WriteLine($" {rank + 1}. {entry.Key,-13} {entry.Value,2} products");
}
}
}
The .NET 10 operators
Why these were needed
Join() has always been an inner join. Rows with no match on the other side simply disappear from the result. Getting the SQL LEFT JOIN behaviour meant chaining three operators, and the middle one existed only to carry a variable past the first one:
// Before .NET 10 - three operators to say "LEFT JOIN"
var result = customers
.GroupJoin(
orders,
c => c.Id,
o => o.CustomerId,
(c, orders) => new { c, orders }) // carrier type, no other purpose
.SelectMany(
x => x.orders.DefaultIfEmpty(), // injects the null row
(x, order) => new
{
Customer = x.c.Name,
OrderId = order?.Id
});
Nobody reads that and immediately thinks "left join". In .NET 10:
// .NET 10 - one operator, flat lambdas
var result = customers.LeftJoin(
orders,
c => c.Id,
o => o.CustomerId,
(c, order) => new
{
Customer = c.Name,
OrderId = order?.Id // `order` is Order? - may be null
});
Here are both signatures, because the important detail lives in them:
// The OUTER element survives; the INNER one is nullable.
public static IEnumerable<TResult> LeftJoin<TOuter, TInner, TKey, TResult>(
this IEnumerable<TOuter> outer,
IEnumerable<TInner> inner,
Func<TOuter, TKey> outerKeySelector,
Func<TInner, TKey> innerKeySelector,
Func<TOuter, TInner?, TResult> resultSelector,
IEqualityComparer<TKey>? comparer = null);
// The INNER element survives; the OUTER one is nullable.
public static IEnumerable<TResult> RightJoin<TOuter, TInner, TKey, TResult>(
this IEnumerable<TOuter> outer,
IEnumerable<TInner> inner,
Func<TOuter, TKey> outerKeySelector,
Func<TInner, TKey> innerKeySelector,
Func<TOuter?, TInner, TResult> resultSelector,
IEqualityComparer<TKey>? comparer = null);
? sits. In LeftJoin the result selector is Func<TOuter, TInner?, TResult>, so the inner element is the one that may be null. In RightJoin it is Func<TOuter?, TInner, TResult>, so the outer element is. The optional side is always whichever sequence is not guaranteed to survive, and with nullable reference types enabled the compiler will point at it for you.LeftJoin()
Every element of the outer sequence appears in the output at least once. An element with no match appears exactly once, with null on the other side. In the demo data, 45 orders match a customer and 20 customers have no orders, so the result has 65 rows where a plain Join() would give 45.
The classic use is not really "list everything" though. It is finding the rows that have nothing on the other side:
// The LINQ version of: WHERE o.CustomerId IS NULL
var neverOrdered = customers
.LeftJoin(orders, c => c.Id, o => o.CustomerId,
(customer, order) => new { Customer = customer, Order = order })
.Where(x => x.Order is null)
.Select(x => x.Customer);
RightJoin()
RightJoin() is the mirror image. Every element of the inner sequence survives, and the outer element is null when nothing matches.
// .NET 10
var result = customers.RightJoin(
orders,
c => c.Id,
o => o.CustomerId,
(customer, order) => new
{
CustomerName = customer?.Name ?? "Unknown", // `customer` is Customer?
OrderId = order.Id // `order` is never null
});
This one earns its place as a data quality check. An inner Join() silently swallows rows whose foreign key points at nothing, which means a broken reference costs you revenue in a report and you never find out. A right join keeps those rows and lets you either flag them or bucket them under "Unassigned". In the demo, the five orphan orders carry $4,706.10 that a plain Join() would have quietly dropped from the segment totals.
Combining the .NET 9 and .NET 10 operators
The new operators compose, but there is a trap when you combine a left join with CountBy(). A customer with no orders still produces one row, so counting rows gives 1 where the right answer is 0. This is exactly the case where the custom fold in AggregateBy() earns its keep, because you get to decide what counts:
// WRONG - a customer with no orders still produces ONE (null) row,
// so CountBy reports 1 instead of 0.
var wrong = customers
.LeftJoin(orders, c => c.Id, o => o.CustomerId,
(c, o) => (c.Name, Order: o))
.CountBy(x => x.Name);
// RIGHT - AggregateBy lets you decide what counts.
var right = customers
.LeftJoin(orders, c => c.Id, o => o.CustomerId,
(c, o) => (c.Name, Order: o))
.AggregateBy(
x => x.Name,
0,
(count, x) => x.Order is null ? count : count + 1);
Complete listing: Demos/Net10LinqDemos.cs
Note the third demo in this file. It runs the old GroupJoin chain and the new LeftJoin side by side and compares them with SequenceEqual, so the claim that they are equivalent is checked rather than asserted.
using CS_LINQ_9_10.Data;
using CS_LINQ_9_10.Models;
using static CS_LINQ_9_10.Demos.ConsoleHelper;
namespace CS_LINQ_9_10.Demos;
/// <summary>
/// LINQ methods added in .NET 10: LeftJoin(), RightJoin().
///
/// Join() has always been an INNER join: rows with no match on the other side vanish.
/// Getting a SQL LEFT JOIN previously meant chaining GroupJoin + SelectMany + DefaultIfEmpty,
/// three operators to express one idea. LeftJoin()/RightJoin() do it directly.
///
/// Key signature detail: the result selector receives a NULLABLE argument on the
/// optional side - Func<TOuter, TInner?, TResult> for LeftJoin,
/// Func<TOuter?, TInner, TResult> for RightJoin. Handle that null.
/// </summary>
public static class Net10LinqDemos
{
public static void Run()
{
Title(".NET 10 LINQ ENHANCEMENTS");
LeftJoinDemo();
LeftJoinFindsMissingRowsDemo();
LeftJoinVersusTheOldWayDemo();
LeftJoinWithNet9AggregateByDemo();
RightJoinDemo();
RightJoinFindsOrphansDemo();
}
// ---------------------------------------------------------------- LeftJoin
/// <summary>
/// LeftJoin(inner, outerKeySelector, innerKeySelector, resultSelector)
///
/// Every customer appears in the output at least once. A customer with no orders
/// appears exactly once with `order` set to null.
/// </summary>
private static void LeftJoinDemo()
{
Section("LeftJoin() - every customer, with or without orders (first 15 rows)");
var customerOrders = SeedData.Customers.LeftJoin(
SeedData.Orders,
customer => customer.Id, // key on the left (outer) side
order => order.CustomerId, // key on the right (inner) side
// `order` is Order? here - null whenever the customer has no match.
(customer, order) => new
{
customer.Id,
Customer = customer.Name,
OrderId = order?.Id, // null-conditional: no order => null
Amount = order?.Amount // decimal? rather than decimal
});
foreach (var row in customerOrders.Take(15))
{
var orderText = row.OrderId is null ? "(no orders)" : $"Order {row.OrderId}";
var amountText = row.Amount is null ? "-" : $"{row.Amount:C}";
Console.WriteLine($" {row.Id,3} {row.Customer,-22} {orderText,-12} {amountText,10}");
}
Note($"Rows produced: {customerOrders.Count()} (an inner Join would produce {SeedData.Customers.Join(SeedData.Orders, c => c.Id, o => o.CustomerId, (c, o) => c).Count()})");
}
/// <summary>
/// The classic reason to reach for a left join: finding rows that have NOTHING
/// on the other side. Filtering the joined result on `order is null` answers
/// "which customers have never ordered?" directly.
/// </summary>
private static void LeftJoinFindsMissingRowsDemo()
{
Section("LeftJoin() - customers who have never placed an order");
var neverOrdered = SeedData.Customers
.LeftJoin(
SeedData.Orders,
c => c.Id,
o => o.CustomerId,
(customer, order) => new { Customer = customer, Order = order })
.Where(x => x.Order is null) // the SQL "WHERE o.CustomerId IS NULL" idiom
.Select(x => x.Customer)
.ToList();
Console.WriteLine($" {neverOrdered.Count} customers, ids {neverOrdered.First().Id}..{neverOrdered.Last().Id}");
foreach (var customer in neverOrdered.Take(6))
{
Console.WriteLine($" {customer.Id,3} {customer.Name,-22} {customer.City,-12} {customer.Segment}");
}
Note("...");
}
/// <summary>
/// Side-by-side with the pre-.NET 10 pattern, running both and comparing the output
/// so the equivalence is not just a claim.
/// </summary>
private static void LeftJoinVersusTheOldWayDemo()
{
Section("LeftJoin() vs the old GroupJoin + SelectMany + DefaultIfEmpty");
// BEFORE .NET 10: three operators, one nested lambda, and an intermediate
// anonymous type whose only job is to carry `c` past the GroupJoin.
var oldWay = SeedData.Customers
.GroupJoin(
SeedData.Orders,
c => c.Id,
o => o.CustomerId,
(c, orders) => new { c, orders })
.SelectMany(
x => x.orders.DefaultIfEmpty(), // DefaultIfEmpty() injects a null row
(x, order) => new { Customer = x.c.Name, OrderId = order?.Id });
// .NET 10: one operator, flat lambdas, no intermediate projection.
var newWay = SeedData.Customers.LeftJoin(
SeedData.Orders,
c => c.Id,
o => o.CustomerId,
(c, order) => new { Customer = c.Name, OrderId = order?.Id });
var oldList = oldWay.ToList();
var newList = newWay.ToList();
Console.WriteLine($" Old pattern rows : {oldList.Count}");
Console.WriteLine($" LeftJoin rows : {newList.Count}");
Console.WriteLine($" Identical output : {oldList.SequenceEqual(newList)}");
Note("Same result, one operator instead of three.");
}
/// <summary>
/// The .NET 9 and .NET 10 additions compose neatly.
///
/// LeftJoin guarantees every customer shows up; AggregateBy then folds the rows into
/// one counter per customer. Because the accumulator only increments when `order` is
/// not null, customers with no orders come out as 0 rather than being missing.
///
/// CountBy() would NOT work here: it counts rows, and a customer with no orders still
/// produces one (null) row, so it would report 1 instead of 0. That is exactly the kind
/// of case where AggregateBy's custom fold earns its keep.
/// </summary>
private static void LeftJoinWithNet9AggregateByDemo()
{
Section("LeftJoin() + AggregateBy() - orders per customer, zeros included");
var orderCounts = SeedData.Customers
.LeftJoin(
SeedData.Orders,
c => c.Id,
o => o.CustomerId,
(customer, order) => (customer.Name, Order: order))
.AggregateBy(
x => x.Name,
0,
// Count the row only when there really is an order behind it.
(count, x) => x.Order is null ? count : count + 1);
var results = orderCounts.ToList();
Console.WriteLine(" Busiest customers:");
foreach (var (name, count) in results.OrderByDescending(x => x.Value).Take(5))
{
Console.WriteLine($" {name,-22} {count,2} orders");
}
Console.WriteLine(" Customers with zero orders (first 5):");
foreach (var (name, count) in results.Where(x => x.Value == 0).Take(5))
{
Console.WriteLine($" {name,-22} {count,2} orders");
}
Note($"{results.Count} distinct customer names covered, {results.Count(x => x.Value == 0)} of them with no orders at all.");
}
// --------------------------------------------------------------- RightJoin
/// <summary>
/// RightJoin(inner, outerKeySelector, innerKeySelector, resultSelector)
///
/// Mirror image of LeftJoin: every element of the INNER sequence survives, and the
/// OUTER element is null when there is no match. Here that means every order is
/// listed even if its customer id does not exist.
///
/// Note which parameter goes nullable: the result selector is
/// Func<Customer?, Order, TResult> - the customer is the optional one now.
/// </summary>
private static void RightJoinDemo()
{
Section("RightJoin() - every order, matched to a customer where possible");
var orderRows = SeedData.Customers.RightJoin(
SeedData.Orders,
customer => customer.Id,
order => order.CustomerId,
// `customer` is Customer? - null for orders pointing at a missing customer.
(customer, order) => new
{
CustomerName = customer?.Name ?? "Unknown",
order.Id,
order.Amount,
order.Status
});
foreach (var row in orderRows.Take(8))
{
Console.WriteLine($" Order {row.Id} {row.CustomerName,-22} {row.Amount,10:C} {row.Status}");
}
Note($"Rows produced: {orderRows.Count()} - exactly the {SeedData.Orders.Count} orders, none dropped.");
}
/// <summary>
/// The data-quality use case: a right join surfaces inner rows whose foreign key
/// points at nothing. An inner Join() would silently swallow these.
/// </summary>
private static void RightJoinFindsOrphansDemo()
{
Section("RightJoin() - orphan orders (CustomerId matches no customer)");
var orphans = SeedData.Customers
.RightJoin(
SeedData.Orders,
c => c.Id,
o => o.CustomerId,
(customer, order) => new { Customer = customer, Order = order })
.Where(x => x.Customer is null)
.Select(x => x.Order)
.ToList();
foreach (var order in orphans)
{
Console.WriteLine($" Order {order.Id} CustomerId {order.CustomerId} does not exist {order.Amount,10:C}");
}
Note($"{orphans.Count} orphan orders found. An inner Join() would have hidden all of them.");
Section("RightJoin() + AggregateBy() - revenue per customer segment, orphans included");
// RightJoin keeps the orphan orders, so their revenue is not quietly lost;
// AggregateBy (from .NET 9) then folds the whole thing into one total per segment.
var revenueBySegment = SeedData.Customers
.RightJoin(
SeedData.Orders,
c => c.Id,
o => o.CustomerId,
(customer, order) => (Segment: customer?.Segment ?? "Unassigned", order.Amount))
.AggregateBy(
x => x.Segment,
0m,
(total, x) => total + x.Amount);
foreach (var (segment, total) in revenueBySegment.OrderByDescending(x => x.Value))
{
Console.WriteLine($" {segment,-12} {total,14:C}");
}
Note($"Grand total {SeedData.Orders.Sum(o => o.Amount):C} - nothing lost, because every order survived the join.");
}
}
Wiring it up
Demos/ConsoleHelper.cs
Formatting only, so the output stays readable:
namespace CS_LINQ_9_10.Demos;
/// <summary>Tiny formatting helpers so the demo output stays readable.</summary>
internal static class ConsoleHelper
{
public static void Title(string text)
{
Console.WriteLine();
Console.WriteLine(new string('=', 78));
Console.WriteLine($" {text}");
Console.WriteLine(new string('=', 78));
}
public static void Section(string text)
{
Console.WriteLine();
Console.WriteLine($"--- {text} ".PadRight(78, '-'));
}
public static void Note(string text) => Console.WriteLine($" {text}");
}
Program.cs
// ---------------------------------------------------------------------------
// LINQ Enhancements in .NET 9 and .NET 10
//
// .NET 9 : CountBy(), AggregateBy(), Index()
// .NET 10: LeftJoin(), RightJoin()
//
// Data set (see Data/SeedData.cs): 50 Customers, 50 Products, 50 Orders.
// Run with: dotnet run
// ---------------------------------------------------------------------------
using CS_LINQ_9_10.Data;
using CS_LINQ_9_10.Demos;
Console.OutputEncoding = System.Text.Encoding.UTF8;
Console.WriteLine($"Seed data loaded: {SeedData.Customers.Count} customers, " +
$"{SeedData.Products.Count} products, {SeedData.Orders.Count} orders.");
Net9LinqDemos.Run();
Net10LinqDemos.Run();
ConsoleHelper.Title("SUMMARY");
Console.WriteLine("""
.NET 9
CountBy(keySelector) -> KeyValuePair<TKey, int> per key.
Replaces GroupBy(...).Select(g => g.Count()).
AggregateBy(keySelector, seed, func) -> one folded accumulator per key, no buffering.
Use the seedSelector overload when the
accumulator is a reference type or depends
on the key.
Index() -> (int Index, T Item) tuples. Replaces
Select((x, i) => ...) and manual counters.
.NET 10
LeftJoin(inner, outerKey, innerKey, sel) -> every OUTER element survives; the inner
argument is nullable. Replaces
GroupJoin + SelectMany + DefaultIfEmpty.
RightJoin(inner, outerKey, innerKey, sel)-> every INNER element survives; the outer
argument is nullable. Good for finding
rows with a broken foreign key.
""");
Console.WriteLine();
The output
Running dotnet run produces this. Every number is reproducible, since the seed data is deterministic:
Seed data loaded: 50 customers, 50 products, 50 orders.
==============================================================================
.NET 9 LINQ ENHANCEMENTS
==============================================================================
--- CountBy() - customers per city -------------------------------------------
Mumbai 7
Pune 7
Chicago 6
New York 6
London 6
Manchester 6
Berlin 6
Sydney 6
Total customers: 50
--- CountBy() - composite key (Country + Segment) ----------------------------
Australia Corporate 2
Australia Government 2
Australia Retail 2
Germany Corporate 2
Germany Government 2
Germany Retail 2
India Corporate 5
India Government 4
India Retail 5
UK Corporate 4
UK Government 4
UK Retail 4
USA Corporate 4
USA Government 4
USA Retail 4
--- CountBy() - orders per status --------------------------------------------
Placed 13
Shipped 13
Delivered 12
Cancelled 12
--- AggregateBy() - order total per customer (top 10) ------------------------
5 Meera Mendez $4,782.40
25 Meera Rao $3,272.45
9 Arjun Fernandes $3,261.92
10 Sofia Whitfield $3,019.90
15 Meera Kulkarni $3,019.90
20 Sofia Osei $2,391.20
948 <unknown customer> $2,335.96
18 Elena Rao $2,069.22
28 Elena Lindqvist $2,069.22
3 Priya Whitfield $2,023.44
--- AggregateBy() - seedSelector overload: product names per category --------
Audio 8 products
Label Roll M105, Thermal Printer M111, Office Chair M117, ...
Consumables 8 products
Docking Station M103, Noise Headset M109, Label Roll M115, ...
Furniture 8 products
Mesh Router M104, Webcam Pro M110, USB-C Hub M116, ...
Imaging 8 products
USB-C Hub M106, Barcode Scanner M112, Standing Desk M118, ...
Networking 9 products
Barcode Scanner M102, Standing Desk M108, Mesh Router M114, ...
Peripherals 9 products
Thermal Printer M101, Office Chair M107, Docking Station M113, ...
--- AggregateBy() - count, sum and max in ONE pass, per order status ---------
Status Count Sum Average Largest
Cancelled 12 $11,349.88 $945.82 $2,391.20
Delivered 12 $12,191.15 $1,015.93 $3,272.45
Placed 13 $13,983.10 $1,075.62 $3,272.45
Shipped 13 $12,180.62 $936.97 $2,391.20
--- Index() - rank the 10 highest-value orders -------------------------------
#1 Order 1025 $3,272.45 Placed
#2 Order 1035 $3,272.45 Delivered
#3 Order 1010 $2,391.20 Shipped
#4 Order 1020 $2,391.20 Cancelled
#5 Order 1039 $2,335.96 Delivered
#6 Order 1049 $2,335.96 Placed
#7 Order 1018 $2,069.22 Shipped
#8 Order 1028 $2,069.22 Cancelled
#9 Order 1024 $1,630.96 Cancelled
#10 Order 1034 $1,630.96 Shipped
--- Index() - numbered list of the newest 8 customers ------------------------
1. Sofia Kulkarni joined 2023-06-24 [Pune]
2. Arjun Lindqvist joined 2023-06-13 [Mumbai]
3. Elena Osei joined 2023-06-02 [Sydney]
4. Kavya Mendez joined 2023-05-22 [Berlin]
5. Thomas Rao joined 2023-05-11 [Manchester]
6. Meera Whitfield joined 2023-04-30 [London]
7. Daniel Fernandes joined 2023-04-19 [New York]
8. Priya Kulkarni joined 2023-04-08 [Chicago]
--- Index() + CountBy() combined - position of each category by product count
1. Peripherals 9 products
2. Networking 9 products
3. Consumables 8 products
4. Furniture 8 products
5. Audio 8 products
6. Imaging 8 products
==============================================================================
.NET 10 LINQ ENHANCEMENTS
==============================================================================
--- LeftJoin() - every customer, with or without orders (first 15 rows) ------
1 Asha Kulkarni Order 1001 $19.99
1 Asha Kulkarni Order 1031 $372.49
2 Rohan Fernandes Order 1002 $533.48
2 Rohan Fernandes Order 1032 $1,238.48
3 Priya Whitfield Order 1003 $1,540.47
3 Priya Whitfield Order 1033 $482.97
4 Daniel Rao Order 1004 $220.96
4 Daniel Rao Order 1034 $1,630.96
5 Meera Mendez Order 1005 $1,509.95
5 Meera Mendez Order 1035 $3,272.45
6 Thomas Osei Order 1006 $548.74
6 Thomas Osei Order 1036 $196.24
7 Kavya Lindqvist Order 1007 $180.98
7 Kavya Lindqvist Order 1037 $180.98
8 Elena Kulkarni Order 1008 $1,011.72
Rows produced: 65 (an inner Join would produce 45)
--- LeftJoin() - customers who have never placed an order --------------------
20 customers, ids 31..50
31 Asha Whitfield Berlin Retail
32 Rohan Rao Sydney Corporate
33 Priya Mendez Mumbai Government
34 Daniel Osei Pune Retail
35 Meera Lindqvist Chicago Corporate
36 Thomas Kulkarni New York Government
...
--- LeftJoin() vs the old GroupJoin + SelectMany + DefaultIfEmpty ------------
Old pattern rows : 65
LeftJoin rows : 65
Identical output : True
Same result, one operator instead of three.
--- LeftJoin() + AggregateBy() - orders per customer, zeros included ---------
Busiest customers:
Asha Kulkarni 2 orders
Rohan Fernandes 2 orders
Priya Whitfield 2 orders
Daniel Rao 2 orders
Meera Mendez 2 orders
Customers with zero orders (first 5):
Asha Whitfield 0 orders
Rohan Rao 0 orders
Priya Mendez 0 orders
Daniel Osei 0 orders
Meera Lindqvist 0 orders
50 distinct customer names covered, 20 of them with no orders at all.
--- RightJoin() - every order, matched to a customer where possible ----------
Order 1001 Asha Kulkarni $19.99 Placed
Order 1002 Rohan Fernandes $533.48 Shipped
Order 1003 Priya Whitfield $1,540.47 Delivered
Order 1004 Daniel Rao $220.96 Cancelled
Order 1005 Meera Mendez $1,509.95 Placed
Order 1006 Thomas Osei $548.74 Shipped
Order 1007 Kavya Lindqvist $180.98 Delivered
Order 1008 Elena Kulkarni $1,011.72 Cancelled
Rows produced: 50 - exactly the 50 orders, none dropped.
--- RightJoin() - orphan orders (CustomerId matches no customer) -------------
Order 1046 CustomerId 945 does not exist $548.74
Order 1047 CustomerId 946 does not exist $180.98
Order 1048 CustomerId 947 does not exist $1,011.72
Order 1049 CustomerId 948 does not exist $2,335.96
Order 1050 CustomerId 949 does not exist $628.70
5 orphan orders found. An inner Join() would have hidden all of them.
--- RightJoin() + AggregateBy() - revenue per customer segment, orphans included
Government $16,057.05
Retail $14,647.05
Corporate $14,294.55
Unassigned $4,706.10
Grand total $49,704.75 - nothing lost, because every order survived the join.
==============================================================================
SUMMARY
==============================================================================
.NET 9
CountBy(keySelector) -> KeyValuePair<TKey, int> per key.
Replaces GroupBy(...).Select(g => g.Count()).
AggregateBy(keySelector, seed, func) -> one folded accumulator per key, no buffering.
Use the seedSelector overload when the
accumulator is a reference type or depends
on the key.
Index() -> (int Index, T Item) tuples. Replaces
Select((x, i) => ...) and manual counters.
.NET 10
LeftJoin(inner, outerKey, innerKey, sel) -> every OUTER element survives; the inner
argument is nullable. Replaces
GroupJoin + SelectMany + DefaultIfEmpty.
RightJoin(inner, outerKey, innerKey, sel)-> every INNER element survives; the outer
argument is nullable. Good for finding
rows with a broken foreign key.
Three things that trip people up
AggregateBy(). The overload taking a seed value evaluates that value once and hands the same instance to every group. With a decimal nothing goes wrong. With a List or any other mutable object, every group ends up writing into one shared accumulator and the result is nonsense. Reach for the seedSelector overload whenever the accumulator is a reference type.LeftJoin makes the inner argument nullable, RightJoin makes the outer one nullable. Writing customer.Name instead of customer?.Name in a RightJoin result selector compiles with a warning and then throws at runtime on the first orphan row. Keep <Nullable>enable</Nullable> on and the compiler catches it for you.CountBy() after a LeftJoin() reports 1 for something that should be 0. Either filter the nulls out first or use AggregateBy() with a fold that checks for null.Summary
| Method | Returns | Use it when |
|---|---|---|
CountBy(keySelector) |
KeyValuePair<TKey, int> per key |
You need a count per group and nothing else. |
AggregateBy(keySelector, seed, func) |
KeyValuePair<TKey, TAccumulate> per key |
You need a sum, a max, a list, or several statistics at once, per group. Use the seedSelector overload for mutable accumulators. |
Index() |
(int Index, T Item) tuples |
You need the position alongside the element, without projecting it away. |
LeftJoin(...) |
One TResult per outer element, at minimum |
Every outer element must appear, matched or not. The inner argument is nullable. |
RightJoin(...) |
One TResult per inner element, at minimum |
Every inner element must appear. Good for finding broken foreign keys. The outer argument is nullable. |
If you are introducing these to a team, CountBy() and LeftJoin() are the two to lead with. Both show up in almost every codebase, both are obvious improvements on sight, and neither needs any explanation beyond showing the before and after.
The code for this article can be downloaded from this link.