Back to Blog
C#

C# LINQ GroupJoin Syntax and Use Cases

c# linq groupjoin: Understand how C# LINQ GroupJoin pairs parent and child collections, produces nested child sequences, and differs from a standard join.

LINQC#GroupJoinData JoiningQuery Syntax
Illustration of C# LINQ GroupJoin connecting departments to employee lists showing nested grouping

When you need to match records from one collection with a set of related records from another, c# linq groupjoin gives you a result shape that a regular join cannot express directly. Instead of flattening every match into a single row, GroupJoin returns each parent element once, with the matching child elements grouped under it. That behavior makes it the natural choice when you want a parent-child hierarchy, such as one order with its line items, or one department with its employees.

How GroupJoin Compares to a Standard Join

A standard Join in LINQ performs an inner join. It combines each matching pair of elements into a single result. If one parent has three children, you get three output rows, repeating the parent fields each time. A GroupJoin, by contrast, produces one result per parent, and each result contains a collection of the matching children. If a parent has no matches, GroupJoin still emits the parent, with an empty child collection. This is closer to a left outer join in SQL, but the child data stays nested rather than flattened.

The difference matters when the consumer cares about the parent as an entity and wants its children as a property, rather than as duplicated rows that must be reshaped later.

GroupJoin Syntax with Method Syntax

In method syntax, GroupJoin has four required arguments: the inner sequence, a key selector for the outer elements, a key selector for the inner elements, and a result selector. The key selectors define what values must match, and the result selector receives each outer element and an IEnumerable<TInner> of the matching inner elements.

The following example pairs each department with its employees:

public record Department(int Id, string Name); public record Employee(int Id, string Name, int DepartmentId); var departments = new List<Department> { new(1, "Engineering"), new(2, "Marketing"), new(3, "Sales") }; var employees = new List<Employee> { new(1, "Alice", 1), new(2, "Bob", 1), new(3, "Charlie", 2) }; var departmentsWithEmployees = departments .GroupJoin( employees, department => department.Id, employee => employee.DepartmentId, (department, employeeGroup) => new { Department = department.Name, EmployeeNames = employeeGroup.Select(e => e.Name) });

departmentsWithEmployees contains three elements. The Engineering department has Alice and Bob, Marketing has Charlie, and Sales has an empty EmployeeNames collection. The result retains departments that have no employees, which is useful when you need to represent missing data explicitly rather than silently omitting the parent.

GroupJoin Syntax with Query Syntax

Query syntax provides a more declarative way to express the same operation using the join ... into keyword. The into keyword preserves the grouped result, and you provide the final projection inside a let clause or directly in the select.

The equivalent query is:

var departmentsWithEmployees = from department in departments join employee in employees on department.Id equals employee.DepartmentId into employeeGroup select new { Department = department.Name, EmployeeNames = employeeGroup.Select(e => e.Name) };

The join ... into clause maps to GroupJoin when the query is compiled. This syntax reads more like a sentence: "join employees on department id, grouped into employeeGroup, then select the department and the names." Many developers find this easier to read when the operation is part of a larger query.

What GroupJoin Does When There Are No Matches

One of the most overlooked behaviors is that GroupJoin returns every outer element, even those with no corresponding inner elements. The result selector receives an empty IEnumerable<TInner> for such cases. This is important when you want to show all parents, such as all departments in a report, and include a placeholder such as "No employees yet." It also means your projection must handle an empty group without assuming there is always at least one item. Code that calls employeeGroup.Single() or employeeGroup.First() will throw when the group is empty.

The following projection uses a default value for empty groups:

var departmentsWithEmployeeSummary = departments .GroupJoin( employees, department => department.Id, employee => employee.DepartmentId, (department, employeeGroup) => new { DepartmentName = department.Name, EmployeeCount = employeeGroup.Count(), HasEmployees = employeeGroup.Any() });

Because Count() and Any() are safe on empty sequences, this pattern works without special casing.

When to Choose GroupJoin Over a Standard Join

GroupJoin is the right tool when the result must preserve a single parent row per parent, regardless of how many children match. Typical scenarios include hierarchical reports, object graphs that need to stay nested, and API responses where a parent DTO contains a list of child DTOs. A standard join is preferable when you need a flat row for every child, such as for CSV export or for feeding a grid where each child is an independent row.

The choice is not about performance alone. GroupJoin often avoids the need to group or reshape data after a flat join, which reduces the amount of code and prevents subtle bugs from duplicate parent values. However, GroupJoin materializes the entire child group for every parent, so memory usage depends on how many children match and whether the sequence is deferred. In LINQ to Objects, both Join and GroupJoin are deferred until enumeration, but once enumerated, they build lookup structures internally, so the source sequences are read fully. This matters for large data sources because fetching all rows from a database into memory is only appropriate when the dataset is manageable in memory.

GroupJoin Performance Considerations

GroupJoin uses a hash-based lookup internally. It builds a lookup table from the inner sequence's key selector, then iterates the outer sequence and probes the lookup. The runtime cost is roughly O(N + M) for the build and probe, where N is the outer count and M is the inner count. This avoids the O(N * M) nested loop cost that a naive implementation would incur. In LINQ to Objects, this means GroupJoin is efficient for in-memory collections, but it also means the entire inner collection is read and indexed before any results are produced.

For large data sources, LINQ to SQL or EF Core translates GroupJoin differently depending on the provider. EF Core may convert a GroupJoin into a correlated subquery, which can perform differently than the in-memory hash lookup. Executing a GroupJoin against a database server may cause a query per parent if the provider cannot generate an efficient join. In such cases, you may see better results with a standard join followed by a GroupBy in memory, or by adjusting the query to use a SelectMany and then grouping. The exact behavior depends on the EF Core version and the database provider, so test the generated SQL for your scenario.

Using GroupJoin with Complex Keys

GroupJoin works with any key type that supports equality comparison. For composite keys, use an anonymous type or a named key object. When using anonymous types, LINQ compares them by value, which works correctly as long as the property names and order match. The following example joins orders to order lines by both order ID and customer ID:

public record Order(int OrderId, int CustomerId, DateTime Date); public record OrderLine(int OrderId, int CustomerId, string Product, int Quantity); var orders = new List<Order> { new(1, 100, DateTime.Today), new(2, 101, DateTime.Today) }; var orderLines = new List<OrderLine> { new(1, 100, "Widget", 2), new(1, 100, "Gadget", 1), new(2, 101, "Widget", 5) }; var ordersWithLines = orders .GroupJoin( orderLines, order => new { order.OrderId, order.CustomerId }, line => new { line.OrderId, line.CustomerId }, (order, lines) => new { order.OrderId, Products = lines.Select(l => l.Product) });

The key selectors must produce equal values for matching records. The anonymous type new { order.OrderId, order.CustomerId } uses the property names OrderId and CustomerId, and the inner selector uses the same names, so the compiler generates matching equality members. This pattern avoids the need to create a separate key class for simple composite keys.

Producing Nested DTOs from GroupJoin Results

A common production use is to map GroupJoin results directly to data transfer objects for an API response. The nested collection in the result becomes a property of the DTO, which matches the JSON structure expected on the client. For example:

public class DepartmentDto { public string Name { get; set; } public List<string> EmployeeNames { get; set; } } var departmentDtos = departments .GroupJoin(employees, department => department.Id, employee => employee.DepartmentId, (department, employeeGroup) => new DepartmentDto { Name = department.Name, EmployeeNames = employeeGroup.Select(e => e.Name).ToList() }) .ToList();

Calling ToList() on the EmployeeNames sequence materializes the child group, which is necessary because the result selector is evaluated lazily. If you leave EmployeeNames as an IEnumerable<string> that references the query, accessing it later may re-evaluate the source. That re-evaluation can cause unexpected repeated work or errors if the underlying collection changes. Materializing the child collection at the point of building the DTO makes the result independent and safe to pass around.

GroupJoin in Larger LINQ Pipelines

GroupJoin does not have to be the final operation. You can chain further LINQ methods after it, such as Where, OrderBy, or SelectMany. For example, you might filter out departments with no employees, then order by department name:

var populatedDepartments = departments .GroupJoin(employees, department => department.Id, employee => employee.DepartmentId, (department, employeeGroup) => new { Department = department, EmployeeCount = employeeGroup.Count() }) .Where(item => item.EmployeeCount > 0) .OrderBy(item => item.Department.Name) .Select(item => item.Department);

One important detail is that deferred execution means the employeeGroup in the result selector is tied to the inner sequence at the moment the query is enumerated. If you store a result of GroupJoin and enumerate it multiple times, the inner sequence may be enumerated again, depending on whether it is repeatable. For a List<T>, repeated enumeration is safe. For a one-shot iterator, such as a generator that reads from a stream, the second enumeration may return no data. Materializing the result with ToList() avoids this pitfall.

Where GroupJoin Can Lead to Surprising Behavior

The into keyword in query syntax is often used with let to make the grouped collection visible to further clauses. Without into, a join behaves as an inner join. Mixing join and join ... into in a single query can confuse less experienced readers, but it is technically valid. The more serious surprise is that GroupJoin does not support an immediate where on the child group directly in the join clause. You must apply filtering to the group in a let or use a where clause after the join. For example, to include only employees whose names start with a letter:

var departmentsWithFilteredEmployees = from department in departments join employee in employees on department.Id equals employee.DepartmentId into employeeGroup let matchingEmployees = employeeGroup.Where(e => e.Name.StartsWith("A")) select new { Department = department.Name, Employees = matchingEmployees };

If you need the filtering to affect which parents appear, you can check whether matchingEmployees.Any() in a where clause. This is a subtle difference from SQL, where filtering in the ON clause changes the join semantics. In GroupJoin, the join itself always pairs every outer with all inner matches; filtering on the group happens after the grouping.

GroupJoin vs SelectMany with GroupBy

An alternative to GroupJoin is a regular SelectMany followed by GroupBy on the flattened sequence. That approach can build the same nested structure, but it is more verbose and can obscure intent. GroupJoin directly expresses the parent-child grouping without manually managing grouping keys. The tradeoff is that GroupJoin always preserves the outer sequence, while the SelectMany + GroupBy approach loses the outer ordering unless you re-sort. GroupJoin also makes it clearer that you are performing a left-outer-like join, which improves maintainability for future readers.

Here is a comparison of the two approaches:

ApproachResult rowsOuter element preservationCode clarity
GroupJoinOne per outer elementAlways preservedHigh
Join + GroupByOne per outer element after groupingRequires re-grouping and re-sortingLower

The table is meant to highlight the practical difference, not to claim one is always faster. In-memory, both require a hash lookup, but GroupJoin avoids an intermediate flattened sequence, which reduces allocation overhead. For database-backed queries, the translation may differ, so measure if this is the hot path.

Using GroupJoin with Immutable and Record Types

With C# records, the result selector often returns a new record that contains both the parent properties and a collection of children. Records work well because they provide value-based equality, which is useful when comparing results in tests.

public record DepartmentWithEmployees(string DepartmentName, List<string> EmployeeNames); var result = departments .GroupJoin(employees, department => department.Id, employee => employee.DepartmentId, (department, employeeGroup) => new DepartmentWithEmployees( department.Name, employeeGroup.Select(e => e.Name).ToList() ));

When using records, be careful not to put an IEnumerable<string> in a record property without materializing it. Deferred execution combined with record equality can cause unexpected re-evaluation during equality checks. Materialize to a List<string> or an array to make the record's state stable.

Common Pitfalls and How to Avoid Them

One frequent mistake is assuming that GroupJoin returns children only when there is at least one match. That assumption leads to null-reference exceptions when the group is empty. Always use .Any() or .Count() before accessing the group's first element unless you explicitly want the exception.

Another mistake is using GroupJoin on a large database table without understanding the generated SQL. In EF Core, a GroupJoin may be translated as a GroupJoin in LINQ but executed as a correlated subquery. This can generate one SQL query per parent, causing N+1 queries. To avoid this, you can write a query that explicitly uses a left join and then groups in memory, or you can use raw SQL if the provider cannot optimize efficiently. Test the query plan with your database provider before assuming that EF Core translates GroupJoin optimally.

A third issue is capturing non-repeatable inner sequences. If the inner sequence is generated by an iterator that reads from a file or a network stream, GroupJoin will read it entirely during first enumeration. If the result is enumerated again, the inner sequence is likely exhausted. Materialize the inner sequence before calling GroupJoin, or materialize the GroupJoin result itself, to ensure repeatability.

GroupJoin with Null Keys and Database Nulls

GroupJoin's key selectors work with the equality comparer used by the underlying collection, which for reference types uses EqualityComparer<T>.Default. This comparer treats null and null as equal. So if your key can be null on both sides, those nulls will match. In SQL, NULL is not equal to NULL, so a database-backed GroupJoin will not match null keys unless the provider uses a special translation. If you expect null keys to match in both worlds, normalize the key to a sentinel value such as a fallback string, or use a custom comparer if the LINQ provider supports it. Be aware that custom comparers are not translated to SQL in EF Core; they apply only in memory, so you cannot rely on them to change SQL behavior.

Sample Use Case: Building a Parent-Child Report

A practical example that captures most of the GroupJoin behavior is generating a report where each department shows its employees and indicates whether the department has any employees. The following code shows the full pattern with filtering and ordering:

var report = departments .GroupJoin(employees, department => department.Id, employee => employee.DepartmentId, (department, employeeGroup) => new { DepartmentName = department.Name, EmployeeNames = employeeGroup.Select(e => e.Name).OrderBy(name => name).ToList(), EmployeeCount = employeeGroup.Count() }) .OrderBy(item => item.DepartmentName) .ToList(); foreach (var item in report) { Console.WriteLine($"{item.DepartmentName} ({item.EmployeeCount} employees)"); foreach (var name in item.EmployeeNames) { Console.WriteLine($" - {name}"); } }

This example materializes the employee names into a list, which makes the report independent of the original employees collection. It also orders each child list and the overall report deterministically, which is important if the result is stored or compared across runs.

The resulting structure is exactly what you would typically return from an API endpoint that provides departments with their employees, and it avoids the duplicated rows that a standard join would produce.

GroupJoin is a distinctive LINQ operator that fills a specific gap between a pure inner join and a manual grouping. Its behavior of preserving the outer sequence, even with empty matches, is valuable in many reporting and data-shaping scenarios. Recognizing when to use GroupJoin, and when to use a standard join, will keep your code clear and your data shape aligned with the consumer's expectations.

c# linq groupjoin: Practical Usage and Code Examples | RYUSLOG DEV