TL;DR: In .NET 11, a.FullJoin(b, x => x.Key, y => y.Key) returns every matched pair plus the unmatched rows from both sides, as (Outer, Inner) tuples. Join, LeftJoin, RightJoin, and GroupJoin also get overloads with no result selector. .NET 11 is at Release Candidate 2 right now: go-live licensed, but not final yet.
The join you Google every time
You have two lists that should agree and never do. What the system thinks is in stock, and what the stocktake actually counted. You want three answers: what matches, what only the left side has, and what only the right side has.
SQL calls that a full outer join. LINQ never had one, so everybody pastes the same answer:
var left =
from s in system
join c in counted on s.Sku equals c.Sku into matches
from c in matches.DefaultIfEmpty()
select (Record: s, Count: c);
var rightOnly =
from c in counted
where !system.Any(s => s.Sku == c.Sku)
select (Record: (StockRecord?)null, Count: c);
var full = left.Concat(rightOnly);
It works. It is also two queries, a DefaultIfEmpty nobody can explain on the first read, and an Any inside a where, which scans the whole left list once for every row on the right. And the key comparison lives in two places, so the day someone changes one of them, the halves quietly disagree.
Meet FullJoin
.NET 10 already added LeftJoin and RightJoin. .NET 11 closes the set with FullJoin, and it returns tuples, so you do not even need a result selector:
var products = new List<(int Id, string Name, string? Category)>
{
(1, "Laptop", "Electronics"),
(2, "Mouse", "Electronics"),
(3, "Orphan", null),
};
var categories = new List<(string Name, string Description)>
{
("Electronics", "Electronic devices"),
("Furniture", "Office furniture"),
};
foreach (var pair in products.FullJoin(categories, p => p.Category, c => c.Name))
Console.WriteLine($"{pair.Outer.Name ?? "(none)"} | {pair.Inner.Description ?? "(none)"}");
Laptop | Electronic devices
Mouse | Electronic devices
Orphan | (none)
(none) | Office furniture
The tuple elements are named Outer and Inner, and the side with no match is default. For reference types, that means null, which is exactly what you want to pattern match on. Here the elements are themselves tuples, so a missing side is an empty tuple whose Name is null; gotcha 1 explains why that deserves a second look.
The same release gives the old operators a selector-free overload, too. products.Join(categories, p => p.Category, c => c.Name) now yields (Outer, Inner) pairs, so (p, c) => new { p, c } can finally retire.
A realistic one: the stocktake
Here is the reconciliation from the start, done properly. The counted SKUs were typed by a human, so one of them is lowercase.
List<StockRecord> system =
[
new("SKU-100", "USB-C cable", 40),
new("SKU-200", "Desk lamp", 12),
new("SKU-300", "Monitor arm", 5),
];
List<ShelfCount> counted =
[
new("sku-100", 38),
new("SKU-300", 5),
new("SKU-999", 3),
];
var report = system.FullJoin(
counted,
s => s.Sku,
c => c.Sku,
StringComparer.OrdinalIgnoreCase);
foreach (var (record, count) in report)
{
Console.WriteLine((record, count) switch
{
(null, { } c) => $"{c.Sku}: on the shelf, not in the system ({c.Quantity})",
({ } r, null) => $"{r.Sku}: in the system, not on the shelf ({r.Quantity} missing)",
({ } r, { } c) when r.Quantity != c.Quantity => $"{r.Sku}: off by {c.Quantity - r.Quantity}",
({ } r, _) => $"{r.Sku}: OK",
(null, null) => "nothing on either side, which FullJoin never yields",
});
}
record StockRecord(string Sku, string Name, int Quantity);
record ShelfCount(string Sku, int Quantity);
SKU-100: off by -2
SKU-200: in the system, not on the shelf (12 missing)
SKU-300: OK
SKU-999: on the shelf, not in the system (3)
One call, one comparer, and a switch that reads like the spec. The comparer is a plain optional parameter, which the query-syntax join ... equals never let you pass at all. It built with zero warnings, nullable checks included.
Can I use it?
-
FullJoinand the tuple-returning overloads are new in .NET 11. The .NET 11 Preview 5 announcement listed "LINQ adds full outer joins", and the release notes cover both additions. - They exist on
Enumerable,Queryable, andAsyncEnumerable. Existing onQueryabledoes not mean every provider translates them, though: see gotcha 6 for EF Core. - .NET 11 RC2 shipped on October 13, 2026 with a go-live support license. It is still a release candidate.
- On the .NET 10 SDK, the call fails with
CS1061: 'int[]' does not contain a definition for 'FullJoin'.
I ran every snippet here as a file-based app with dotnet run file.cs on SDK 11.0.100-rc.1, on Windows. The classic two-query version ran on the .NET 10 SDK, with the SKUs in matching case, and paired up the same four rows.
Gotchas
1. Value types hide the missing side. default for an int is 0, not "nothing". A full join of the int arrays [0, 5] and [0, 7] on the values themselves printed (0,0), (5,0), (0,7), and you cannot tell the real match from the two misses. With int?[] the same data printed (0,0), (5,null), (null,7). If your elements are structs or tuples, make them nullable or check a field that is never empty.
2. Null keys never match. Joining ["a", null] with [null, "b"] gave four rows, and the two null keys came out as separate unmatched rows instead of a pair. That is the same rule Join has always had, so this is consistency, not a bug.
3. Duplicate keys multiply. [1, 1, 2] joined with [1, 1, 3] produced four (1,1) rows, then (2,0) and (0,3). Two times two is still four, even in a full join.
4. Unmatched right rows come last. Joining ["x", "a", "y"] with ["b", "a", "c"] gave (x,-), (a,a), (y,-), (-,b), (-,c). Left rows keep their order, and the right-only rows are appended at the end in their own order. If your report needs a different order, sort it.
5. GroupJoin's new overload is not a tuple. The selector-free GroupJoin returns IGrouping<TOuter, TInner>, so the outer element is g.Key. Grouping the products from the first example under their categories gave Electronics: 2 [Laptop, Mouse] and Furniture: 0 [].
6. EF Core wants a result selector, and no comparer. Added after a reader asked about EF Core in the comments. EF Core 11 translates FullJoin to a real FULL JOIN on relational providers, but only the overload with a result selector. On EF Core 11 RC1, with SQL Server LocalDB and SQLite, this worked:
var rows = db.SystemRecords.FullJoin(
db.ShelfCounts,
s => s.Sku,
c => c.Sku,
(s, c) => new { S = s.Sku, C = c.Sku });
SELECT [s].[Sku] AS [S], [s0].[Sku] AS [C]
FROM [SystemRecords] AS [s]
FULL JOIN [ShelfCounts] AS [s0] ON [s].[Sku] = [s0].[Sku]
The selector-free tuple overload from the top of this post threw "could not be translated" on every provider I tried, SQL Server included. Passing StringComparer.OrdinalIgnoreCase threw as well; in SQL, the column collation does that job. That cuts both ways: with sku-100 against SKU-100, SQL Server's default case-insensitive collation matched them, while SQLite returned two unmatched rows. The InMemory provider rejected both shapes, so a query that passes against SQL Server can still throw in an InMemory test. Cosmos rejected them too, checked with ToQueryString() only, since no emulator was running.
Wrap-up
The full outer join was the last SQL join that LINQ made you build by hand. Now it is one call that tells you what matches, what is missing, and what showed up uninvited. Go find that DefaultIfEmpty().Concat(...) in your codebase and see whether it survives .NET 11.
Sources
- What's new in .NET libraries for .NET 11: LINQ join improvements (Microsoft Learn)
- Enumerable.FullJoin method (Microsoft Learn)
- Enumerable.GroupJoin method (Microsoft Learn)
- .NET 11 Preview 5 libraries release notes (GitHub, dotnet/core)
- .NET 11 Preview 5 is now available (.NET Blog)
- Announcing .NET 11 Release Candidate 2 (.NET Blog)
- Add FULL OUTER JOIN support (GitHub, dotnet/efcore#38340)
This article was written with the help of AI and checked against the sources above.
Top comments (2)
The EF side is worth a line too: dotnet/efcore PR #38340 (merged in June) translates Queryable.FullJoin to a real FULL JOIN on relational providers, but InMemory and Cosmos report it as untranslatable. So the same query can pass against SQL Server and throw in an InMemory test. Did you try it on a DbSet? I haven't yet.
Updated the post with a gotcha for this, thanks