You have two collections. You need every element from both, matched where possible and unmatched where not. In SQL, this is a FULL OUTER JOIN -- one of the most fundamental relational operations. In LINQ, it has been a multi-step ordeal involving GroupJoin, SelectMany, DefaultIfEmpty, and Concat -- a pattern so verbose that most developers either reach for raw SQL or write a custom extension method.
.NET 10 addressed part of this gap by adding LeftJoin and RightJoin to System.Linq. .NET 11 Preview 5 completes the picture: Enumerable.FullJoin is now a first-class LINQ operator, and every join method -- including the existing Join and GroupJoin -- gains tuple-returning overloads that eliminate the result selector delegate entirely.
These additions ship across Enumerable, Queryable, and AsyncEnumerable, meaning you get the same API surface whether you are working with in-memory collections, EF Core queries, or async streams.
What FullJoin does
FullJoin returns all elements from both sequences. Where keys match, you get a paired result. Where a key exists only on the left, the right element is default. Where a key exists only on the right, the left element is default. This is the exact semantics of SQL's FULL OUTER JOIN.
Consider a scenario where you are reconciling a product catalogue against sales records. Some products have never been sold, and some sales reference products that have been removed from the catalogue:
var products = new List<Product>
{
new(Sku: "LAP-001", Name: "ThinkPad X1"),
new(Sku: "MON-002", Name: "Dell U2723QE"),
new(Sku: "KBD-003", Name: "HHKB Professional"),
};
var sales = new List<Sale>
{
new(Sku: "LAP-001", Quantity: 14),
new(Sku: "MON-002", Quantity: 7),
new(Sku: "GPU-004", Quantity: 3), // no longer in catalogue
};
var reconciled = products.FullJoin(
sales,
p => p.Sku,
s => s.Sku,
(product, sale) => new
{
Sku = product?.Sku ?? sale!.Sku,
Name = product?.Name ?? "(removed from catalogue)",
Sold = sale?.Quantity ?? 0,
});
foreach (var row in reconciled)
Console.WriteLine($"{row.Sku}: {row.Name} — {row.Sold} sold");
Output:
LAP-001: ThinkPad X1 — 14 sold
MON-002: Dell U2723QE — 7 sold
KBD-003: HHKB Professional — 0 sold
GPU-004: (removed from catalogue) — 3 sold
The crucial detail is in the result selector signature: Func<TOuter?, TInner?, TResult>. Both parameters are nullable because either side can be absent. This differs from LeftJoin (where only the inner is nullable) and RightJoin (where only the outer is nullable).
The API surface
FullJoin ships with four overloads on Enumerable:
// With result selector
public static IEnumerable<TResult> FullJoin<TOuter, TInner, TKey, TResult>(
this IEnumerable<TOuter> outer,
IEnumerable<TInner> inner,
Func<TOuter, TKey> outerKeySelector,
Func<TInner, TKey> innerKeySelector,
Func<TOuter?, TInner?, TResult> resultSelector);
// With result selector and custom comparer
public static IEnumerable<TResult> FullJoin<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);
// Tuple-returning (no result selector)
public static IEnumerable<(TOuter? Outer, TInner? Inner)> FullJoin<TOuter, TInner, TKey>(
this IEnumerable<TOuter> outer,
IEnumerable<TInner> inner,
Func<TOuter, TKey> outerKeySelector,
Func<TInner, TKey> innerKeySelector);
// Tuple-returning with custom comparer
public static IEnumerable<(TOuter? Outer, TInner? Inner)> FullJoin<TOuter, TInner, TKey>(
this IEnumerable<TOuter> outer,
IEnumerable<TInner> inner,
Func<TOuter, TKey> outerKeySelector,
Func<TInner, TKey> innerKeySelector,
IEqualityComparer<TKey>? comparer);
The same overloads exist on Queryable (using Expression<Func<...>>) and on AsyncEnumerable (with both synchronous and asynchronous key selectors returning ValueTask<TKey>).
Like every other LINQ join operator, FullJoin uses deferred execution. The inner sequence is buffered into a hash-based lookup on first enumeration; the outer sequence is streamed.
Tuple-returning overloads: the bigger quality-of-life improvement
While FullJoin gets the headline, the tuple-returning overloads across all join methods arguably matter more for day-to-day code. Since LINQ shipped in 2007, every call to Join or GroupJoin has required a result selector -- even when you just want the paired elements:
// Bad — the result selector is pure ceremony
var matched = orders.Join(
customers,
o => o.CustomerId,
c => c.Id,
(order, customer) => new { order, customer });
.NET 11 adds overloads that return ValueTuple directly:
// Good — tuple deconstruction, no selector needed
var matched = orders.Join(
customers,
o => o.CustomerId,
c => c.Id);
foreach (var (order, customer) in matched)
Console.WriteLine($"Order {order.Id} for {customer.Name}");
The tuple-returning overloads are available on:
| Method | Return type |
|---|---|
Join |
IEnumerable<(TOuter Outer, TInner Inner)> |
LeftJoin |
IEnumerable<(TOuter Outer, TInner? Inner)> |
RightJoin |
IEnumerable<(TOuter? Outer, TInner Inner)> |
FullJoin |
IEnumerable<(TOuter? Outer, TInner? Inner)> |
GroupJoin |
IEnumerable<(TOuter Outer, IEnumerable<TInner> Inner)> |
Notice how the nullability follows the join semantics: inner joins have no nullable elements, left joins make the inner nullable, right joins make the outer nullable, and full joins make both nullable. This is enforced at the type level -- the compiler catches null-safety mistakes before you run the code.
Every tuple-returning overload also accepts an optional IEqualityComparer<TKey> parameter.
Real-world patterns
Comparing snapshots
Full joins excel at diff-style comparisons. If you are comparing deployed configuration against expected configuration, the unmatched elements on each side tell you exactly what is missing or unexpected:
public IEnumerable<ConfigDiff> CompareConfiguration(
IEnumerable<ConfigEntry> expected,
IEnumerable<ConfigEntry> deployed)
{
return expected.FullJoin(
deployed,
e => e.Key,
d => d.Key,
(exp, dep) => new ConfigDiff
{
Key = exp?.Key ?? dep!.Key,
Status = (exp, dep) switch
{
(not null, not null) when exp.Value == dep.Value => DiffStatus.Unchanged,
(not null, not null) => DiffStatus.Modified,
(not null, null) => DiffStatus.Missing,
(null, not null) => DiffStatus.Unexpected,
},
ExpectedValue = exp?.Value,
DeployedValue = dep?.Value,
});
}
The exhaustive switch expression over the nullable tuple is a natural fit. When both sides are present, you compare values. When only one side exists, you classify it immediately.
Tuple overloads with pattern matching
The tuple-returning overloads pair particularly well with deconstruction and is patterns:
var allStudents = students.FullJoin(
enrolments,
s => s.Id,
e => e.StudentId);
foreach (var (student, enrolment) in allStudents)
{
var status = (student, enrolment) switch
{
(not null, not null) => $"{student.Name} is enrolled in {enrolment.CourseName}",
(not null, null) => $"{student.Name} has no enrolment",
(null, not null) => $"Orphaned enrolment for student ID {enrolment.StudentId}",
_ => throw new InvalidOperationException(),
};
Console.WriteLine(status);
}
No result selector, no anonymous types -- just tuples and pattern matching.
AsyncEnumerable support
For async streams, the API mirrors the synchronous version:
public async IAsyncEnumerable<InventoryDelta> StreamInventoryDeltas(
IAsyncEnumerable<WarehouseItem> warehouseStream,
IAsyncEnumerable<OrderItem> orderStream,
[EnumeratorCancellation] CancellationToken ct = default)
{
await foreach (var (warehouse, order) in warehouseStream.FullJoin(
orderStream,
w => w.Sku,
o => o.Sku).WithCancellation(ct))
{
yield return new InventoryDelta(
Sku: warehouse?.Sku ?? order!.Sku,
InStock: warehouse?.Quantity ?? 0,
OnOrder: order?.Quantity ?? 0);
}
}
The async overloads also support asynchronous key selectors that return ValueTask<TKey>, useful when the key computation itself involves an async lookup.
How it works internally
The FullJoin implementation follows a two-phase approach:
Build phase: The inner sequence is materialised into a hash-based grouping structure (similar to how
Joinworks internally). Each inner element is grouped by its key.Emit phase: The outer sequence is iterated. For each outer element, the implementation looks up matching inner groups. Matched inner groups are marked as "seen." If no match exists, a result with
defaultfor the inner side is emitted. After the outer sequence is exhausted, any inner groups that were never matched are emitted withdefaultfor the outer side.
This means the inner sequence is fully buffered in memory, while the outer sequence can be streamed. If memory pressure is a concern, place the smaller collection as the inner (second) argument.
// TIP
When both sequences are large, consider which one to place as the inner argument. The inner sequence is fully materialised into a lookup; the outer sequence is streamed. Placing the smaller collection as the inner argument reduces memory consumption.
Queryable and EF Core considerations
The Queryable versions of these operators expose the same API shape using expression trees. Whether your query provider can translate FullJoin to SQL depends on the provider.
For EF Core specifically, LeftJoin and RightJoin translation was added in EF Core 10. FullJoin translation support in EF Core is being tracked separately, so check the EF Core 11 release notes for the current status. In the meantime, FullJoin works perfectly for in-memory LINQ and for providers that support the operation natively.
// WARNING
If your EF Core provider does not yet translate FullJoin, calling it on an IQueryable will throw at query execution time. Test your queries against your actual database before deploying. For in-memory LINQ via Enumerable, this is never an issue.
Common pitfalls
Forgetting that both parameters are nullable. Unlike LeftJoin or RightJoin where only one side can be null, FullJoin's result selector receives TOuter? and TInner?. The compiler warns you, but it is easy to assume one side is always present if you are used to LeftJoin patterns. Handle both-null cases defensively.
Value types and default. When TOuter or TInner is a value type (or a tuple of value types), the "missing" element is default, not null. A missing int is 0, a missing bool is false. If you need to distinguish "matched with a zero value" from "unmatched," use nullable wrappers or switch to a reference type.
// Bad — cannot distinguish "no sale" from "zero quantity sale"
products.FullJoin(sales, p => p.Sku, s => s.Sku,
(p, s) => new { Sku = p?.Sku, Quantity = s.Quantity }); // s might be default(Sale)
// Good — check nullability explicitly
products.FullJoin(sales, p => p.Sku, s => s.Sku,
(p, s) => new { Sku = p?.Sku ?? s?.Sku, Quantity = s?.Quantity });
Assuming ordering. FullJoin does not guarantee a specific output order. Matched pairs appear as the outer sequence is iterated, but unmatched inner elements are appended at the end. If you need sorted output, chain .OrderBy() after the join.
Duplicate keys. When multiple elements on one side share the same key, FullJoin produces a cross-product of the matching groups -- the same behaviour as Join. If you have five products and three sales for the same SKU, you get fifteen result rows. Use GroupJoin followed by aggregation if you need to collapse duplicates.
Mixing LeftJoin + RightJoin instead of FullJoin. A common pre-.NET-11 workaround was to Concat a LeftJoin with a filtered RightJoin. This is both slower (it hashes the inner sequence twice) and error-prone (deduplication is manual). Use FullJoin directly.
Summary
Enumerable.FullJoinadds first-class full outer join support to LINQ, available onEnumerable,Queryable, andAsyncEnumerable.- Tuple-returning overloads across
Join,LeftJoin,RightJoin,FullJoin, andGroupJoineliminate result selector boilerplate. - Nullable tuple elements encode join semantics at the type level: the compiler enforces which sides can be absent.
- The inner sequence is buffered; place the smaller collection there when memory matters.
- EF Core translation for
FullJoinmay lag behind the LINQ-to-Objects implementation -- verify against your provider before deploying. - These APIs ship in .NET 11 Preview 5 and are expected in the final release in November 2026.