Comparing and Sorting: IComparable and IComparer
Objective
Sorting in .NET rests on one contract: a comparison returns a negative number,
zero or a positive number. IComparable<T> puts it on the type, IComparer<T>
and Comparison<T> put it outside, and Comparer<T>.Default picks one when
you pass nothing. Breaking it rarely fails loudly: a comparer that disagrees
with Equals merges items in a SortedSet<T>, List<T>.Sort reorders equal
keys, and BinarySearch on unsorted data returns a wrong index. This concept
shows each on .NET 10 / C# 14, plus nulls, the exceptions, multi-key ordering,
NaN, DateTimeOffset, spans and the measured cost of each comparer style.
Use Cases
- A natural order for a value type like
Money, soSort()needs no argument. - Sorting one entity several ways (by name, by date) without changing the type.
- Keeping a list sorted and inserting with
BinarySearchinstead of re-sorting. - Diagnosing "the sort moved my rows" or "the set lost an item" bugs.
Deep Dive
The sign contract and the three shapes
The docs define only the sign, and implementations return other magnitudes:
test < 0, never == -1, and use CompareTo, not subtraction (it overflows):
csharp"a".CompareTo("b"); string.CompareOrdinal("a", "c"); ((byte)1).CompareTo((byte)9); // -1 -2 -8
int[] xs = [5, int.MinValue, -3, int.MaxValue, 0];
Array.Sort(xs, (a, b) => a - b); // 5, 2147483647, -2147483648, -3, 0
Array.Sort(xs, (a, b) => a.CompareTo(b)); // -2147483648, -3, 0, 5, 2147483647
readonly record struct Money(decimal Amount) : IComparable<Money>
{ public int CompareTo(Money other) => Amount.CompareTo(other.Amount); }
people.Sort(new ByAge()); // a class implementing IComparer<Person>
people.Sort((a, b) => b.Name.CompareTo(a.Name)); // a Comparison<Person> lambda
var byAgeDesc = Comparer<Person>.Create((a, b) => b.Age.CompareTo(a.Age)); // lambda to IComparerComparer<T>.Create serves APIs that only take an IComparer<T>. The default
is GenericComparer`1 for int and Person, NullableComparer`1 for
int? and ObjectComparer`1 for a class that implements nothing.
Nulls and types with no order
The default comparer handles nulls before calling your code (the IComparer<T>
docs: null is less than any non-null reference). A lambda gets no such help,
and a type with no IComparable fails at the first comparison:
csharpvar list = new List<Person?> { new("Caio", 30), null, new("Ana", 25), null };
list.Sort(); // null, null, Ana(25), Caio(30)
list.Sort((a, b) => a!.Age.CompareTo(b!.Age));
// InvalidOperationException: Failed to compare two elements in the array.
// InnerException: NullReferenceException
new List<Plain> { new(1), new(2) }.Sort(); // same for Array.Sort and OrderBy(p => p)
// InvalidOperationException: Failed to compare two elements in the array.
// InnerException: ArgumentException: At least one object must implement IComparable.
new SortedSet<Plain> { new(1), new(2) }; // the ArgumentException, not wrappedA one-element list sorts without error. Records get equality but no ordering:
record Rec(int X) and record struct RS(int X) throw the same from Sort().
Generic code asks for IComparable<T> (or the generic math
IComparisonOperators<T, T, bool> for <), so with Max<T> constrained
where T : IComparable<T> the compiler reports the gap:
textr1 < r2 error CS0019: Operator '<' cannot be applied to operands of type 'Rec' and 'Rec' Max([r1, r2]) error CS0311: ... no implicit reference conversion from 'Rec' to 'System.IComparable<Rec>'. Max([s1, s2]) error CS0315: ... no boxing conversion from 'RS' to 'System.IComparable<RS>'.
A comparer that breaks the rules
A value must equal itself and a pair must always get the same answer. Three broken comparisons on 10, 100 and 10,000 ints gave:
text(a, b) => 1 no exception at any size, output not sorted (a, b) => -1 n=10 not sorted; n=100 and n=10000 throw random -1/0/1 n=10 and n=100 not sorted; n=10000 throws ArgumentException: Unable to sort because the IComparer.Compare() method returns inconsistent results. Either a value does not compare equal to itself, or one value repeatedly compared to another value yields different results. IComparer: 'System.Comparison`1[System.Int32]'.
The exception (no inner exception) is a lucky detection, not a guarantee. A
realistic way to get there is comparing doubles with operators, which are all
false for NaN, while double.CompareTo puts NaN below every value:
csharpdouble[] v = [3, double.NaN, 1, 2];
Array.Sort(v, (x, y) => x < y ? -1 : x > y ? 1 : 0); // 3 NaN 1 2: nothing moved
Array.Sort(v); // NaN 1 2 3CompareTo, Equals and the sorted collections
Sorted collections find duplicates with the comparer alone, so a CompareTo
that returns 0 for objects Equals calls different loses data:
csharpsealed record Emp(string Name, int Age) : IComparable<Emp> // CompareTo ignores Name
{ public int CompareTo(Emp? o) => o is null ? 1 : Age.CompareTo(o.Age); }
var ana = new Emp("Ana", 30); var bia = new Emp("Bia", 30); // CompareTo 0, Equals False
var set = new SortedSet<Emp> { ana, bia, new("Caio", 25) }; // Count 2 (HashSet: 3)
var dict = new SortedDictionary<Emp, int> { [ana] = 1, [bia] = 2 }; // Count 1: Ana(30) = 2
dict.Add(new Emp("X", 30), 3);
// ArgumentException: An item with the same key has already been added. Key: [X(30), 3]
var desc = new SortedSet<int>(Comparer<int>.Create((x, y) => y.CompareTo(x))) { 5, 1, 9 };
// 9, 5, 1 and Min = 9: Min and Max follow the comparerBinarySearch(bia) on the sorted list Caio, Ana, Bia, Duda returned 1
(Ana), while IndexOf(bia) (which uses Equals) returned 2. Make the order
total (add Name as a tiebreaker) or keep it in a named IComparer<T>.
SortedDictionary and SortedList take a comparer too: with
OrdinalIgnoreCase, setting "B" updated the key "b".
Stability and multi-key ordering
The List<T>.Sort docs: "performs an unstable sort; that is, if two elements
are equal, their order might not be preserved" (introsort, with insertion sort
for partitions of 16 or fewer). Orders with ids 0..39, Priority = Id % 3:
csharplist.Sort((x, y) => x.Priority.CompareTo(y.Priority)); // Array.Sort: the same
// priority 0 ids: 0,36,33,30,27,24,21,18,15,12,39,9,3,6
orders.OrderBy(o => o.Priority); // documented as stable
// priority 0 ids: 0,3,6,9,12,15,18,21,24,27,30,33,36,39Up to 16 items it looked stable; the first reordering came at n = 17, so small
unit tests miss it. A stable in-place sort needs the original index as the last
key (or a LINQ sort copied back). For several keys a comparer chain printed the
same as ThenBy: the chain sorts in place and is reusable by SortedSet<T>,
ThenBy allocates but is stable (see LINQ Operator Families and Query Syntax):
csharpindexed.Sort((x, y) => x.o.Priority != y.o.Priority // (o, i) pairs
? x.o.Priority.CompareTo(y.o.Priority) : x.i.CompareTo(y.i)); // stable
staff.Sort(Comparer<Person>.Create((x, y) => y.Age != x.Age
? y.Age.CompareTo(x.Age) : StringComparer.Ordinal.Compare(x.Name, y.Name)));
staff.OrderByDescending(p => p.Age).ThenBy(p => p.Name, StringComparer.Ordinal);
// both: Ana(30), Bia(30), Caio(30), Duda(25), ana(25)BinarySearch and the bitwise complement
A hit returns the index. A miss returns a negative number whose complement
(~) is the index of the next larger element, or Count if there is none:
csharpvar sorted = new List<int> { 10, 20, 30, 40 };
sorted.BinarySearch(30); // 2
sorted.BinarySearch(25); // -3, ~(-3) = 2: before 30
sorted.BinarySearch(5); // -1, ~(-1) = 0: before everything
sorted.BinarySearch(50); // -5, ~(-5) = 4: after the end
int r = sorted.BinarySearch(25); if (r < 0) sorted.Insert(~r, 25); // 10,20,25,30,40
new List<int> { 40, 10, 30, 20, 50 }.BinarySearch(10); // -1, though 10 is at index 1
Array.BinarySearch([1, 2, 2, 2, 2, 2, 3], 2); // 3: any duplicate may come backSearch with the sort's comparer: in apple, Banana, cherry sorted with
OrdinalIgnoreCase, "BANANA" was at 1 with it and -3 with the default.
Strings, doubles and DateTimeOffset
Ten strings sorted with each comparer, culture en-US, ICU on macOS:
textOrdinal 10 9 A B Z _x a b e é OrdinalIgnoreCase 10 9 a A b B e Z _x é CurrentCulture, InvariantCulture, none _x 10 9 a A b B e é Z
The default is cultural (under sv-SE a plain Sort() put ä after z), and
"10" precedes "9" in all (see the strings concept). Doubles and dates:
csharpdouble.NaN.CompareTo(double.NegativeInfinity); // -1, while NaN < -Infinity is False
double.NaN.CompareTo(double.NaN); // 0, while NaN == NaN is False
(-0.0).CompareTo(0.0); // 0: sorted zeros came out as 0 -0 0 -0
var utc = new DateTimeOffset(2026, 10, 28, 12, 0, 0, TimeSpan.Zero);
var brt = new DateTimeOffset(2026, 10, 28, 9, 0, 0, TimeSpan.FromHours(-3));
Console.WriteLine($"{utc.CompareTo(brt)} {utc == brt} {utc.EqualsExact(brt)}"); // 0 True False
// sorting orders by instant; OrderBy(d => d.DateTime) by clock reading insteadSpans, key/item arrays and what each style costs
MemoryExtensions.Sort sorts a slice in place: big.AsSpan(1, 3).Sort() turned
50,40,30,20,10 into 50,20,30,40,10. Array.Sort(keys, items) moves a second
array in step: keys 3,1,2 with items c,a,b became 1,2,3 and a,b,c.
Sorting 1,000,000 random ints and 1,000,000 two-int structs (by key), Release build, 5 warmups then the median of 15 runs, Apple Silicon, .NET 10.0.0, with other processes on the machine (allow about 10%):
| Sort | int[] | struct[] |
|---|---|---|
Array.Sort(a) (default or IComparable<T>) |
about 49 ms | about 51 ms |
Array.Sort(a, Comparison<T> lambda) |
about 55 ms | about 59 ms |
Array.Sort(a, IComparer<T> class) |
about 77 ms | about 87 ms |
span.Sort(structComparer) |
about 85 ms | about 94 ms |
Order().ToArray() / Array.Sort(keys, items) |
about 49 ms | about 50 ms |
OrderBy(...).ToArray() |
about 106 ms | about 107 ms |
The default was fastest, also with Comparer<int>.Default passed (about 49 ms).
A lambda cost about 12 to 16% more, a comparer class more still, and a struct
comparer gained nothing: the runtime source of Span.Sort<T, TComparer> says
"value-type comparer will be boxed". OrderBy cost about twice the in-place
sort, while Order() on int matched Array.Sort.
Trade-offs
- A natural order is a promise to every caller. Keep it to one obvious,
total order consistent with
Equals; anything else is a namedIComparer<T>. - In-place sorts are fast and unstable. They reordered ties from 17 items
up. Use
OrderBy/ThenByor an index tiebreaker when ties must keep order. - A broken comparer may not throw. The "inconsistent results" exception is
occasional;
(a, b) => 1produced unsorted output with no error, and operator comparisons ondoublebreak onNaN. - Sorted collections deduplicate by the comparer, not
Equals. A comparer that ignores a field merges set items and overwrites dictionary values. BinarySearchtrusts you. Badly sorted input gives a wrong index, silently.- Struct comparers do not pay off.
Span.Sortboxes them; a lambda was faster.