Sorting, equality and custom comparers
OrderBy chains, sort stability, and the equality rules that quietly decide the result of Distinct, GroupBy and every set operator.
Chains and stability
// ThenBy continues the previous ordering; OrderBy would restart it
var sorted = orders
.OrderBy(o => o.Currency)
.ThenByDescending(o => o.Total)
.ThenBy(o => o.Id);
// The sort is stable: equal keys keep their relative input order
var byStatus = orders.OrderBy(o => o.Status);
// A custom comparer, without implementing IComparer<T>
var natural = names.OrderBy(n => n, StringComparer.OrdinalIgnoreCase);
// A comparer built from a key selector
var byLength = names.OrderBy(n => n, Comparer<string>.Create(
(x, y) => x.Length.CompareTo(y.Length)));
// A multi-key comparer as a class, which is what you want if it is reused
public sealed class OrderPriorityComparer : IComparer<Order>
{
public int Compare(Order? x, Order? y)
{
if (ReferenceEquals(x, y)) return 0;
if (x is null) return -1;
if (y is null) return 1;
var byStatus = StatusRank(x.Status).CompareTo(StatusRank(y.Status));
if (byStatus != 0) return byStatus;
return y.PlacedAt.CompareTo(x.PlacedAt); // newest first
}
private static int StatusRank(OrderStatus s) => s switch
{
OrderStatus.Pending => 0,
OrderStatus.Fulfilled => 1,
_ => 2,
};
}
var ranked = orders.OrderBy(o => o, new OrderPriorityComparer());OrderByis stable in LINQ to Objects, so equal elements keep their original order. In a database the stability guarantee is weaker: add a tie-breaking key or the order is undefined.- Sorting is deferred and, in LINQ to Objects, fully buffering: the first element is not available until the whole source is read.
OrderByafterThenBydiscards the chain and starts a new primary sort, which is a common and silent mistake.Reverse()on anIEnumerable<T>is also fully buffering. It is not a cheap negate of the ordering.
Equality and custom comparers
// A record gets value equality for free, based on all its properties
public sealed record Sku(string Code, int Revision);
var a = new Sku("A-1", 1);
var b = new Sku("A-1", 1);
Console.WriteLine(a == b); // true
Console.WriteLine(new[] { a }.Distinct().Count()); // 1
// A plain class uses reference equality unless you override both members
public sealed class SkuBad
{
public string Code { get; init; } = "";
public int Revision { get; init; }
}
// An explicit comparer when you cannot change the type
public sealed class SkuCodeComparer : IEqualityComparer<SkuBad>
{
public bool Equals(SkuBad? x, SkuBad? y) =>
x is not null && y is not null &&
string.Equals(x.Code, y.Code, StringComparison.Ordinal);
public int GetHashCode(SkuBad obj) =>
StringComparer.Ordinal.GetHashCode(obj.Code);
}
var comparer = new SkuCodeComparer();
var distinct = items.Distinct(comparer).ToList();
var grouped = items.GroupBy(i => i, comparer)
.ToDictionary(g => g.Key.Code, g => g.Count(), StringComparer.Ordinal);
var overlap = left.Intersect(right, comparer);
var joined = left.Join(right, l => l, r => r, (l, r) => (l, r), comparer);
// In EF Core, a custom comparer cannot be translated and moves work to the client.| Operation | What equality decides | Deserves a comparer? |
|---|---|---|
Distinct | Which elements survive | Yes for reference types without value equality |
GroupBy | Which elements share a key | Yes when grouping by a case-insensitive string |
Union | Which elements are new | Yes, and it must match the comparer used elsewhere |
Except / Intersect | Which elements are removed or kept | Yes |
Contains | Whether an element is present | Careful: this compiles to a SQL IN and often cannot |
ToDictionary | Which duplicate key throws | Pass one to choose the first or last |
⚠️
Overriding
Equals without a matching GetHashCode makes a hash-based set behave unpredictably: two elements that compare equal can land in different buckets, so Distinct stops removing duplicates. Always change both together.FAQ
Why is my ordering different on the database than in memory?
Because SQL has no stability guarantee for equal keys and the collation may differ from the .NET comparer. Always add a unique tie-breaker such as the primary key.
Does a record struct have value equality?
Yes, generated by the compiler over all fields, which makes it a good dictionary key. Watch the cost if the struct is large, since equality compares every field.
Related
Grouping, joining and set operations Common LINQ operators
Last refreshed 2026-09-18.