Grouping, joining and set operations
GroupBy returns a sequence of groups, joins have two syntaxes, and set operations compare with the default equality comparer unless you say otherwise.
GroupBy and its result shape
// The result is IEnumerable<IGrouping<TKey, TElement>>
IEnumerable<IGrouping<string, Order>> groups =
orders.GroupBy(o => o.Currency);
foreach (var g in groups)
{
Console.WriteLine(g.Key + ": " + g.Count());
foreach (var o in g) Console.WriteLine(" " + o.Id);
}
// Project immediately: a group is only useful while you are inside it
var summary = orders
.GroupBy(o => new { o.Currency, o.Status })
.Select(g => new
{
g.Key.Currency,
g.Key.Status,
Count = g.Count(),
Total = g.Sum(o => o.Total),
Newest = g.Max(o => o.PlacedAt),
})
.OrderByDescending(x => x.Total)
.ToList();
// Query syntax adds the into continuation
var byYear = from o in orders
group o by o.PlacedAt.Year into year
orderby year.Key descending
select new { Year = year.Key, Total = year.Sum(x => x.Total) };
// In EF Core, GroupBy translates to GROUP BY only when the projection is
// something the provider can express. A group you enumerate per key does not.Joins and set operations
// Query syntax: inner join
var inner = from o in orders
join p in products on o.Sku equals p.Sku
select new { o.Id, p.Name, o.Total };
// A left join needs DefaultIfEmpty
var left = from o in orders
join p in products on o.Sku equals p.Sku into matches
from p in matches.DefaultIfEmpty()
select new { o.Id, ProductName = p?.Name ?? "(unknown)" };
// Method syntax for the same left join
var left2 = orders.GroupJoin(
products, o => o.Sku, p => p.Sku, (o, matches) => new { Order = o, Matches = matches })
.SelectMany(
x => x.Matches.DefaultIfEmpty(),
(x, p) => new { x.Order.Id, ProductName = p?.Name ?? "(unknown)" });
// Set operations use the default equality comparer unless you pass one
var a = new[] { 1, 2, 2, 3 };
var b = new[] { 3, 4 };
var union = a.Union(b); // 1, 2, 3, 4
var intersect = a.Intersect(b); // 3
var except = a.Except(b); // 1, 2
var distinct = a.Distinct(); // 1, 2, 3
var concat = a.Concat(b); // 1, 2, 2, 3, 3, 4 (no deduplication)
// SequenceEqual is order sensitive; the set operators are not
var sameContents = new[] { 1, 2, 3 }.SequenceEqual(new[] { 3, 2, 1 }); // false| Operator | Duplicates | Order | Set semantics |
|---|---|---|---|
Union | Removed | Input order, first occurrence wins | Union |
Concat | Kept | First then second | None |
Intersect | Removed | Input order of the first sequence | Intersection |
Except | Removed | Input order of the first sequence | Difference |
Distinct | Removed | First occurrence order | None |
SequenceEqual | Kept | Compared position by position | Equality of sequences |
- The default comparer for a custom class is reference equality unless the class overrides
EqualsandGetHashCode. Two orders with the same id are different objects by default. - For EF Core, a join expressed as a navigation property usually produces better SQL than an explicit join, because the provider knows the relationship.
GroupJoinplusSelectManywithDefaultIfEmptyis the left-join idiom in method syntax, and it is worth learning by name rather than reconstructing it each time.- Set operators learn the key set of the second sequence before yielding the first result, which means they cannot stream.
💡
A group is a live view over the source, so it can be enumerated more than once with the same cost each time. Project into a concrete summary as soon as you have finished with the group rather than storing the grouping objects.
FAQ
Why does my join return fewer rows than expected?
Because a join is an inner join: rows with no match disappear. Use the DefaultIfEmpty pattern for a left join, and check for duplicate keys on the right-hand side, which multiply rows.
Is GroupBy in EF Core translated to SQL?
Only for projections that map to a GROUP BY, such as a count or a sum per key. Enumerating the members of each group requires the provider to fetch all the rows, which is usually a signal to reshape the query.
Related
Projection and shaping results Sorting, equality and custom comparers
Last refreshed 2026-09-18.