← Open in the full interactive course (progress tracking, search & more)

Two separate lists, one shared key — Join is how LINQ stitches them back together.

Real data rarely lives in one flat list. Orders live in one collection; the customers who placed them live in another. A report that needs "customer name next to their order total" has to connect two separate sequences using a key they both share — exactly the same problem a relational database's JOIN clause solves, except here both sequences are already sitting in memory as plain C# collections.

This lesson covers Join (an inner join — only matched pairs survive) and GroupJoin (grouping each outer item with all its matches, including zero matches — the shape a left-outer-join needs), matching two in-memory sequences by key, with a real orders-and-customers example.

What Is It?

The Simple Explanation

Join takes two sequences and a key from each, and produces one output item for every pair where the keys match — exactly like a database inner join. If an item on either side has no match, it simply doesn't appear in the result at all. GroupJoin instead keeps every item from the first ("outer") sequence and attaches all its matches from the second — even if that means an empty group for an outer item with zero matches.

The Technical Definition

public static IEnumerable<TResult> Join<TOuter, TInner, TKey, TResult>( this IEnumerable<TOuter> outer, IEnumerable<TInner> inner, Func<TOuter, TKey> outerKeySelector, Func<TInner, TKey> innerKeySelector, Func<TOuter, TInner, TResult> resultSelector) public static IEnumerable<TResult> GroupJoin<TOuter, TInner, TKey, TResult>( this IEnumerable<TOuter> outer, IEnumerable<TInner> inner, Func<TOuter, TKey> outerKeySelector, Func<TInner, TKey> innerKeySelector, Func<TOuter, IEnumerable<TInner>, TResult> resultSelector)

Both methods take four things: the second ("inner") sequence to join against, a function to pull the key out of each outer item, a function to pull the (comparable) key out of each inner item, and a result selector describing what to produce for each match. The only real difference is the shape of that result selector: Join's gives you one matched inner item at a time (TOuter, TInner); GroupJoin's gives you the outer item alongside all of its matches as a sequence (TOuter, IEnumerable<TInner>) — which is exactly what a bucket from the previous lesson's GroupBy looks like.

Why Does It Exist?

Without Join, matching two sequences by hand usually means a nested loop — check every inner item against every outer item:

// Manual nested-loop join — O(n × m) comparisons var matched = new List<(Order, Customer)>(); foreach (var order in orders) { foreach (var customer in customers) { if (order.CustomerId == customer.Id) matched.Add((order, customer)); } }

Join replaces this with a single, declarative expression:

// Join var matched = orders.Join( customers, order => order.CustomerId, customer => customer.Id, (order, customer) => (order, customer));

Beyond readability, the nested-loop version is genuinely inefficient — for n orders and m customers, it does up to n × m comparisons. As covered in Under the Hood, Join avoids that entirely by building an internal lookup structure, so it scales far better than a naive nested loop as either sequence grows.

Big Picture

Join vs GroupJoin — WHAT SURVIVES AN UNMATCHED ITEM
Join — inner join
Customers: [Ana, Ben, Cara, Dev]
Orders: [Ana×2, Ben×1]

Result: [Ana+order, Ana+order, Ben+order]
Cara and Dev vanish — zero matches, zero output
GroupJoin — every outer item kept
Customers: [Ana, Ben, Cara, Dev]
Orders: [Ana×2, Ben×1]

Result: [Ana→[2 orders], Ben→[1 order], Cara→[], Dev→[]]
Cara and Dev still appear, with empty order groups

How It Works

Join, STEP BY STEP
1. INDEX THE INNER SEQUENCE BY KEY
2. FOR EACH OUTER ITEM, PROBE THE LOOKUP
3. YIELD ONE RESULT PER MATCHING PAIR

Simple Example

public record Customer(int Id, string Name, string City); public record Order(int Id, int CustomerId, DateOnly OrderDate, decimal Total); List<Customer> customers = [ new(1, "Ana Cole", "Seattle"), new(2, "Ben Diaz", "Austin"), new(3, "Cara Lopez", "Seattle"), new(4, "Dev Patel", "Austin"), // has no orders at all ]; List<Order> orders = [ new(101, 1, new(2026, 1, 5), 120.50m), new(102, 2, new(2026, 1, 18), 75.00m), new(103, 1, new(2026, 2, 2), 200.00m), new(104, 3, new(2026, 2, 14), 50.25m), ]; // ─── Join — inner join, one row per matched (order, customer) pair ─── var orderDetails = orders.Join( customers, order => order.CustomerId, customer => customer.Id, (order, customer) => new { customer.Name, order.OrderDate, order.Total }); foreach (var d in orderDetails) Console.WriteLine($"{d.Name} — {d.OrderDate} — ${d.Total}"); // Ana Cole — 2026-01-05 — $120.50 // Ben Diaz — 2026-01-18 — $75.00 // Ana Cole — 2026-02-02 — $200.00 // Cara Lopez — 2026-02-14 — $50.25 // (Dev Patel never appears — no orders reference CustomerId 4)

Code → Meaning → Result: Every order finds exactly one matching customer, so four orders produce four result rows. Dev Patel, who has zero orders, simply never appears anywhere in the output — that's the defining trait of an inner join.

GroupJoin — Keeping Every Outer Item, Even With Zero Matches

// ─── GroupJoin — every customer kept, with their group of matching orders ─── var customerOrderGroups = customers.GroupJoin( orders, customer => customer.Id, order => order.CustomerId, (customer, matchedOrders) => new { customer.Name, Orders = matchedOrders }); foreach (var g in customerOrderGroups) Console.WriteLine($"{g.Name}: {g.Orders.Count()} order(s)"); // Ana Cole: 2 order(s) // Ben Diaz: 1 order(s) // Cara Lopez: 1 order(s) // Dev Patel: 0 order(s) ← still present, with an empty group

Notice Dev Patel does appear this time, with an empty Orders group — GroupJoin never drops an outer item, no matter how many (or how few) matches it has. This is the LINQ building block a true left-outer-join is built from: flattening that result with SelectMany and DefaultIfEmpty() produces one row per customer even when they have no orders, with null (or a default) standing in for the missing order — a pattern worth recognizing when you eventually meet EF Core's own left-join support.

Real-World Example

An order-summary report needs each order shown alongside its customer's name and city — classic Join territory — while a separate "customer activity" report needs every customer listed, including ones who haven't ordered yet, which is exactly what GroupJoin is for:

// Order summary report — inner join, only orders that have a valid customer var orderSummary = orders .Join(customers, o => o.CustomerId, c => c.Id, (o, c) => new { c.Name, c.City, o.OrderDate, o.Total }) .OrderByDescending(x => x.OrderDate); foreach (var row in orderSummary) Console.WriteLine($"{row.OrderDate} | {row.Name,-12} | {row.City,-8} | ${row.Total}"); // Customer activity report — every customer, even those with zero orders var activity = customers.GroupJoin( orders, c => c.Id, o => o.CustomerId, (c, custOrders) => new { c.Name, OrderCount = custOrders.Count(), TotalSpent = custOrders.Sum(o => o.Total) }); foreach (var a in activity) Console.WriteLine($"{a.Name}: {a.OrderCount} orders, ${a.TotalSpent} total"); // Ana Cole: 2 orders, $320.50 total // Ben Diaz: 1 orders, $75.00 total // Cara Lopez: 1 orders, $50.25 total // Dev Patel: 0 orders, $0.00 total

Both reports join the exact same two lists by the exact same key — the only thing that changes is which operator you reach for, based on whether unmatched outer items (customers with no orders) should disappear (Join) or still be represented (GroupJoin).

Analogy

Matching Claim Tickets

Think of a coat-check counter: every coat has a claim ticket number, and every visitor holds a matching ticket stub. Join is the attendant walking down the rack, handing back a coat only when a matching stub is presented — if a coat has no matching stub anywhere, it just stays on the rack, invisible to the output. GroupJoin is instead the end-of-night inventory: every visitor is accounted for on the list, with their coat attached if they have one, or an empty entry if they never checked one in at all.

Under the Hood

WHY Join OUTPERFORMS A NESTED LOOP
1. THE INNER SEQUENCE IS BUILT INTO A LOOKUP FIRST
2. OUTER ITEMS PROBE THE LOOKUP, NOT THE RAW SEQUENCE
3. THE PAYOFF: ROUGHLY O(n + m) INSTEAD OF O(n × m)

Common Confusion

1. Join vs GroupJoin — "drops unmatched" vs "keeps everything, grouped"

This is the single most important distinction in this lesson. Join flattens matches into individual pairs and silently drops any outer item with zero matches. GroupJoin keeps every outer item exactly once, attaching a (possibly empty) group of matches to each. Reach for Join when unmatched items are irrelevant to your result; reach for GroupJoin when "customers with no orders yet" is itself meaningful information you need to see.

2. LINQ's Join is an equi-join only

Unlike raw SQL, where a JOIN ... ON clause can express any comparison (>, <=, ranges), LINQ's Join and GroupJoin only support matching on equality of keys — an "equi-join." For more exotic matching conditions, you'd fall back to a nested query with Where instead, accepting the performance trade-off discussed above.

Common Mistakes

Mistake 1 — Reaching for a nested loop (or nested Where) instead of Join

orders.Select(o => customers.First(c => c.Id == o.CustomerId)) re-scans the entire customers list for every single order — exactly the O(n × m) cost Join was built to avoid. Use Join whenever you're matching two sequences by a shared key; it builds the lookup once instead of scanning repeatedly.

Mistake 2 — Using Join when you actually needed every outer item represented

Using Join for a "customers and their order counts" report silently loses every customer who hasn't ordered yet — they simply never appear, which is easy to miss until someone asks "where did Dev Patel go?" Use GroupJoin whenever unmatched outer items still need to appear in the result, even with an empty group.

When Should I Use It?

Use Join when

Use GroupJoin when

Mental Model

Join = "give me one row per matching pair — no match, no row"
GroupJoin = "give me every outer item, each with its group of matches, even an empty one"

Remember:
· Join is an inner join; unmatched items on either side vanish.
· GroupJoin keeps every outer item exactly once, matched or not.
· LINQ's Join and GroupJoin only match on key equality, not arbitrary comparisons.
· Under the hood, both build a lookup once instead of scanning repeatedly — far cheaper than a nested loop.

Key Takeaway


Check Your Understanding

You've learned how to match two sequences with Join and GroupJoin. Let's check your understanding.

1. A customer named Dev Patel has zero orders. What happens to Dev Patel when you run customers.Join(orders, c => c.Id, o => o.CustomerId, (c, o) => ...)?

Show answer

Correct: B

Why B is correct: Join is an inner join — it only produces output for matching pairs. An outer item with zero matches contributes zero rows to the result, exactly as shown with Dev Patel in the Simple Example.

Why A is incorrect: Join never produces a row with a placeholder null for a missing match — it simply produces no row at all for that outer item.

Why C is incorrect: Zero matches is not an error condition — Join handles it silently by contributing no output for that item.

Why D is incorrect: That's the behavior of GroupJoin, not JoinJoin's result selector never receives a group, only one matched inner item at a time.

Reinforcement: If you need unmatched outer items to still show up, that's exactly the scenario GroupJoin exists for.

2. What does the result selector of GroupJoin receive for each outer item, that Join's result selector does not?

Show answer

Correct: C

Why C is correct: As shown in the Technical Definition and the customer-activity example, GroupJoin's result selector receives the outer item and an IEnumerable<TInner> of everything that matched it — empty when there were no matches at all.

Why A is incorrect: That's exactly what distinguishes Join's result selector — GroupJoin's is fundamentally different, receiving a group instead of a single item.

Why B is incorrect: The inner sequence handed to the result selector is filtered down to only the items matching this specific outer item's key — not the entire original inner sequence.

Why D is incorrect: GroupJoin gives you the actual matches (or lack thereof) as a sequence, not merely a yes/no flag — you can still call .Any() or .Count() on it yourself if that's all you need.

Reinforcement: "One item vs. a group of items" is the core difference between Join's and GroupJoin's result selectors.

3. Why is orders.Join(customers, ...) generally faster than orders.Select(o => customers.First(c => c.Id == o.CustomerId)) for large sequences?

Show answer

Correct: B

Why B is correct: As explained in Under the Hood, Join builds an internal lookup on the inner sequence exactly once, turning what would otherwise be an O(n × m) nested scan into roughly O(n + m) work.

Why A is incorrect: The difference is real and grows significantly as either sequence gets larger — this is precisely why Join exists rather than everyone just writing nested loops.

Why C is incorrect: Neither approach involves any automatic parallelism — the performance difference comes purely from avoiding redundant scanning, not from using multiple cores.

Why D is incorrect: First itself works correctly — the problem is calling it repeatedly inside a loop over the full customers list for every single order, which is a usage pattern issue, not a flaw in First.

Reinforcement: Building an index once and probing it repeatedly is dramatically cheaper than re-scanning a collection from scratch for every item.

4. You need a report listing every customer along with their total number of orders, including customers who have placed none. Which operator fits this requirement?

Show answer

Correct: B

Why B is correct: This is exactly the customer-activity scenario from the Real-World Example — every customer must appear, with a (possibly zero) order count, which is precisely what GroupJoin guarantees.

Why A is incorrect: Join is an inner join — it drops any customer with zero matching orders, the opposite of what's required here.

Why C is incorrect: Neither operator matches two separate sequences by key on its own — this pattern doesn't solve the cross-sequence matching problem at all.

Why D is incorrect: Grouping orders alone would completely omit any customer who has placed zero orders, since they'd never appear in the orders sequence to begin with.

Reinforcement: "Every outer item must be represented, matched or not" is the signal to reach for GroupJoin over Join.

You can now match related sequences together confidently. Next up: collapsing a sequence down to a single meaningful value with aggregation.


dotnetmadeeasy.com — Learn C# and .NET, the right way.