Understanding C# LINQ Join with Examples
c# linq join: Learn how to use LINQ Join to combine collections, including inner, left, and multi-key joins, with C# examples and common pitfalls.
When you need to relate elements from two collections that do not share a common interface, the LINQ Join operator is the standard way to do it. The c# linq join syntax mirrors SQL joins, but works in memory against any IEnumerable<T> source, including lists, arrays, and IQueryable<T> sequences backed by a query provider such as Entity Framework Core.
Why Use Join Instead of Nested Loops
A naive way to combine two collections is to iterate every element of the first collection and, inside that loop, iterate every element of the second collection. That approach works, but it is quadratic in the size of the inputs. For two collections of 10,000 items each, that is 100 million comparisons.
LINQ Join uses internal lookup structures to match elements by a key. Because it hashes the keys of one sequence and then probes with keys from the other, the average complexity is close to linear. The actual implementation details matter less than the practical consequence: for anything beyond a small, fixed set of data, Join is the safer choice.
Basic Inner Join Syntax
The most common form of c# linq join is an inner join. It returns only pairs where both sequences contain a matching key.
var orders = new List<Order> { new Order { OrderId = 1, CustomerId = 101, Amount = 49.99m }, new Order { OrderId = 2, CustomerId = 102, Amount = 89.50m } }; var customers = new List<Customer> { new Customer { CustomerId = 101, Name = "Acme Corp" }, new Customer { CustomerId = 103, Name = "Globex" } }; var orderWithCustomer = orders.Join( customers, order => order.CustomerId, customer => customer.CustomerId, (order, customer) => new { order.OrderId, customer.Name, order.Amount });
The first argument is the inner sequence. The second and third arguments are key selectors: one for the outer sequence (in this case orders) and one for the inner sequence (customers). The final argument projects the matching pair into a result type. Here, the result is an anonymous type with OrderId, Name, and Amount.
The output for this example would be a single result: order 1 paired with "Acme Corp". The customer with ID 103 has no order, so it is omitted. The order with ID 2 references customer 102, which does not exist here, so it is also omitted. That is the defining behavior of an inner join.
Query Syntax: The SQL-Like Form
The method syntax above is explicit, but many developers find query syntax more readable when the join involves several columns or a compound condition.
var result = from order in orders join customer in customers on order.CustomerId equals customer.CustomerId select new { order.OrderId, customer.Name, order.Amount };
Query syntax makes the relationship between the sequences visible. The on ... equals ... clause is the only way to express a join in query syntax. Both the from clause and the join clause can be typed explicitly if you prefer clarity over type inference.
The query is compiled into the same Join call as the method syntax. There is no separate execution path. Choosing one over the other is a readability decision, not a performance one.
Handling a Left Join with DefaultIfEmpty
A left join returns all elements from the left sequence, even if there is no match in the right sequence. LINQ does not have a dedicated LeftJoin operator, so you combine GroupJoin with SelectMany.
var result = from order in orders join customer in customers on order.CustomerId equals customer.CustomerId into joined from customer in joined.DefaultIfEmpty() select new { order.OrderId, CustomerName = customer?.Name ?? "No customer", order.Amount };
The into joined clause groups all matching customers for each order. The DefaultIfEmpty() call turns an empty grouping into a single null entry. The null-conditional operator ?. safely accesses Name when the customer is null. This pattern gives you the rows that would come from a SQL LEFT JOIN.
Be careful with the null handling. If you need to access several properties of the right-hand element, you may want to project early or use a helper method to avoid repeating the null check.
Joining on Multiple Conditions
Sometimes a single key is not enough. For instance, when joining by a composite business key that includes both an ID and a date range, you need a match on multiple fields.
The method syntax supports anonymous type keys. When the key type is an anonymous type, the runtime uses value equality, so two anonymous objects match if their properties are equal.
var result = orders.Join( deliveries, order => new { order.Region, order.OrderDate }, delivery => new { delivery.Region, delivery.OrderDate }, (order, delivery) => new { order.OrderId, delivery.TrackingNumber, order.Region });
In query syntax, you chain equals with your own and keyword, but that only works when the underlying provider supports it. For LINQ to Objects, you have to combine keys into a single anonymous type.
var result = from order in orders join delivery in deliveries on new { order.Region, order.OrderDate } equals new { delivery.Region, delivery.OrderDate } select new { order.OrderId, delivery.TrackingNumber };
The anonymous type in the equals clause is the standard way to perform a multi-key join. Both keys must be of the same type and have the same property names.
Performance and Memory Behavior
LINQ Join is not lazy in the same way that Where is. When you call Join, it builds a lookup from the inner sequence. That lookup allocates memory proportional to the size of the inner sequence. For a small inner sequence, the allocation is acceptable. For a massive inner sequence, you should be aware of the memory cost.
The operator also buffers the outer sequence. Once the outer elements are enumerated, they are added to the lookup as they arrive, but the lookup itself stays in memory until the whole Join produces its results. This means that a join cannot stream elements in a single pass without memory overhead. It must see all the outer elements to emit the matches. This is very different from Select or Where, which can process elements one at a time.
If you are joining two large IEnumerable<T> collections and memory is a constraint, consider whether one of the collections can be reduced first with a Where or Distinct before the join. That can shrink the lookup size.
Differences Between Join and GroupJoin
Join produces a flat result: one row per matching pair. GroupJoin produces a hierarchical result: each left-side element gets a sequence of matching right-side elements.
var grouped = customers.GroupJoin( orders, customer => customer.CustomerId, order => order.CustomerId, (customer, orderList) => new { customer.Name, Orders = orderList });
Here, every customer appears once, even if they have zero orders. The orders sequence for a customer with no orders is an empty sequence, not a null. This is useful when you need to render a customer record with a collection of their orders.
A left join is essentially a GroupJoin followed by SelectMany with DefaultIfEmpty. That composite is what query syntax wraps into the into and from pattern.
Common Pitfalls and How to Avoid Them
One frequent mistake is using == instead of equals in query syntax. The equals keyword is a special context keyword inside a join clause. It does not call the == operator, and it cannot be replaced by ==. The compiler will produce a syntax error if you try.
Another issue is key type mismatch. If you join on an int from one sequence and a long from the other, the compiler will complain. You need to cast one of the key selectors to make the types identical.
// Compilation error var result = orders.Join( customers, order => order.CustomerId, // int customer => customer.CustomerKey, // long (o, c) => c); // Correct: convert the long to int var result = orders.Join( customers, order => order.CustomerId, customer => (int)customer.CustomerKey, (o, c) => c);
A third issue is expecting Join to sort the output. It does not. The order of the results is not guaranteed. If you need a specific order, apply OrderBy or OrderByDescending after the join.
When to Consider Alternatives
For one-off data preparation in a test or a small script, a double foreach with a dictionary keyed by the lookup primary key can be simpler to read. For example, building a dictionary from the inner sequence and then looking up the value for each outer element is often more explicit than a Join.
var customerLookup = customers.ToDictionary(c => c.CustomerId); var result = orders .Where(o => customerLookup.ContainsKey(o.CustomerId)) .Select(o => new { o.OrderId, customerLookup[o.CustomerId].Name });
That pattern is essentially a manual inner join. It performs the same lookup logic without the full LINQ join machinery. It also lets you control the key comparison type, which can matter when the key selector is not a simple property.
In query providers such as Entity Framework Core, Join is translated into a SQL JOIN instead of an in-memory operation. The translation works as long as the key selectors are simple property accesses. If you use complex expressions in the key selectors, the provider may not be able to translate them and will either throw an exception or execute the join on the client, which can be a serious performance problem.
Stick to simple key selectors for database-backed queries. For in-memory collections, you have the freedom to use anonymous types for composite keys, but you still need to ensure the keys are comparable and that the value semantics are what you expect.
Ultimately, Join is the right tool for the common case: combining two sequences by a key and getting a flat result. Understanding how it builds a lookup internally helps you know when its memory behavior matters, and knowing how to express left joins and multi-key joins lets you cover most relational scenarios in C#.