C# Custom Sorting with Comparers and LINQ
Learn how to implement c# custom sorting using IComparer, Comparison delegates, and LINQ OrderBy with practical examples and performance considerations.
c# custom sorting requires a clear understanding of the core syntax, runtime behavior, and practical implementation patterns demonstrated in the examples below.
When you need to sort a list by a property that doesn't have a natural ordering, or when the default alphabetical and numeric rules don't match your business logic, you have to provide your own comparison logic. In C#, custom sorting typically involves one of three mechanisms: the IComparer<T> interface, the Comparison<T> delegate, or the LINQ OrderBy family of methods. Each approach has its place, and the choice affects readability, reusability, and performance.
Using the IComparer<T> Interface
The IComparer<T> interface is the classic way to define a reusable sorting rule. It has a single method, Compare(T x, T y), which returns a negative value if x comes before y, zero if they are equal, and a positive value if x comes after y. This method is used by List<T>.Sort() and by Array.Sort(), as well as by some collection classes like SortedDictionary<TKey, TValue>.
Here is a simple example that sorts a list of Person objects by LastName and then FirstName:
public class Person { public string FirstName { get; set; } public string LastName { get; set; } } public class PersonLastNameComparer : IComparer<Person> { public int Compare(Person x, Person y) { if (x == null && y == null) return 0; if (x == null) return -1; if (y == null) return 1; int lastNameComparison = string.Compare(x.LastName, y.LastName, StringComparison.Ordinal); if (lastNameComparison != 0) return lastNameComparison; return string.Compare(x.FirstName, y.FirstName, StringComparison.Ordinal); } }
Notice that the implementation handles null references explicitly. The default List<T>.Sort() accepts null elements, and your comparer must decide how to order them. In this example, null sorts before any non-null object. If you don't handle null, your code may throw a NullReferenceException when the list contains null entries.
To use this comparer, create an instance and pass it to Sort:
List<Person> people = GetPeople(); people.Sort(new PersonLastNameComparer());
The IComparer<T> instance can be shared across multiple sorting calls, and it's a good candidate for dependency injection if your sorting rules are part of a service layer. Because the comparer itself is a class, it can encapsulate complex rule sets, including grouping, locale-specific ordering, or even comparing multiple properties in a specific priority order.
Using the Comparison<T> Delegate
For one-off sorts, the Comparison<T> delegate is more concise. Instead of creating a separate class, you supply a method that follows the same contract as Compare. This is useful when the sorting logic is short and doesn't need to be reused elsewhere.
List<Person> people = GetPeople(); people.Sort((x, y) => { int lastNameComparison = string.Compare(x.LastName, y.LastName, StringComparison.Ordinal); if (lastNameComparison != 0) return lastNameComparison; return string.Compare(x.FirstName, y.FirstName, StringComparison.Ordinal); });
The lambda expression is converted to a Comparison<Person> delegate. This keeps the logic close to the point of use, making the code easier to read when the sorting rule is obvious from the context. However, if the same logic is needed in several places, you should extract it into a named method or an IComparer<T> implementation to avoid duplication.
One subtlety: the Comparison<T> overload of List<T>.Sort() modifies the list in place. If you need to keep the original order, you must copy the list first.
Using LINQ OrderBy and ThenBy
LINQ provides a functional, query-based way to sort sequences. OrderBy sorts a sequence by a key, and ThenBy provides secondary keys. Unlike List<T>.Sort(), LINQ methods return a new ordered sequence and do not modify the original collection.
List<Person> people = GetPeople(); IEnumerable<Person> sorted = people .OrderBy(p => p.LastName, StringComparer.Ordinal) .ThenBy(p => p.FirstName, StringComparer.Ordinal);
OrderBy and ThenBy have an overload that accepts an IComparer<T> for the key type. In the example above, StringComparer.Ordinal is a built-in comparer that performs a case-sensitive ordinal comparison. If you need a custom rule for the key, you can pass your own IComparer<T>.
LINQ's ordering is stable: elements with equal keys retain their original relative order. This is not guaranteed for List<T>.Sort(), which uses an unstable sort algorithm (introsort). Stability matters when you sort by a primary key that is not unique and you want to preserve a previous secondary ordering.
The tradeoff is that LINQ's OrderBy creates an additional ordered enumerable, which may allocate memory and add overhead if the collection is large and the ordering sequence is enumerated only once. In most applications, the readability and composability outweigh the cost, but for high-throughput scenarios you should measure the difference.
Sorting Strings with a Custom Comparison
String sorting is a common case where default culture-sensitive behavior can cause surprises. The string.Compare method has overloads that accept a StringComparison value. Using StringComparison.Ordinal gives a comparison based on Unicode code points, which is fast and deterministic across machines. Culture-aware comparisons are locale-dependent and may order "café" differently on different systems.
Here's an example that sorts a list of strings by length, and then alphabetically:
List<string> words = new List<string> { "apple", "fig", "banana", "date" }; words.Sort((a, b) => { int lengthComparison = a.Length.CompareTo(b.Length); if (lengthComparison != 0) return lengthComparison; return string.Compare(a, b, StringComparison.Ordinal); });
This demonstrates that you are not limited to comparing the objects themselves; you can derive keys from them. The Comparison<T> delegate gives you full control over the comparison logic, so you can compute any key on the fly.
Handling Null and Edge Cases in Compare Logic
When writing a custom comparer, you must decide how to handle null references. The Compare method receives references that may be null, especially if the list contains null elements or if you're comparing keys that can be null. A robust implementation should handle both arguments being null, one being null, and non-null values.
Another edge case is when two elements are considered equal according to all keys, but they are not the same object. For sorts, returning 0 is correct; the sort will treat them as equal and may leave them in any order (unless using a stable sort like LINQ's). For binary searches or sorted sets, you may need to consider identity to ensure uniqueness, but for simple sorting, equality is sufficient.
public class NullSafeComparer : IComparer<string> { public int Compare(string x, string y) { if (x == null && y == null) return 0; if (x == null) return -1; if (y == null) return 1; return string.Compare(x, y, StringComparison.Ordinal); } }
In this example, the null-safe comparer is generic enough to be reused for any string list. When writing comparers for your own types, always consider the domain rules: if null means "unknown", you might want to sort it to the end instead of the beginning.
Custom Sorting in Sorted Collections and Lookups
SortedDictionary<TKey, TValue> and SortedSet<T> use an IComparer<T> to maintain order. If you need to store objects in a sorted collection with custom ordering, you can provide your comparer at construction time.
SortedDictionary<Person, string> directory = new SortedDictionary<Person, string>(new PersonLastNameComparer());
This is useful when you want automatic sorting on every insertion. However, note that the comparer must be consistent with the object's equality; if Compare returns 0 for two objects that are not Equals, the dictionary will treat them as duplicates and may throw or overwrite the value depending on the implementation. Ensure that the comparer and the type's Equals and GetHashCode agree if you plan to use the object as a key.
For lookups, you can also use List<T> with a custom comparer for binary search. The List<T>.BinarySearch(T item, IComparer<T> comparer) method assumes the list is already sorted according to that comparer. This is a way to use a sorted list for fast retrieval, but you must keep the list sorted as you add elements.
Performance and Stability Considerations
The List<T>.Sort() method uses an unstable introspective sort, which has O(n log n) average complexity. In-place sorting avoids extra allocations, which is beneficial for large lists. However, because it is unstable, equal elements may be rearranged. If you need a stable sort (preserving the original order of equal elements), you should use LINQ's OrderBy or an explicit stable sort algorithm.
For very large collections, custom sorting can become a bottleneck, especially if the comparison logic invokes expensive operations, such as database calls or complex string manipulations. In such cases, consider extracting a sortable key once, prior to sorting. For example, you can project your objects to a tuple of key fields and sort the tuples, then reconstruct the original objects if needed.
Improving performance by modifying the comparison logic itself is rarely effective; the biggest wins come from avoiding repeated calculations. If you need to sort by a property that is expensive to compute, you might want to precompute the key for each element and then sort by that key using a comparer that simply compares the keys.
When to Choose Each Sorting Approach
The following table summarizes the typical use cases for each method:
| Approach | Best For | Stability | In-Place? | Reusable? |
|---|---|---|---|---|
IComparer<T> | Complex, reusable rules; sorted collections | Unstable | Yes | Yes |
Comparison<T> | One-off custom logic in a single scope | Unstable | Yes | No |
LINQ OrderBy/ThenBy | Query-based composition and stable order | Stable | No | Lambda-based |
Use IComparer<T> when you have a sorting rule that appears in multiple places or when you work with sorted collection classes like SortedDictionary or SortedSet. Use Comparison<T> when you need a quick custom sort in a local method and the logic is short. Use LINQ OrderBy when you want to chain multiple ordering criteria in a declarative style and when the stability guarantee matters.
In production code, the choice also affects maintainability. An IComparer<T> class is easy to unit-test in isolation. You can create test cases that exercise different combinations of nulls and edge values. A lambda that lives inside a method is harder to test without extracting it. If your sorting rule is part of a business rule that may change, encapsulating it in a class gives you a single place to update.
A Complete Example: Sorting by Priority and Date
Consider a scenario where you have a list of tasks, each with a priority (High, Medium, Low) and a due date. You want to sort them first by priority (High first), then by the earliest due date. A custom comparer with an explicit priority ranking is a clean solution:
public enum Priority { Low = 0, Medium = 1, High = 2 } public class TaskItem { public string Name { get; set; } public Priority Priority { get; set; } public DateTime DueDate { get; set; } } public class TaskPriorityDateComparer : IComparer<TaskItem> { public int Compare(TaskItem x, TaskItem y) { if (x == null && y == null) return 0; if (x == null) return -1; if (y == null) return 1; // Sort by priority descending (High = 2 first) int priorityComparison = y.Priority.CompareTo(x.Priority); if (priorityComparison != 0) return priorityComparison; // Then by due date ascending return x.DueDate.CompareTo(y.DueDate); } }
You can then use this comparer with List<T>.Sort() or pass it to LINQ's OrderBy if you need a stable sort. Notice how the comparer encapsulates the domain rules; any change to the priority order or date logic only affects this class.
This example illustrates how custom sorting in C# is not just about calling Sort—it's about defining a reusable comparison strategy that matches your business rules and hosting it in a maintainable location.