ZetCode

C# LINQ Join

last modified November 6, 2026

In this article we show how to join data sources using LINQ join operations.

Language-Integrated Query (LINQ) is a domain-specific language for querying data from various data sources, including arrays, lists, XML files, or databases.

The join operation associates objects from one data source with objects from another data source based on matching keys. LINQ joins are equijoins; they compare the keys for equality.

C# LINQ inner join

The inner join is the most common join type. The inner join selects only those records from data sources that have matching values.

Program.cs
List<Country> countries =
[
    new("Slovakia", 1),
    new("Hungary", 2),
    new("Austria", 3),
    new("Poland", 4),
    new("Slovenia", 5)
];

List<City> cities =
[
    new("Bratislava", 1),
    new("Poprad", 1),
    new("Trnava", 1),
    new("Budapest", 2),
    new("Miskolc", 2),
    new("Ajka", 2),
    new("Vienna", 3),
    new("Graz", 3),
    new("Linz", 3),
    new("Warsaw", 4),
    new("Krakow", 4),
    new("Atlantis", 9)
];

var joined = from country in countries
             join city in cities on country.Id equals city.CountryId
             select new
             {
                 Country = country.Name,
                 City = city.Name
             };

foreach (var e in joined)
{
    Console.WriteLine($"{e.City} {e.Country}");
}

record City(string Name, int CountryId);
record Country(string Name, int Id);

We have a list of countries and cities. Each city contains the corresponding country Id.

var joined = from country in countries
             join city in cities on country.Id equals city.CountryId
             select new
             {
                 Country = country.Name,
                 City = city.Name
             };

The join operation is performed with the join, in, on, and equals keywords. The key from the outer sequence must be on the left side of equals.

Slovenia has no city in our data source and Atlantis refers to a nonexistent country. Neither of them appears in the output.

$ dotnet run
Bratislava Slovakia
Poprad Slovakia
Trnava Slovakia
Budapest Hungary
Miskolc Hungary
Ajka Hungary
Vienna Austria
Graz Austria
Linz Austria
Warsaw Poland
Krakow Poland

C# LINQ Join method

In the method syntax, the inner join is performed with the Join method.

Program.cs
List<Country> countries =
[
    new("Slovakia", 1),
    new("Hungary", 2),
    new("Austria", 3),
    new("Poland", 4),
    new("Slovenia", 5)
];

List<City> cities =
[
    new("Bratislava", 1),
    new("Poprad", 1),
    new("Trnava", 1),
    new("Budapest", 2),
    new("Miskolc", 2),
    new("Ajka", 2),
    new("Vienna", 3),
    new("Graz", 3),
    new("Linz", 3),
    new("Warsaw", 4),
    new("Krakow", 4),
    new("Atlantis", 9)
];

var joined = countries.Join(cities,
    country => country.Id,
    city => city.CountryId,
    (country, city) => $"{city.Name}, {country.Name}");

Console.WriteLine(string.Join("; ", joined));

record City(string Name, int CountryId);
record Country(string Name, int Id);

The example produces the same pairs as the previous one.

var joined = countries.Join(cities,
    country => country.Id,
    city => city.CountryId,
    (country, city) => $"{city.Name}, {country.Name}");

The method takes the inner sequence, the outer key selector, the inner key selector, and a result selector that combines a matching pair.

$ dotnet run
Bratislava, Slovakia; Poprad, Slovakia; Trnava, Slovakia; Budapest, Hungary; Miskolc, Hungary; Ajka, Hungary; Vienna, Austria; Graz, Austria; Linz, Austria; Warsaw, Poland; Krakow, Poland

C# LINQ group join

A group join joins two sequences based on a key and groups the resulting matches for each element of the first sequence.

Program.cs
List<Country> countries =
[
    new("Slovakia", 1),
    new("Hungary", 2),
    new("Austria", 3),
    new("Poland", 4),
    new("Slovenia", 5)
];

List<City> cities =
[
    new("Bratislava", 1),
    new("Poprad", 1),
    new("Trnava", 1),
    new("Budapest", 2),
    new("Miskolc", 2),
    new("Ajka", 2),
    new("Vienna", 3),
    new("Graz", 3),
    new("Linz", 3),
    new("Warsaw", 4),
    new("Krakow", 4),
    new("Atlantis", 9)
];

var groups = from country in countries
             join city in cities on country.Id equals city.CountryId into cGroup
             select new
             {
                 Country = country.Name,
                 Cities = cGroup
             };

foreach (var group in groups)
{
    Console.WriteLine($"{group.Country} ({group.Cities.Count()})");

    foreach (var city in group.Cities)
    {
        Console.WriteLine($"  {city.Name}");
    }
}

record City(string Name, int CountryId);
record Country(string Name, int Id);

Instead of displaying each city mapped to the country, we show a list of countries with their corresponding cities grouped.

join city in cities on country.Id equals city.CountryId into cGroup

The into keyword turns the join into a group join. The cGroup variable holds the sequence of cities that belong to the current country. In the method syntax, this is done with GroupJoin.

Unlike the inner join, the group join includes Slovenia; its group of cities is empty.

$ dotnet run
Slovakia (3)
  Bratislava
  Poprad
  Trnava
Hungary (3)
  Budapest
  Miskolc
  Ajka
Austria (3)
  Vienna
  Graz
  Linz
Poland (2)
  Warsaw
  Krakow
Slovenia (0)

C# LINQ left outer join

A left outer join returns all elements of the first sequence, even if they have no match in the second sequence.

Program.cs
List<Country> countries =
[
    new("Slovakia", 1),
    new("Hungary", 2),
    new("Austria", 3),
    new("Poland", 4),
    new("Slovenia", 5)
];

List<City> cities =
[
    new("Bratislava", 1),
    new("Poprad", 1),
    new("Trnava", 1),
    new("Budapest", 2),
    new("Miskolc", 2),
    new("Ajka", 2),
    new("Vienna", 3),
    new("Graz", 3),
    new("Linz", 3),
    new("Warsaw", 4),
    new("Krakow", 4),
    new("Atlantis", 9)
];

var res = from country in countries
          join city in cities on country.Id equals city.CountryId into cGroup
          from city in cGroup.DefaultIfEmpty()
          select $"{country.Name}: {city?.Name ?? "no city"}";

Console.WriteLine(string.Join(Environment.NewLine, res));

Console.WriteLine("----------");

var res2 = countries.LeftJoin(cities,
    country => country.Id,
    city => city.CountryId,
    (country, city) => $"{country.Name}: {city?.Name ?? "no city"}");

Console.WriteLine(string.Join(", ", res2.TakeLast(3)));

var res3 = countries.RightJoin(cities,
    country => country.Id,
    city => city.CountryId,
    (country, city) => $"{city.Name}: {country?.Name ?? "unknown"}");

Console.WriteLine(string.Join(", ", res3.TakeLast(3)));

record City(string Name, int CountryId);
record Country(string Name, int Id);

We list all countries with their cities.

join city in cities on country.Id equals city.CountryId into cGroup
from city in cGroup.DefaultIfEmpty()

The classic way of writing a left join in the query syntax is to combine a group join with DefaultIfEmpty. For an empty group, DefaultIfEmpty returns a sequence with a single null value, so Slovenia is kept.

var res2 = countries.LeftJoin(cities,
    country => country.Id,
    city => city.CountryId,
    (country, city) => $"{country.Name}: {city?.Name ?? "no city"}");

.NET 10 added the LeftJoin method, which expresses the same operation directly. The inner element passed to the result selector is null when there is no match.

var res3 = countries.RightJoin(cities,

The RightJoin method, also new in .NET 10, keeps all elements of the second sequence. Atlantis is included with an unknown country.

$ dotnet run
Slovakia: Bratislava
Slovakia: Poprad
Slovakia: Trnava
Hungary: Budapest
Hungary: Miskolc
Hungary: Ajka
Austria: Vienna
Austria: Graz
Austria: Linz
Poland: Warsaw
Poland: Krakow
Slovenia: no city
----------
Poland: Warsaw, Poland: Krakow, Slovenia: no city
Warsaw: Poland, Krakow: Poland, Atlantis: unknown

C# LINQ composite key

We may not have a single, well-defined key that associates two data sources. It is possible to generate a composite key from multiple values.

Program.cs
List<Worker> workers =
[
    new("John", "Doe", 1),
    new("Roger", "Roe", 2),
    new("Patrick", "Smith", 3),
    new("Peter", "Novak", 4),
    new("Lucia", "Novak", 5),
];

List<Voter> voters =
[
    new("John", "Doe", 1),
    new("Dave", "Manole", 2),
    new("Patrick", "Smith", 3),
    new("Paul", "Dempsey", 4),
    new("Svetlana", "Malikova", 5),
    new("Roger", "Roe", 6),
    new("Roman", "Holz", 7),
    new("Lucia", "Molnarova", 8),
    new("Peter", "Novak", 9),
];

var joined = from worker in workers
             join voter in voters
             on new { worker.FirstName, worker.LastName }
             equals new { voter.FirstName, voter.LastName }
             select $"{voter.FirstName} {voter.LastName}";

Console.WriteLine(string.Join(Environment.NewLine, joined));

var joined2 = workers.Join(voters,
    w => (w.FirstName, w.LastName),
    v => (v.FirstName, v.LastName),
    (w, v) => $"worker {w.Id} = voter {v.Id}");

Console.WriteLine(string.Join(Environment.NewLine, joined2));

record Voter(string FirstName, string LastName, int Id);
record Worker(string FirstName, string LastName, int Id);

We have a list of workers and voters. We want to find out all workers that are also voters. The Ids of the two lists are unrelated.

on new { worker.FirstName, worker.LastName }
equals new { voter.FirstName, voter.LastName }

We create a composite key from the first name and the last name using anonymous types. Two anonymous objects are equal when all their properties are equal; the property names and types must match on both sides. We assume that each person is unique.

w => (w.FirstName, w.LastName),
v => (v.FirstName, v.LastName),

In the method syntax, tuples are a convenient composite key.

$ dotnet run
John Doe
Roger Roe
Patrick Smith
Peter Novak
worker 1 = voter 1
worker 2 = voter 6
worker 3 = voter 3
worker 4 = voter 9

C# LINQ multiple joins

We can join multiple data sources with multiple join operations.

Program.cs
List<Employee> employees = [
    new(1, "John", "Doe"),
    new(2, "Paul", "Smith"),
    new(3, "Roger", "Roe")
];

List<Order> orders = [
    new(1, 1, 1, 1),
    new(2, 1, 2, 1),
    new(3, 2, 5, 2),
    new(4, 3, 4, 1),
    new(5, 3, 1, 1),
    new(6, 3, 3, 1)
];

List<Book> books = [
    new(1, "War and Peace", 24.90m),
    new(2, "Old Goriot", 14.50m),
    new(3, "Essays and Aphorisms", 35m),
    new(4, "The Call of the Wild", 11m),
    new(5, "English Grammar", 33m),
];

var res = from o in orders
          join e in employees on o.EmployeeId equals e.Id
          join b in books on o.BookId equals b.Id
          orderby e.Id
          select new { e.FirstName, e.LastName, b.Title, Total = b.Price * o.Quantity };

foreach (var e in res)
{
    Console.WriteLine($"{e.FirstName} {e.LastName}: {e.Title}, {e.Total:F2}");
}

var totals = from o in orders
             join e in employees on o.EmployeeId equals e.Id
             join b in books on o.BookId equals b.Id
             group b.Price * o.Quantity by e.LastName into g
             select (Employee: g.Key, Sales: g.Sum());

foreach (var (employee, sales) in totals)
{
    Console.WriteLine($"{employee}: {sales:F2}");
}

record Employee(int Id, string FirstName, string LastName);
record Book(int Id, string Title, decimal Price);
record Order(int Id, int EmployeeId, int BookId, int Quantity);

We have three data sources: employees, books, and orders. Our goal is to map each employee to the books sold.

var res = from o in orders
          join e in employees on o.EmployeeId equals e.Id
          join b in books on o.BookId equals b.Id
          orderby e.Id
          select new { e.FirstName, e.LastName, b.Title, Total = b.Price * o.Quantity };

With two join operations, we combine the data together. First we join orders with the employees. Then we add the book details. In the end, we get which books each employee sold and for how much.

group b.Price * o.Quantity by e.LastName into g
select (Employee: g.Key, Sales: g.Sum());

The joined data can be grouped further; we compute the total sales of each employee.

$ dotnet run
John Doe: War and Peace, 24.90
John Doe: Old Goriot, 14.50
Paul Smith: English Grammar, 66.00
Roger Roe: The Call of the Wild, 11.00
Roger Roe: War and Peace, 24.90
Roger Roe: Essays and Aphorisms, 35.00
Doe: 39.40
Smith: 66.00
Roe: 70.90

Source

Join operations in LINQ

In this article we showed how to join data sources using LINQ join operations.

Author

My name is Jan Bodnar, and I am a passionate programmer with extensive programming experience. I have been writing programming articles since 2007. To date, I have authored over 1,400 articles and 8 e-books. I possess more than ten years of experience in teaching programming.

List all C# tutorials.