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
OperatorDuplicatesOrderSet semantics
UnionRemovedInput order, first occurrence winsUnion
ConcatKeptFirst then secondNone
IntersectRemovedInput order of the first sequenceIntersection
ExceptRemovedInput order of the first sequenceDifference
DistinctRemovedFirst occurrence orderNone
SequenceEqualKeptCompared position by positionEquality of sequences
  • The default comparer for a custom class is reference equality unless the class overrides Equals and GetHashCode. 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.
  • GroupJoin plus SelectMany with DefaultIfEmpty is 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.

Projection and shaping results Sorting, equality and custom comparers

Last refreshed 2026-09-18.