LINQ in Practice · Lesson 21

Left joins preserve unmatched parent rows

Represent missing related data explicitly instead of dropping the parent.

How it works

An inner join includes only parents with matching related rows. A left join preserves every parent, which is often the correct contract for a customer report. Sam should remain visible even though no orders match Sam's identifier. Dropping that customer would make no activity look like no customer.

GroupJoin first associates each customer with a sequence of matching orders. SelectMany then flattens those sequences into report rows. DefaultIfEmpty is the crucial boundary: when a customer's matching sequence is empty, it contributes one default Order reference, represented here by null, instead of contributing no output row.

Ada produces two rows because two orders match. Sam produces a single none row because no order matches. The projection checks for null before reading the order total. A missing related entity is not an order with a zero total; keeping those cases distinct avoids inventing data while preserving report completeness.

Join cardinality matters. If customer identifiers are duplicated, or if several orders legitimately match one customer, the result can contain more rows than the parent sequence. That growth is expected for one-to-many relationships. Count the output and validate key uniqueness where required rather than assuming one parent always means one report row.

Practice by adding an order for Sam, then by removing all orders. Predict the number of output rows and which labels contain none in each case. Add a customer whose identifier appears twice and decide whether the application should reject that source data before joining.

Finally, create a summary containing each customer's order count and sum without flattening individual order rows. Compare that grouped result with this detail report. Choosing the output shape first makes the query easier to understand: an activity detail report and a one-row-per-customer summary require different projections even when they use the same relationship.

A working example

using System;
using System.Linq;
using System.Collections.Generic;
using System.Globalization;
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Threading;
using System.Threading.Tasks;
using System.Runtime.CompilerServices;

var customers = new[] { new Customer(1, "Ada"), new Customer(2, "Sam") };
var orders = new[] { new Order(1, 10), new Order(1, 20) };
var report = customers.GroupJoin(orders, c => c.Id, o => o.CustomerId,
    (customer, matches) => new { customer, matches })
    .SelectMany(row => row.matches.DefaultIfEmpty(),
        (row, order) => $"{row.customer.Name}:{(order is null ? "none" : order.Total.ToString(CultureInfo.InvariantCulture))}");
foreach (var line in report) Console.WriteLine(line);
record Customer(int Id, string Name);
record Order(int CustomerId, int Total);

Expected output

Ada:10
Ada:20
Sam:none
Try this
DefaultIfEmpty supplies a null related record for an unmatched parent.