Inner Joins with Join
Join() correlates elements from two sequences based on matching keys, performing an inner join: only elements with a matching key on both sides appear in the result, and unmatched elements from either sequence are dropped entirely. Its signature is outer.Join(inner, outerKeySelector, innerKeySelector, resultSelector), so customers.Join(orders, c => c.Id, o => o.CustomerId, (c, o) => new { c.Name, o.Total }) pairs each customer with each of their orders, producing one result row per matching pair — a customer with three orders yields three joined rows.
Cricket analogy: Matching each player in a squad list to their entries in a scorecard log, so only players who actually batted appear in the combined report, mirrors Join()'s inner-join behavior — unmatched squad members are dropped.
Composite Keys in Join
Just as with GroupBy(), Join() can correlate on more than one field by projecting the key selectors into matching anonymous types: shipments.Join(deliveries, s => new { s.OrderId, s.WarehouseId }, d => new { d.OrderId, d.WarehouseId }, (s, d) => ...). Both the outer and inner key selectors must produce anonymous types with the same property names, types, and declaration order, because that's precisely what determines whether two anonymous type instances are considered equal for matching purposes.
Cricket analogy: Matching bowling figures to batting figures for the same match AND the same innings number (not just the same match) requires a composite key, mirroring Join() with keys combining MatchId and InningsNumber.
GroupJoin for Left-Outer-Join Patterns
Join() drops unmatched outer elements, but GroupJoin() correlates each outer element with the entire collection of matching inner elements (possibly empty), producing exactly one result per outer element: customers.GroupJoin(orders, c => c.Id, o => o.CustomerId, (c, orderGroup) => new { c.Name, OrderCount = orderGroup.Count() }) keeps every customer, even those with zero orders, because orderGroup is simply an empty sequence in that case. Combining GroupJoin() with SelectMany() and DefaultIfEmpty() is the standard LINQ pattern for a true left outer join that flattens back into one row per (customer, order) pair, using null or a default value where no match exists.
Cricket analogy: A squad report that lists every player, including those who haven't batted yet this series (showing zero innings rather than omitting them), mirrors GroupJoin() keeping every outer element even with an empty match group.
Join Performance Considerations
LINQ-to-Objects' Join() builds an internal lookup (essentially a hash table keyed by the inner sequence's key) before scanning the outer sequence, giving it roughly O(n + m) performance rather than the O(n * m) you'd get from a naive nested Where() inside a SelectMany(), which is why Join() is strongly preferred over manual nested-loop correlation for in-memory collections. Against an IQueryable database source, however, the query provider translates Join() into an actual SQL JOIN, and performance instead depends entirely on the database's execution plan, index coverage on the join columns, and whether the provider can push the correlation down efficiently rather than pulling both tables into memory first.
Cricket analogy: Looking up a player's full bio from a pre-built index card by name instead of flipping through every page of a giant yearbook for each lookup is far faster, mirroring how Join() builds a hash lookup instead of a naive nested scan.
var customers = new List<Customer>
{
new Customer { Id = 1, Name = "Ada" },
new Customer { Id = 2, Name = "Grace" },
};
var orders = new List<Order>
{
new Order { Id = 10, CustomerId = 1, Total = 120.00m },
new Order { Id = 11, CustomerId = 1, Total = 45.00m },
};
// Inner join: Grace is dropped because she has no matching orders
var innerJoined = customers.Join(
orders,
c => c.Id,
o => o.CustomerId,
(c, o) => new { c.Name, o.Total });
// GroupJoin: every customer is kept, Grace gets an empty order group
var grouped = customers.GroupJoin(
orders,
c => c.Id,
o => o.CustomerId,
(c, orderGroup) => new { c.Name, OrderCount = orderGroup.Count() });
// Left outer join pattern: GroupJoin + SelectMany + DefaultIfEmpty
var leftOuter = customers.GroupJoin(
orders,
c => c.Id,
o => o.CustomerId,
(c, orderGroup) => new { c, orderGroup })
.SelectMany(
x => x.orderGroup.DefaultIfEmpty(),
(x, o) => new { x.c.Name, Total = o?.Total ?? 0m });Query syntax has native support for both patterns: 'join o in orders on c.Id equals o.CustomerId' compiles to Join(), while adding 'into orderGroup' after the join clause ('join o in orders on c.Id equals o.CustomerId into orderGroup') compiles to GroupJoin(), which you can then combine with a further 'from o in orderGroup.DefaultIfEmpty()' to express the left-outer-join flattening pattern directly in query syntax.
Join() uses the default equality comparer for the key type unless you supply an explicit IEqualityComparer<TKey> overload. This becomes a subtle bug source with string keys that differ only in casing or culture ('ABC123' vs 'abc123'), or with reference types that don't override Equals()/GetHashCode() — in both cases, elements that a human would consider 'matching' silently fail to join because the default comparer says they're not equal.
- Join() performs an inner join, keeping only elements with matching keys on both sides and dropping unmatched elements entirely.
- GroupJoin() keeps every outer element, pairing it with a (possibly empty) group of matching inner elements.
- Combining GroupJoin(), SelectMany(), and DefaultIfEmpty() produces a true left-outer-join pattern.
- Composite-key joins use matching anonymous types for the outer and inner key selectors.
- LINQ-to-Objects' Join() builds a hash lookup for roughly O(n + m) performance, beating naive nested-loop correlation.
- Against IQueryable database sources, Join() translates to SQL JOIN, and performance depends on indexes and the execution plan.
- Join() uses the default equality comparer unless an explicit IEqualityComparer<TKey> is supplied, which can silently miss expected matches.
Practice what you learned
1. What happens to a customer with zero matching orders when using Join()?
2. How does GroupJoin() differ from Join() in terms of which outer elements appear in the result?
3. Which combination of operators produces a standard flattened left-outer-join pattern in LINQ?
4. How do you join two sequences on more than one field in LINQ?
5. Why does LINQ-to-Objects' Join() generally outperform a naive nested Where() inside a SelectMany() for correlating two collections?
Was this page helpful?
You May Also Like
Grouping with GroupBy
Learn how LINQ's GroupBy operator clusters elements by a key into IGrouping sequences, including result selectors, composite keys, and how it differs from SQL's GROUP BY.
Filtering with Where
Learn how LINQ's Where operator filters sequences using predicates, how deferred execution affects when the filter actually runs, and how to combine multiple conditions safely.
Projection with Select
Learn how LINQ's Select operator transforms each element of a sequence into a new shape, including anonymous types, the indexed overload, and how it differs from SelectMany.
Related Reading
Related Study Notes in Microsoft Technologies
Browse all study notesWindows 10 / UWP Development Study Notes
.NET · 30 topics
Microsoft TechnologiesWindows Batch Scripting Study Notes
Batch · 30 topics
Microsoft TechnologiesMFC (Microsoft Foundation Classes) Study Notes
C++ · 30 topics
Microsoft TechnologiesSilverlight Study Notes
.NET · 30 topics
Microsoft TechnologiesXAML Study Notes
.NET · 30 topics
Microsoft TechnologiesWPF (Windows Presentation Foundation) Study Notes
.NET · 30 topics