# LINQ FullJoin in .NET 11: Find Missing Rows on Both Sides

> A left join from the warehouse misses items listed only on the storefront. Use LINQ FullJoin in .NET 11 to compare both inventories and keep missing rows separate from zero stock.

Published: 2026-11-13. Author: Milan Jovanović.

Canonical: https://milanjovanovic.tech/blog/linq-fulljoin-dotnet-11

.NET 11 adds `Enumerable.FullJoin`, a full outer join for LINQ to Objects.
It pairs elements with equal keys and keeps every element that has no match on the other side.
In a stock report, that lets you distinguish a product with zero units from one that's missing from the warehouse inventory.

## Which Rows Would a Left Join Miss?

Say a warehouse sends a stock file and the storefront exports its catalog.
SKU `A100` appears in both, with different quantities.

The warehouse lists `B200` with zero units.
The storefront still offers six units of `C300`, which the warehouse doesn't list at all.

A left join starting from the warehouse tells you about `A100` and `B200`.
It drops `C300`.
Starting from the storefront picks up `C300` but drops `B200`.

The [**left and right joins introduced in .NET 10**](https://milanjovanovic.tech/blog/whats-new-in-ef-core-10-leftjoin-and-rightjoin-operators-in-linq) work when one side lists every SKU you care about.

With these snapshots, `FullJoin` returns **four pairs**, including `D400`, whose quantities agree.
For `B200`, zero is the count in the warehouse.
The `null` on the storefront side means there is no row to compare it with.

![FullJoin returns B200 (0, null) from the warehouse only, A100 (8, 10) and D400 (5, 5) from both, and C300 (null, 6) from the storefront only.](https://milanjovanovic.tech/blogs/articles/linq-fulljoin-dotnet-11/full-join-rows.png)

## Reconcile Two Snapshots With FullJoin

Save this as `StockDiff.cs` and run `dotnet run --file StockDiff.cs` with the .NET 11 SDK.
For an existing project, [**target .NET 11**](https://milanjovanovic.tech/blog/whats-new-dotnet-11) to use the new LINQ method.
Each source uses its own casing convention, so the call supplies an ordinal, case-insensitive key comparer.

```csharp
#!/usr/bin/env dotnet

Stock[] warehouse = [new("A100", 8), new("B200", 0), new("D400", 5)];
Stock[] storefront = [new("a100", 10), new("C300", 6), new("D400", 5)];

var pairs = warehouse.FullJoin(
    storefront,
    item => item.Sku,
    item => item.Sku,
    StringComparer.OrdinalIgnoreCase);

foreach (var (stored, listed) in pairs)
{
    if (stored is null)
    {
        Console.WriteLine($"{listed!.Sku}: storefront only ({listed.Units})");
    }
    else if (listed is null)
    {
        Console.WriteLine($"{stored.Sku}: warehouse only ({stored.Units})");
    }
    else if (stored.Units != listed.Units)
    {
        Console.WriteLine($"{stored.Sku}: warehouse {stored.Units}, storefront {listed.Units}");
    }
}

sealed record Stock(string Sku, int Units);
```

The join returned four pairs, but the loop prints only the three discrepancies.
Both sides report five units for `D400`, so it produces no output.

```text
A100: warehouse 8, storefront 10
B200: warehouse only (0)
C300: storefront only (6)
```

The storefront value in the `B200` pair is `null` because `Stock` is a record class.
The warehouse value is a `Stock` whose `Units` is zero.

The [**tuple-returning overload of FullJoin**](https://learn.microsoft.com/en-us/dotnet/api/system.linq.enumerable.fulljoin?view=net-11.0) fills the unmatched side with its type's default value.
If you join two sequences of integers, you get zero for a missing reading as well as a measured zero.

## What Happens With Duplicate or Null Keys?

Suppose `A100` appears twice in the warehouse file because the export has one row per bin.
If the storefront also lists `A100` three times, one per sales channel, `FullJoin` emits **six** matched pairs.
A later `Sum` over warehouse units would count each bin three times.

For a report with one row per SKU, sum the warehouse bins before joining.
On the storefront side, sum the channels only if each reports its own allocation.
If each channel reports the same shared stock, adding those numbers would overstate the quantity available to sell.

If the file is supposed to have unique SKUs, reject duplicate keys before comparing quantities.

A missing **key** is different from a missing **row**.
The [**LINQ to Objects implementation**](https://github.com/dotnet/runtime/blob/v11.0.0/src/libraries/System.Linq/src/System/Linq/FullJoin.cs) returns two unmatched rows when both SKUs are null, even if their quantities agree.
Report those rows separately if you have no other identifier to match them on.

## How Much Does FullJoin Keep in Memory?

Assigning the query to `pairs` doesn't enumerate either sequence.
When the loop starts and both inputs contain rows, `FullJoin` builds a lookup over the second sequence.
It tracks which groups matched so it can return the remaining storefront rows at the end.

If the storefront export is several gigabytes, reading it a line at a time still leaves its rows in the lookup.
If the warehouse export is smaller, pass it as the second sequence and swap the labels in the report.

## Can EF Core 11 Run FullJoin in the Database?

Yes, [**EF Core 11 translates FullJoin**](https://learn.microsoft.com/en-us/ef/core/what-is-new/ef-core-11.0/whatsnew#support-for-the-new-net-11-fulljoin-operator) on relational providers that support a SQL full join.
EF Core 11.0.0's SQLite provider couldn't translate the tuple-returning overload used above.
Give it a result selector that projects the warehouse and storefront rows.

Given two entity sets named `Warehouse` and `Storefront`, each with a `Sku` key and a `Units` column, you can inspect the translation without executing the query.

```csharp
var results = db.Warehouse.FullJoin(
    db.Storefront,
    stored => stored.Sku,
    listed => listed.Sku,
    (stored, listed) => new { Stored = stored, Listed = listed });

Console.WriteLine(results.ToQueryString());
```

With EF Core 11.0.0 and SQLite, that produces the following SQL.

```sql
SELECT "w"."Sku", "w"."Units", "s"."Sku", "s"."Units"
FROM "Warehouse" AS "w"
FULL JOIN "Storefront" AS "s" ON "w"."Sku" = "s"."Sku"
```

The SQL compares the two `Sku` columns directly.
The column collation decides whether their casing matters.
If you want `A100` to match `a100` as it did in memory, use a case-insensitive collation or normalize both keys on import.

For a query that selects the row with the highest or lowest value, the [**EF Core 11 query guide**](https://milanjovanovic.tech/blog/ef-core-11-new-features) compares the SQL and its execution plan before and after adding an index.

## Summary

Keep `B200` in the regression test and assert that the report contains `warehouse only (0)`.
If someone later substitutes zero for a missing storefront count, both sides will appear to agree and that discrepancy will disappear.

## Frequently asked questions

### How do you write a full outer join in LINQ in .NET 11?

Call FullJoin on the first sequence and pass the second sequence and a key selector for each side. The tuple contains an element from each side. For an unmatched row, the missing element takes the default value for its type. A separate overload takes a result selector.

### What happens when FullJoin sees duplicate keys?

Each left row pairs with every right row that has the same key. Two rows on the left and three on the right produce six pairs for that key. Group or validate the input first when your report requires one row per key.

### Does LINQ FullJoin match null keys?

Enumerable.FullJoin in .NET 11 preserves elements with null keys as unmatched rows. A null key on the left and a null key on the right produce two unmatched rows.

### Can EF Core 11 translate FullJoin to SQL?

EF Core 11 supports translating Queryable.FullJoin to a SQL full join on relational providers that support the operation. Inspect the query with ToQueryString and test it on your database.
