LINQ join/on/equals/let: Joins That Stay Readable

Avoiding Join Confusion

It's tempting to join collections using nested loops and Where clauses. It works—until you accidentally create a Cartesian product that multiplies your data by thousands and grinds performance to a halt. You meant to match orders to customers but ended up with every order paired with every customer.

Nested loops hide join logic in imperative code. The relationship between collections becomes implicit, and bugs slip through when conditions change. Performance suffers because you're manually filtering instead of using optimized join algorithms. Code reviewers struggle to understand what data you're actually combining.

This article shows the safer pattern: LINQ's join...on...equals syntax that makes relationships explicit and prevents accidental cross joins. You'll learn inner joins, group joins for one-to-many relationships, and let clauses for computed values. By the end, you'll write joins that SQL developers recognize immediately and that compile to efficient code.

Understanding Inner Joins

The join keyword combines two collections based on matching keys. You specify the collections, the key selector for each side, and equals to enforce the match. LINQ only returns items where keys match on both sides, just like SQL INNER JOIN.

The syntax looks like SQL but with stricter type safety. Both key selectors must return the same type, and equals is a keyword rather than an operator. This prevents common mistakes like using comparison operators that don't work with joins.

BasicJoin.cs
var customers = new List
{
    new() { Id = 1, Name = "Alice" },
    new() { Id = 2, Name = "Bob" },
    new() { Id = 3, Name = "Carol" }
};

var orders = new List
{
    new() { OrderId = 101, CustomerId = 1, Total = 50.00m },
    new() { OrderId = 102, CustomerId = 2, Total = 75.50m },
    new() { OrderId = 103, CustomerId = 1, Total = 120.00m }
};

var query = from customer in customers
            join order in orders on customer.Id equals order.CustomerId
            select new
            {
                CustomerName = customer.Name,
                order.OrderId,
                order.Total
            };

foreach (var item in query)
{
    Console.WriteLine($"{item.CustomerName}: Order {item.OrderId} - ${item.Total}");
}

// Output:
// Alice: Order 101 - $50.00
// Bob: Order 102 - $75.50
// Alice: Order 103 - $120.00

record Customer { public int Id { get; init; } public string Name { get; init; } }
record Order { public int OrderId { get; init; } public int CustomerId { get; init; }
               public decimal Total { get; init; } }

This query matches customers to their orders using customer ID. Notice Alice appears twice because she has two orders. Carol doesn't appear because she has no orders. The join enforces the relationship between customer.Id and order.CustomerId, preventing mismatched data.

Group Joins for One-to-Many

Group join uses into to create a collection of matching items for each left-side element. This lets you get all related items together, similar to SQL LEFT JOIN when combined with DefaultIfEmpty. Without DefaultIfEmpty, items with no matches are excluded like INNER JOIN.

Group joins are perfect when you need to aggregate or count related items. Each result contains the parent item and all its children as an enumerable you can iterate or count.

GroupJoin.cs
var customers = new List
{
    new() { Id = 1, Name = "Alice" },
    new() { Id = 2, Name = "Bob" },
    new() { Id = 3, Name = "Carol" }
};

var orders = new List
{
    new() { OrderId = 101, CustomerId = 1, Total = 50.00m },
    new() { OrderId = 102, CustomerId = 2, Total = 75.50m },
    new() { OrderId = 103, CustomerId = 1, Total = 120.00m }
};

var query = from customer in customers
            join order in orders on customer.Id equals order.CustomerId into customerOrders
            select new
            {
                customer.Name,
                OrderCount = customerOrders.Count(),
                TotalAmount = customerOrders.Sum(o => o.Total)
            };

foreach (var item in query)
{
    Console.WriteLine(
        $"{item.Name}: {item.OrderCount} orders, Total: ${item.TotalAmount}");
}

// Output:
// Alice: 2 orders, Total: $170.00
// Bob: 1 orders, Total: $75.50
// Carol: 0 orders, Total: $0

record Customer { public int Id { get; init; } public string Name { get; init; } }
record Order { public int OrderId { get; init; } public int CustomerId { get; init; }
               public decimal Total { get; init; } }

The into clause names the collection of matching orders. Even though Carol has no orders, she appears in results with zero count and total. This happens because group join returns all left-side items. You can call aggregate functions like Count and Sum directly on the grouped collection.

Chaining Multiple Joins

You can join more than two collections by adding additional join clauses. Each join operates on the combined result of previous joins. The query remains readable when you align the syntax and use descriptive names.

MultipleJoins.cs
var products = new List
{
    new() { ProductId = 1, Name = "Laptop", CategoryId = 1 },
    new() { ProductId = 2, Name = "Mouse", CategoryId = 2 },
    new() { ProductId = 3, Name = "Keyboard", CategoryId = 2 }
};

var categories = new List
{
    new() { CategoryId = 1, Name = "Computers" },
    new() { CategoryId = 2, Name = "Accessories" }
};

var inventory = new List
{
    new() { ProductId = 1, Quantity = 10 },
    new() { ProductId = 2, Quantity = 50 },
    new() { ProductId = 3, Quantity = 25 }
};

var query = from product in products
            join category in categories on product.CategoryId equals category.CategoryId
            join item in inventory on product.ProductId equals item.ProductId
            select new
            {
                product.Name,
                Category = category.Name,
                item.Quantity
            };

foreach (var item in query)
{
    Console.WriteLine($"{item.Name} ({item.Category}): {item.Quantity} in stock");
}

record Product { public int ProductId { get; init; } public string Name { get; init; }
                 public int CategoryId { get; init; } }
record Category { public int CategoryId { get; init; } public string Name { get; init; } }
record InventoryItem { public int ProductId { get; init; } public int Quantity { get; init; } }

This query joins products to their categories and inventory counts. Each join adds another dimension to the result. The alignment of join keywords makes the query structure clear. All three collections connect through their respective ID fields.

Using let for Intermediate Values

The let keyword introduces a range variable that stores a computed value you can reference later in the query. This makes complex queries readable by naming expressions and prevents recalculating the same value multiple times.

Place let clauses after from and join but before where, orderby, or select. You can have multiple let clauses, each building on previous values. This creates a readable pipeline of transformations.

LetClause.cs
var orders = new List
{
    new() { OrderId = 101, Subtotal = 100.00m, TaxRate = 0.08m },
    new() { OrderId = 102, Subtotal = 50.00m, TaxRate = 0.08m },
    new() { OrderId = 103, Subtotal = 200.00m, TaxRate = 0.08m }
};

var query = from order in orders
            let tax = order.Subtotal * order.TaxRate
            let total = order.Subtotal + tax
            where total > 100
            orderby total descending
            select new
            {
                order.OrderId,
                order.Subtotal,
                Tax = tax,
                Total = total
            };

foreach (var item in query)
{
    Console.WriteLine(
        $"Order {item.OrderId}: ${item.Subtotal} + ${item.Tax:F2} = ${item.Total:F2}");
}

// Output:
// Order 103: $200 + $16.00 = $216.00
// Order 101: $100 + $8.00 = $108.00

record Order { public int OrderId { get; init; } public decimal Subtotal { get; init; }
               public decimal TaxRate { get; init; } }

The let clause calculates tax once and stores it in a variable. The second let uses tax to compute total. Both tax and total are available in where, orderby, and select without recalculation. This pattern keeps queries clean when you have multi-step calculations.

Choosing the Right Approach

Choose query syntax when you have multiple joins or complex filtering logic. The join...on...equals pattern prevents accidental cross joins and makes join conditions explicit. SQL developers can read query syntax immediately because it mirrors SQL structure.

Choose method syntax for simple joins or when mixing with other LINQ operations like GroupBy or Distinct that read better as method chains. Method syntax gives you access to overloads not available in query syntax and integrates smoothly with fluent APIs.

Use let clauses when you reference the same calculation multiple times in filters or projections. This avoids repeating expensive operations and gives meaningful names to complex expressions. If you only use a value once, inline it in the select instead of creating a let binding.

For many-to-many relationships, use two joins instead of a group join. Join to the junction table first, then join to the target table. This produces flat results you can easily project or filter further.

Try It Yourself

Build a console app that demonstrates different join patterns with sample data.

Steps

  1. Create: dotnet new console -n JoinDemo
  2. Move to folder: cd JoinDemo
  3. Replace Program.cs with the code below
  4. Run: dotnet run
Program.cs
var authors = new List
{
    new() { Id = 1, Name = "Alice" },
    new() { Id = 2, Name = "Bob" }
};

var books = new List
{
    new() { Title = "C# Basics", AuthorId = 1 },
    new() { Title = "LINQ Guide", AuthorId = 1 },
    new() { Title = "Web APIs", AuthorId = 2 }
};

// Inner join
var innerJoin = from author in authors
                join book in books on author.Id equals book.AuthorId
                select new { author.Name, book.Title };

Console.WriteLine("Inner Join:");
foreach (var item in innerJoin)
    Console.WriteLine($"{item.Name} - {item.Title}");

// Group join
var groupJoin = from author in authors
                join book in books on author.Id equals book.AuthorId into authorBooks
                select new { author.Name, BookCount = authorBooks.Count() };

Console.WriteLine("\nGroup Join:");
foreach (var item in groupJoin)
    Console.WriteLine($"{item.Name}: {item.BookCount} books");

record Author(int Id, string Name);
record Book(string Title, int AuthorId);
JoinDemo.csproj
<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <OutputType>Exe</OutputType>
    <TargetFramework>net8.0</TargetFramework>
    <Nullable>enable</Nullable>
    <ImplicitUsings>enable</ImplicitUsings>
  </PropertyGroup>
</Project>

Output

You'll see the inner join list each book with its author. The group join shows each author with their book count. Alice has 2 books, Bob has 1. The queries demonstrate how join produces flat results while group join aggregates related items.

Quick FAQ

When should I use query syntax instead of method syntax for joins?

Use query syntax when you have multiple joins or complex conditions. The join...on...equals pattern reads more naturally than nested method calls. Query syntax prevents accidentally creating cross joins and makes join conditions explicit.

What's the difference between join and group join?

Join produces one result per match, like SQL INNER JOIN. Group join produces one result per left-side item with all matching right-side items as a collection, like SQL LEFT JOIN. Use into to create a group join.

Why does equals require both sides to match types?

LINQ join uses equality comparison with GetHashCode for performance. Both sides must have the same type to generate compatible hash codes. Cast or project to matching types before the join if needed.

When should I use let in LINQ queries?

Use let to store intermediate calculations you reference multiple times in where, orderby, or select clauses. This avoids repeating expensive computations and makes queries more readable by naming complex expressions.

Back to Articles