If you've ever tried to correlate data from two in-memory collections, you've run into the need for LINQ joins in C#. The Join operator handles the simple equijoin case cleanly, but the moment you need to keep unmatched rows -- the classic left outer join -- the code historically became a verbose mess of GroupJoin followed by SelectMany. That changed in .NET 10, which introduced first-class LeftJoin and RightJoin operators. This article covers the complete picture: inner joins, grouped joins, left and right outer joins, cross joins, and Zip for positional pairing, all with realistic domain examples and direct SQL comparisons.
The Domain Model
All examples in this article use a Customer/Order/Product domain:
namespace DevLeader.LinqJoins;
public record Customer(int Id, string Name, string Region);
public record Order(int Id, int CustomerId, int ProductId, int Quantity, DateTimeOffset PlacedAt);
public record Product(int Id, string Name, decimal UnitPrice, string Category);
This gives us natural join scenarios: orders per customer, products on orders, customers without orders, and so on.
Join: Inner Equijoin
Join matches elements from two sequences where a key selector produces equal keys. Unmatched elements from either side are excluded -- this is the SQL INNER JOIN equivalent.
namespace DevLeader.LinqJoins;
IEnumerable<Customer> customers = GetCustomers();
IEnumerable<Order> orders = GetOrders();
// Inner join: only customers who have placed at least one order
var customerOrders = customers.Join(
inner: orders,
outerKeySelector: c => c.Id,
innerKeySelector: o => o.CustomerId,
resultSelector: (customer, order) => new
{
customer.Name,
customer.Region,
order.Id,
order.PlacedAt,
order.Quantity
});
foreach (var row in customerOrders)
{
Console.WriteLine($"{row.Name} ({row.Region}) -- Order #{row.Id} on {row.PlacedAt:d}");
}
SQL equivalent:
SELECT c.Name, c.Region, o.Id, o.PlacedAt, o.Quantity
FROM Customers c
INNER JOIN Orders o ON c.Id = o.CustomerId
Three-Way Join
Chaining joins is a matter of performing the first join and using the result as the outer sequence for the next:
namespace DevLeader.LinqJoins;
IEnumerable<Product> products = GetProducts();
// Customers -> Orders -> Products (three-way inner join)
var orderDetails = customers
.Join(orders,
c => c.Id,
o => o.CustomerId,
(c, o) => new { Customer = c, Order = o })
.Join(products,
co => co.Order.ProductId,
p => p.Id,
(co, p) => new
{
co.Customer.Name,
ProductName = p.Name,
p.UnitPrice,
co.Order.Quantity,
LineTotal = p.UnitPrice * co.Order.Quantity
});
foreach (var detail in orderDetails)
{
Console.WriteLine(
$"{detail.Name} bought {detail.Quantity}x {detail.ProductName} " +
$"= ${detail.LineTotal:F2}");
}
Three-way joins get verbose fast. If you find yourself chaining more than two joins, the feature slicing approach can help by co-locating query logic with the feature that owns it rather than spreading it across layers.
GroupJoin: The Old Left Outer Join Pattern
Before .NET 10, a left outer join required GroupJoin followed by SelectMany with a DefaultIfEmpty:
namespace DevLeader.LinqJoins;
// Pre-.NET 10 left outer join -- customers with all their orders (or null if none)
var leftJoinOld = customers.GroupJoin(
orders,
c => c.Id,
o => o.CustomerId,
(customer, matchedOrders) => new { customer, matchedOrders })
.SelectMany(
x => x.matchedOrders.DefaultIfEmpty(),
(x, order) => new
{
x.customer.Name,
OrderId = order?.Id,
OrderDate = order?.PlacedAt
});
foreach (var row in leftJoinOld)
{
Console.WriteLine(row.OrderId.HasValue
? $"{row.Name} -- Order #{row.OrderId} on {row.OrderDate:d}"
: $"{row.Name} -- (no orders)");
}
This works, but the intent is buried. The GroupJoin + SelectMany + DefaultIfEmpty pattern is notoriously difficult to read, especially for developers coming from a SQL background.
GroupJoin for True Grouped Results
GroupJoin does have a legitimate use case beyond simulating left joins: when you actually want each outer element paired with a collection of matching inner elements:
namespace DevLeader.LinqJoins;
// Customers with their order collections (not flattened)
var customersWithOrders = customers.GroupJoin(
orders,
c => c.Id,
o => o.CustomerId,
(customer, matchedOrders) => new
{
customer.Name,
Orders = matchedOrders.ToList(),
TotalSpend = matchedOrders.Sum(o => o.Quantity) // simplified
});
foreach (var row in customersWithOrders)
{
Console.WriteLine($"{row.Name}: {row.Orders.Count} orders");
}
Use GroupJoin for this "one-to-many with full sub-collection" pattern. For everything else, the .NET 10 operators described below are cleaner.
.NET 10: LeftJoin
LeftJoin was added in .NET 10 specifically to replace the GroupJoin/SelectMany/DefaultIfEmpty pattern. The API mirrors Join exactly, with the semantic guarantee that every outer element appears in the result:
namespace DevLeader.LinqJoins;
// .NET 10 -- same result as the GroupJoin workaround, far more readable
var leftJoin = customers.LeftJoin(
orders,
c => c.Id,
o => o.CustomerId,
(customer, order) => new
{
customer.Name,
customer.Region,
OrderId = order?.Id,
OrderDate = order?.PlacedAt
});
foreach (var row in leftJoin)
{
Console.WriteLine(row.OrderId.HasValue
? $"{row.Name} ({row.Region}) -- Order #{row.OrderId}"
: $"{row.Name} ({row.Region}) -- (no orders)");
}
SQL equivalent:
SELECT c.Name, c.Region, o.Id, o.PlacedAt
FROM Customers c
LEFT JOIN Orders o ON c.Id = o.CustomerId
The order parameter in the result selector is nullable because it is null when no matching inner element exists. This is the same nullability you see with SQL LEFT JOIN -- non-nullable in SQL sense but nullable in the projected columns.
LeftJoin with Default Value for Inner
If you'd rather use a sentinel value instead of null for the non-matching case, project into a type with a default:
namespace DevLeader.LinqJoins;
public record OrderSummary(int? Id, DateTimeOffset? PlacedAt, bool HasOrder);
var leftJoinWithDefault = customers.LeftJoin(
orders,
c => c.Id,
o => o.CustomerId,
(c, o) => new
{
c.Name,
Summary = o is null
? new OrderSummary(null, null, false)
: new OrderSummary(o.Id, o.PlacedAt, true)
});
.NET 10: RightJoin
RightJoin keeps every inner (right-side) element in the result, with null for unmatched outer elements. This is exactly SQL RIGHT JOIN, and is symmetric to LeftJoin:
namespace DevLeader.LinqJoins;
IEnumerable<Order> allOrders = GetAllOrders(); // may include orders with no valid customer
IEnumerable<Customer> activeUsers = GetActiveCustomers();
// Every order appears -- even orphaned ones without a matching customer
var rightJoin = activeUsers.RightJoin(
allOrders,
c => c.Id,
o => o.CustomerId,
(customer, order) => new
{
CustomerName = customer?.Name ?? "(orphaned)",
order.Id,
order.PlacedAt
});
foreach (var row in rightJoin)
{
Console.WriteLine($"Order #{row.Id} -- Customer: {row.CustomerName}");
}
SQL equivalent:
SELECT COALESCE(c.Name, '(orphaned)'), o.Id, o.PlacedAt
FROM Customers c
RIGHT JOIN Orders o ON c.Id = o.CustomerId
RightJoin is less common than LeftJoin in practice. You can always swap the source and inner arguments and use LeftJoin to achieve the same result. But having both operators available means your code can match the mental model of the query -- "start with orders, optionally bring in customer data" -- without restructuring the sequence order.
Note for
IQueryable<T>users:LeftJoinandRightJoinare LINQ to Objects operators added in .NET 10. Translation support inIQueryable<T>providers (such as Entity Framework Core) may lag behind the runtime release -- check your provider's release notes before relying on these operators in database queries.
Zip: Positional Join
Zip is not a relational join -- it pairs elements from two sequences by position. The first element of sequence A pairs with the first element of sequence B, and so on. If one sequence is longer, the extra elements are silently dropped (in the two-parameter overload).
namespace DevLeader.LinqJoins;
string[] customerNames = ["Alice", "Bob", "Carol"];
int[] loyaltyPoints = [1200, 450, 3300];
// Pair by position
IEnumerable<(string Name, int Points)> paired = customerNames.Zip(loyaltyPoints);
foreach ((string name, int points) in paired)
{
Console.WriteLine($"{name}: {points} points");
}
With a result selector:
namespace DevLeader.LinqJoins;
IEnumerable<string> summary = customerNames.Zip(
loyaltyPoints,
(name, points) => $"{name} has {points} loyalty points");
foreach (string line in summary)
{
Console.WriteLine(line);
}
.NET 6+ also offers a three-sequence overload:
namespace DevLeader.LinqJoins;
string[] names = ["Alice", "Bob", "Carol"];
int[] orders = [12, 5, 22];
decimal[] spend = [4200m, 320m, 8500m];
IEnumerable<(string, int, decimal)> triples = names.Zip(orders, spend);
Use Zip for things like pairing labels with values, or interleaving two pre-sorted lists. Do not use it as a substitute for a keyed join -- if the positions of corresponding elements can drift, Join on a shared key is the correct tool.
Cross Joins with SelectMany
A cross join produces every combination of elements from two sequences. LINQ handles this with SelectMany:
namespace DevLeader.LinqJoins;
string[] sizes = ["S", "M", "L", "XL"];
string[] colors = ["Red", "Blue", "Green"];
// Every size-color combination
IEnumerable<string> skus = sizes.SelectMany(
_ => colors,
(size, color) => $"{size}-{color}");
foreach (string sku in skus)
{
Console.WriteLine(sku); // S-Red, S-Blue, ... XL-Green
}
Cross joins are expensive -- the result is the product of both sequence lengths. For the plugin architecture pattern, cross joins occasionally appear when registering plugin combinations, but always evaluate whether the full Cartesian product is really needed.
LINQ Joins vs SQL Joins -- Side-by-Side
| SQL Join | LINQ Equivalent (pre-.NET 10) | LINQ Equivalent (.NET 10+) |
|---|---|---|
INNER JOIN |
Join() |
Join() |
LEFT OUTER JOIN |
GroupJoin().SelectMany().DefaultIfEmpty() |
LeftJoin() |
RIGHT OUTER JOIN |
Reversed GroupJoin() workaround |
RightJoin() |
CROSS JOIN |
SelectMany() |
SelectMany() |
FULL OUTER JOIN |
Concat of left and right joins |
Manual -- no built-in |
A full outer join in LINQ requires concatenating a LeftJoin result with a RightJoin result and deduplicating. This is rare enough that it's not built into the framework.
Practical Guidance
A few things worth keeping in mind when working with LINQ joins in production code:
- Materialize before joining large sequences. If both source sequences are lazily evaluated,
Joinwill enumerate the inner sequence once per outer element. Call.ToList()on the inner sequence before the join. - Use CQRS query handlers to encapsulate complex joins. A query that joins customers, orders, and products belongs in a dedicated query handler, not scattered across controller actions.
- Enum-based status fields are common join keys. If you're joining on an enum column, see the C# enum complete guide for value normalization patterns.
- For semantic similarity joins (e.g., finding "close" products), LINQ equality-based joins won't help -- see how to build a semantic search engine instead.
FAQ
What is the difference between Join and GroupJoin in C#?
Join produces a flat sequence where each outer element is paired one-to-one with each matching inner element. GroupJoin produces a sequence where each outer element is paired with a collection of all matching inner elements. GroupJoin is the foundation for the pre-.NET 10 left outer join pattern, but it is also useful on its own when you want grouped results rather than a flat projection.
How do I do a left outer join in LINQ before .NET 10?
Use GroupJoin followed by SelectMany with DefaultIfEmpty: outer.GroupJoin(inner, outerKey, innerKey, (o, matches) => new { o, matches }).SelectMany(x => x.matches.DefaultIfEmpty(), (x, i) => resultSelector(x.o, i)). In .NET 10 and later, use LeftJoin directly.
What is LeftJoin in .NET 10?
LeftJoin is a new LINQ operator introduced in .NET 10 that performs a left outer join -- every element from the outer (left) sequence appears in the result, with the inner element being null when no matching element exists in the inner (right) sequence.
Is LINQ Join the same as SQL INNER JOIN?
Yes, functionally. Join returns only pairs where the key selectors produce equal values, discarding all unmatched elements from both sides -- exactly like SQL INNER JOIN. The key difference is that LINQ Join operates on in-memory collections while SQL JOIN operates at the database level.
When should I use Zip instead of Join?
Use Zip when elements correspond by position rather than by a shared key. Common scenarios include pairing a list of values with a pre-generated list of labels, or interleaving two sequences that were intentionally sorted to align. If you need relational matching on a key field, always prefer Join, LeftJoin, or RightJoin.
Can LINQ do a full outer join?
Not with a single built-in operator. You must concatenate the result of a LeftJoin (which covers all outer-side unmatched records) with the result of a RightJoin filtered to unmatched inner-side records only. This is uncommon enough that most teams build a small extension method for it when needed.
How do I join on multiple keys in LINQ?
Pass an anonymous type or value tuple as the key selector on both sides: .Join(inner, o => new { o.CategoryId, o.RegionId }, i => new { i.CategoryId, i.RegionId }, ...). Anonymous types use structural equality, so this works correctly as long as the property names and types match on both sides.
Summary
LINQ joins in C# cover a wide range of data-correlation scenarios:
Joinis clean and efficient for inner equijoins -- use it whenever unmatched rows should be excluded.GroupJoinis best for "one outer element with a collection of inner elements," not as a left join workaround..NET 10'sLeftJoinandRightJoineliminate the verboseGroupJoin/SelectMany/DefaultIfEmptypattern entirely -- upgrade your code as soon as you're on .NET 10.Ziphandles positional pairing and is not a substitute for keyed joining.- Cross joins via
SelectManyare powerful but expensive -- use sparingly.
For building well-structured applications around these query patterns, see how feature slicing vs clean architecture can guide where join logic lives. And if you're composing join results with decorators, the decorator pattern real-world example shows how to layer behavior on top of query results without modifying the core pipeline.

