If you have ever written a left outer join in LINQ, you know the ritual. GroupJoin, then SelectMany, then DefaultIfEmpty, each nested inside the other like some kind of functional matryoshka doll. It works. It produces the correct SQL. But it is genuinely painful to read, worse to explain to a junior developer, and the sort of code that makes you question whether LINQ was designed by people who had never heard of LEFT JOIN.

.NET 10 fixes this. Enumerable.LeftJoin and Enumerable.RightJoin now exist in System.Linq, and EF Core 10 translates them to the SQL you would expect. The old incantation still works, but there is no longer any reason to use it.

The problem with the old approach

Consider a straightforward scenario: you have orders and you want to include shipping information, but not every order has been shipped yet. In SQL, this is a one-liner — LEFT JOIN. In LINQ before .NET 10, it looked like this:

Queries/OrderQueries.cs
// Bad — the pre-.NET 10 way to express a left outer join
var ordersWithShipping = dbContext.Orders
    .GroupJoin(
        dbContext.ShipmentDetails,
        order => order.Id,
        shipment => shipment.OrderId,
        (order, shipments) => new { order, shipments })
    .SelectMany(
        grouped => grouped.shipments.DefaultIfEmpty(),
        (grouped, shipment) => new
        {
            grouped.order.Id,
            grouped.order.CustomerName,
            grouped.order.Total,
            TrackingNumber = shipment != null ? shipment.TrackingNumber : null,
            ShippedDate = shipment != null ? shipment.ShippedDate : (DateTime?)null
        });

Three methods chained together to express a concept that every SQL developer learns in their first week. The GroupJoin correlates the two sequences, SelectMany flattens the grouped results, and DefaultIfEmpty ensures unmatched outer elements are preserved with null on the inner side. It is technically correct, but the intent — "give me all orders, with shipping details if they exist" — is buried under ceremony.

The cognitive load compounds when you need multiple left joins. Each one adds another layer of GroupJoin-SelectMany-DefaultIfEmpty, and the resulting code quickly becomes the sort of thing developers copy-paste rather than write from understanding.

The new LeftJoin operator

.NET 10 adds LeftJoin as a first-class method on both Enumerable and Queryable. The same query now looks like this:

Queries/OrderQueries.cs
// Good — .NET 10 LeftJoin
var ordersWithShipping = dbContext.Orders
    .LeftJoin(
        dbContext.ShipmentDetails,
        order => order.Id,
        shipment => shipment.OrderId,
        (order, shipment) => new
        {
            order.Id,
            order.CustomerName,
            order.Total,
            TrackingNumber = shipment != null ? shipment.TrackingNumber : null,
            ShippedDate = shipment != null ? shipment.ShippedDate : (DateTime?)null
        });

One method call. The parameters mirror Join exactly — outer sequence, inner sequence, outer key selector, inner key selector, result selector — with the only difference being that the TInner parameter in the result selector is nullable. When there is no matching inner element, it arrives as null.

The method signature on Enumerable is:

Example.cs
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);

There is also an overload that accepts an IEqualityComparer<TKey> for custom key comparison. The Queryable variants follow the same pattern, using Expression<Func<...>> parameters as you would expect.

What EF Core 10 generates

When you use LeftJoin against a DbContext, EF Core 10 translates it directly to a SQL LEFT JOIN. There is no intermediate grouping or flattening — the generated SQL is identical to what you would write by hand:

query.sql
SELECT o."Id", o."CustomerName", o."Total",
       s."TrackingNumber", s."ShippedDate"
FROM "Orders" AS o
LEFT JOIN "ShipmentDetails" AS s ON o."Id" = s."OrderId"

This is exactly the same SQL that the old GroupJoin-SelectMany-DefaultIfEmpty pattern produced. The improvement is entirely on the C# side — clearer intent, less nesting, fewer things to get wrong.

RightJoin: the mirror image

RightJoin works identically but preserves all elements from the inner (right) sequence instead:

Queries/InventoryQueries.cs
var warehouseStock = dbContext.WarehouseSlots
    .RightJoin(
        dbContext.Products,
        slot => slot.ProductId,
        product => product.Id,
        (slot, product) => new
        {
            product.Name,
            product.Sku,
            QuantityInStock = slot != null ? slot.Quantity : 0,
            Location = slot?.Aisle
        });

This translates to RIGHT JOIN in SQL. Every product appears in the results, even if no warehouse slot is assigned to it. The slot parameter is nullable, so unmatched rows arrive with null values on the left side.

In practice, RightJoin is used far less than LeftJoin — you can always rewrite a right join as a left join by swapping the two sequences. But having both available means you can express the query in whichever direction reads most naturally for your domain.

Using LeftJoin with LINQ to Objects

These operators are not limited to EF Core. They live in System.Linq, so they work with any IEnumerable<T>:

Services/ReportBuilder.cs
var employees = GetAllEmployees();
var recentReviews = GetReviewsFromLastQuarter();

var employeeReviewStatus = employees.LeftJoin(
    recentReviews,
    employee => employee.Id,
    review => review.EmployeeId,
    (employee, review) => new
    {
        employee.Name,
        employee.Department,
        HasRecentReview = review != null,
        ReviewScore = review?.Score
    });

The in-memory implementation uses deferred execution, just like Join. It builds a hash table from the inner sequence and probes it with each outer element — the same strategy as GroupJoin internally.

Chaining multiple left joins

Real queries often involve more than two tables. With the old approach, chaining left joins was nightmarish. With LeftJoin, each additional join is simply another method call:

Queries/CustomerQueries.cs
var customerOverview = dbContext.Customers
    .LeftJoin(
        dbContext.Orders,
        customer => customer.Id,
        order => order.CustomerId,
        (customer, order) => new { customer, order })
    .LeftJoin(
        dbContext.ShipmentDetails,
        co => co.order != null ? co.order.Id : -1,
        shipment => shipment.OrderId,
        (co, shipment) => new
        {
            co.customer.Name,
            OrderId = co.order != null ? co.order.Id : (int?)null,
            OrderTotal = co.order != null ? co.order.Total : (decimal?)null,
            shipment?.TrackingNumber
        });

Each join is self-contained and reads top-to-bottom. Compare that to nesting three levels of GroupJoin-SelectMany-DefaultIfEmpty, and the readability benefit is immediately clear.

// TIP

When chaining multiple left joins, use an anonymous type to carry forward the results from each stage. This keeps the intermediate shape explicit and avoids losing track of which properties come from which table.

What about query syntax?

C# query syntax (from ... join ... select ...) does not yet support left join or right join keywords. The existing join keyword maps to an inner join, and there is no left join equivalent planned for C# 14.

If you prefer query syntax, you are still stuck with the old into-DefaultIfEmpty pattern:

Example.cs
// Query syntax — still requires the old workaround
var query =
    from order in dbContext.Orders
    join shipment in dbContext.ShipmentDetails
        on order.Id equals shipment.OrderId into shipmentGroup
    from shipment in shipmentGroup.DefaultIfEmpty()
    select new { order.Id, shipment?.TrackingNumber };

For left and right joins specifically, method syntax with the new operators is now strictly cleaner. This might shift more developers towards method syntax for these queries — or it might prompt a future C# language proposal to add left join to query syntax. For now, method syntax is the way to go.

Common pitfalls

Forgetting null checks on the inner element. The TInner parameter in the result selector is nullable. If you access properties on it without a null check, you will get a NullReferenceException at runtime (for LINQ to Objects) or incorrect results in EF Core. Always use null-conditional operators or explicit null checks:

Example.cs
// Bad — will throw for unmatched rows in LINQ to Objects
(order, shipment) => new { TrackingNumber = shipment.TrackingNumber }

// Good — handles the null case
(order, shipment) => new { TrackingNumber = shipment?.TrackingNumber }

Assuming RightJoin adds value over a swapped LeftJoin. a.RightJoin(b, ...) is semantically identical to b.LeftJoin(a, ...). Use whichever reads more naturally, but do not introduce RightJoin if it makes the query harder to follow. In most codebases, consistently using LeftJoin and swapping the sequences is more predictable.

Using LeftJoin when you actually want an inner join. If every outer element is guaranteed to have a match, or you only want matched rows, use Join instead. LeftJoin adds overhead for null handling that you do not need.

Mixing the old GroupJoin pattern with LeftJoin in the same codebase. If you are migrating, pick one approach and apply it consistently. A codebase that uses GroupJoin-DefaultIfEmpty in some queries and LeftJoin in others is harder to maintain than one that uses either consistently. However, there is no urgency to migrate existing code — the old pattern still works and produces identical SQL.

Expecting query syntax support. As noted above, from x left join y is not valid C# syntax. If you try to mix LeftJoin into a query expression, it will not compile. Stick to method syntax when using the new operators.

Summary