A join takes two sequences and pairs up elements that share a common key. In an e-commerce app, you almost always have data split across collections (customers in one list, their orders in another) and you need to combine them by customer ID. LINQ gives you two join operators (Join and GroupJoin), each available in method syntax and query syntax, with patterns built on top of them for left joins and multi-key joins. This lesson covers all of those, the limitations LINQ to Objects has compared to SQL, and the hash-based algorithm that makes joins fast.
LINQ to Objects ships with exactly two join operators: Join and GroupJoin. Everything else (left join, multi-key join, full join) is built on top of these two.
| Operator | Shape of result | Closest SQL equivalent |
|---|---|---|
Join | one row per matched pair | INNER JOIN |
GroupJoin | one row per outer element, with a collection of matches | LEFT JOIN then GROUP BY |
GroupJoin + SelectMany + DefaultIfEmpty | one row per outer element, plus one row when no match | LEFT JOIN |
Notice what's missing: no RightJoin, no FullJoin, no built-in LeftJoin. The pattern below covers left join, and right join is just left join with the operands swapped. Full outer join needs a union of two left joins. We'll build each of those.
Before any code, a quick mental model. The diagram below shows how the same two sets of customers and orders flow through inner join, group join, and left join.
The three results differ in two ways: how matches are grouped, and what happens to unmatched outer elements. Inner join drops Carol because she has no orders. Group join keeps Carol with an empty list. Left join keeps Carol with a single placeholder row. Picking the right operator is mostly about which of those three behaviors you want.
JoinThe method-syntax shape of Join takes four arguments:
The first argument is the second sequence to join against. The next two are functions that pull a key out of each side; the keys must be equal for two elements to match. The last is a function that combines a matched pair into the result row.
Here's a concrete example. We have a list of customers and a list of orders, and we want one row per (customer, order) pair.
Alice appears twice because she has two orders, which is the normal inner-join behavior (the result is a Cartesian product filtered by the key). Carol doesn't appear at all because she has no orders. The result is flat: one row per matched pair, not one row per customer.
The key selectors don't have to share a type with each other as long as they produce the same key type. Here customer => customer.Id and order => order.CustomerId both return int, which is what Join requires.
Join builds a hash lookup on the inner sequence internally. It walks the inner sequence once to build the lookup, then walks the outer sequence once and probes the hash table for each element. Total work is O(n + m), not O(n m). The naive nested-loop alternative (`from c in customers from o in orders where c.Id == o.CustomerId`) is O(n m) and gets slow fast.
Query syntax has a join ... in ... on ... equals ... form that compiles to a call to Join. It's often easier to read when you're chaining a join with other clauses like where and orderby.
Two syntax rules apply. The keyword between the two key expressions is equals, not ==. The outer key (customer.Id) goes on the left of equals and the inner key (order.CustomerId) goes on the right; if you swap them, you get a compile error. The compiler is strict about the direction because it controls which sequence becomes the lookup.
The whole join clause compiles to exactly the same Join call we wrote by hand earlier. Same hash lookup, same O(n + m) work. Use whichever form reads better for the situation.
GroupJoin: One Row per Outer ElementGroupJoin is the same join with a different result shape. Instead of one row per matched pair, you get one row per outer element with a collection of all the matches.
Carol shows up with zero orders. That's the headline difference: GroupJoin preserves every outer element regardless of whether it has any matches. The result selector receives an IEnumerable<TInner> (here IEnumerable<Order>) that's empty when there are no matches.
The query-syntax form uses join ... into:
The into keyword takes the matched-inner-elements collection and binds it to a name (customerOrders here), which you can then use in select or downstream clauses. The result is the same as calling GroupJoin directly; query syntax is just nicer to read when you want to count, sum, or filter the inner group.
GroupJoin + SelectMany + DefaultIfEmptyLINQ has no built-in LeftJoin. The standard idiom is to do a GroupJoin and then flatten each group, substituting a single null when the group is empty.
Walk through what each operator does. GroupJoin produces three rows (one per customer), each carrying the customer plus a (possibly empty) collection of orders. SelectMany then flattens those rows: for Alice, it expands her two orders into two rows; for Bob, one row; for Carol, the collection is empty so there'd be zero rows. That last case is the problem the left-join idiom solves.
DefaultIfEmpty() is the trick. When called on an empty IEnumerable<T>, it yields a single element whose value is default(T). For reference types, that's null. So Carol's empty order collection becomes a one-element sequence containing a single null order, and SelectMany produces one row for Carol with order?.Id == null. The shape now matches SQL's LEFT JOIN: every outer element appears at least once, even when it has no inner match.
The query-syntax form reads more naturally:
The second from clause is what makes it a left join. Without DefaultIfEmpty, the inner from would yield zero rows for Carol and drop her, turning the whole query back into an inner join.
The left-join idiom does the same hash-lookup work as Join, plus a small extra cost for the empty-group case. It's still O(n + m), not O(n * m). The expensive part isn't the joining; it's the SelectMany allocation when materializing the whole result with ToList().
LINQ to Objects has no RightJoin and no FullJoin. Both are easy to build from what we have.
A right join is the same as a left join with the operands swapped. To get "every order, with customer info when available":
Order #103 has no matching customer (CustomerId = 99), so the customer-name column falls back to (unknown) instead of dropping the row. We didn't write a RightJoin operator; we just left-joined orders against customers, which gives the same shape.
Full outer join is the union of two left joins: one in each direction, with duplicates removed. Here's the standalone version that emits every customer and every order, with null on whichever side has no match:
The left side gives us every customer (Alice, Bob, Carol), filling in nulls for Carol. The right side gives us only the orphan orders (the where customer == null filter strips out matches that already appeared on the left). Concatenating them produces a full outer join. It's not as ergonomic as SQL's FULL OUTER JOIN, but it's O(n + m) and uses only standard LINQ operators.
Sometimes the join key is a combination of fields rather than a single column. The standard pattern is to build an anonymous type as the key, with one property per field.
Here's a realistic example. We have category-level sales targets keyed by (Region, Year), and category-level actuals keyed the same way. We want to compare them.
The US 2024 actual is correctly dropped because no target row matches that key. The 2025 rows match cleanly across all three fields.
There's a rule that bites people the first time: both sides of equals must produce the same anonymous type, which means the property names and types must match exactly, in the same order. If you write new { target.Region, target.Year, target.Category } on the left and new { actual.Year, actual.Region, actual.Category } on the right (different order), you get a compile error. The same goes for renaming a property: new { Region = target.Region } and new { target.Region } are different anonymous types, and the join won't compile.
The fix is to keep the names and order identical on both sides. If the underlying property names differ between the two record types, rename them consistently on both sides:
A multi-key join builds the same hash lookup as a single-key join, but the key is now an anonymous-type instance, which means a hash code that combines all fields and an equality check that compares all fields. It's still O(n + m), and the constant factor is small because anonymous types implement GetHashCode and Equals automatically.
Join Actually WorksBoth Join and GroupJoin are implemented the same way internally. They build a hash lookup on the inner sequence, then stream the outer sequence and probe the lookup for each element. Concretely:
innerKeySelector on each element, and bucket the elements into a Lookup<TKey, TInner> keyed by that.outerKeySelector, look up the bucket of matching inner elements, and produce result rows.A few consequences fall out of this design. First, the work is O(n + m) where n is the outer size and m is the inner size. That beats the naive nested-loop approach (O(n * m)) for any meaningful data size. Second, the inner sequence is buffered completely in memory when the result is first iterated, so it doesn't matter if the inner is infinite or huge; you'll either run out of memory or wait a long time. The outer can be streamed without buffering. If one of your two sequences is enormous and the other is small, put the small one as the inner so the hash lookup stays small.
Third, Join is deferred: building the lookup happens the first time you iterate the result, not when you call .Join(...). Until then, no work has been done. Materialize with .ToList() if you plan to iterate multiple times, or you'll rebuild the lookup on each pass.
Finally, the key comparison uses the type's default equality (EqualityComparer<TKey>.Default). For int, string, Guid, and most BCL types, that's exactly what you want. For custom key types, override Equals and GetHashCode, or pass a custom IEqualityComparer<TKey> to the overload of Join that accepts one.
The custom-comparer overload is the same operator with one extra argument. You'll rarely need it for the e-commerce examples in this lesson, but it's there when keys are case-insensitive strings, culture-sensitive comparisons, or composite keys with custom equality.
Joins compose. To join three sequences (customers, orders, and order items), chain two joins together. Each join clause adds one more sequence to the result.
Two join clauses, two hash lookups. The first joins customers to orders; the second joins the (customer, order) pairs to items. Total work is O(c + o + i), where each letter is the size of one of the three sequences. The order of join clauses can matter for performance: put the most selective join first (the one that filters out the most rows) so later joins work on a smaller intermediate result.
You can mix join and join into in the same query if you want some inner joins and some group joins. For example, "every customer with all their orders, plus the order's items":
The outer query is a group join (one row per customer). The inner query is an inner join (each customer's orders joined to their items). Nesting queries this way is often cleaner than trying to flatten everything into one giant join.
Several common mistakes come up with joins. Each has a short fix.
What's wrong with this code?
The query is fine; the iteration pattern is the problem. rows.Any() iterates the query once (building the hash lookup), and then any later iteration rebuilds it from scratch. The query is deferred and doesn't cache.
Fix: materialize once with .ToList() if you plan to inspect the result more than once.
What's wrong with this code?
The join clause uses equals, not ==. The compiler rejects == in a join because the two sides of the comparison must be a join key (used to build the hash lookup), and the compiler can't be sure == was meant as a key comparison.
Fix: use equals.
What's wrong with this code?
This works, but it does extra work. A join into is a group join, which keeps every customer including ones with no orders. If you only want customers who actually have orders, an inner join is simpler and avoids the empty groups.
Fix: drop the into clause.
Pick the operator that matches the shape you want. Group join for "one row per outer", inner join for "only matched pairs", left join for "every outer plus a placeholder when no match".
Where with a SubqueryA common alternative to Join is a where clause with a nested lookup:
This works and is sometimes more readable, but it's O(n * m) in the worst case. Every customer scans every order with a linear Where. For small lists it's fine; for thousands of customers and millions of orders, it's slow.
The faster pattern is to build the lookup once and then query it:
ToLookup does the same hash bucketing that Join does internally, but exposes it as a reusable ILookup<TKey, TValue>. The point here is that Join and GroupJoin are doing this work for you automatically, which is why they're O(n + m) instead of O(n * m).
For one-off pairings, use Join or GroupJoin directly. For multiple lookups against the same inner sequence, build a Lookup once and reuse it.
9 quizzes