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());
  • OrderBy is 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.
  • OrderBy after ThenBy discards the chain and starts a new primary sort, which is a common and silent mistake.
  • Reverse() on an IEnumerable<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.
OperationWhat equality decidesDeserves a comparer?
DistinctWhich elements surviveYes for reference types without value equality
GroupByWhich elements share a keyYes when grouping by a case-insensitive string
UnionWhich elements are newYes, and it must match the comparer used elsewhere
Except / IntersectWhich elements are removed or keptYes
ContainsWhether an element is presentCareful: this compiles to a SQL IN and often cannot
ToDictionaryWhich duplicate key throwsPass 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.

Grouping, joining and set operations Common LINQ operators

Last refreshed 2026-09-18.