Back to Blog
C#

c# comparer create: Sorting and Equality Rules

c# comparer create: Learn how to create custom comparers in C# to control sorting and equality in LINQ, collections, and dictionaries.

IComparerIEqualityComparerComparer<T>SortingLINQ
Illustration of two lists being sorted and deduplicated using custom comparer logic in C#.

c# comparer create requires a clear understanding of the core syntax, runtime behavior, and practical implementation patterns demonstrated in the examples below.

When you call OrderBy or use a SortedDictionary, the .NET runtime relies on a comparer to determine ordering and equality. By default, it uses the type's default implementation, which may not reflect your business rules. Creating a custom comparer in C# gives you explicit control over these decisions. This article explains how to create and use comparers for common scenarios, and where the standard implementations fall short.

Why Create a Custom Comparer

The default comparer for a type, accessed through Comparer<T>.Default, works well for simple types like int or string where the natural ordering is obvious. For your own classes, the default comparer relies on the type implementing IComparable<T>. If the type does not implement that interface, or if you need a different ordering (case-insensitive, reverse, or based on a property), you must provide your own comparer.

For equality, EqualityComparer<T>.Default uses Object.Equals unless the type implements IEquatable<T>. This is problematic when you want to compare objects by a subset of properties, such as treating two Person instances as equal if they share the same Id regardless of name. A custom equality comparer solves this cleanly.

The IComparer<T> Interface

The IComparer<T> interface defines a single method:

public interface IComparer<in T> { int Compare(T? x, T? y); }

The Compare method returns:

  • A negative value if x is less than y.
  • Zero if x equals y.
  • A positive value if x is greater than y.

A minimal implementation for ordering by a numeric property looks like this:

public class Person { public int Id { get; set; } public string Name { get; set; } = ""; } public class PersonIdComparer : IComparer<Person> { public int Compare(Person? x, Person? y) { if (x is null) return y is null ? 0 : -1; if (y is null) return 1; return x.Id.CompareTo(y.Id); } }

Notice the null handling. A null reference is considered less than any non-null value, following the convention used by many .NET sorting methods. Omitting null checks can cause NullReferenceException when the comparer is used on a collection containing null elements.

Making the Comparer Public

In practice, exposing the comparer as a static property or a read-only field makes it easy to reuse across your application. A common pattern is:

public class Person { public int Id { get; set; } public string Name { get; set; } = ""; public static PersonIdComparer IdComparer { get; } = new PersonIdComparer(); }

Then usage becomes list.OrderBy(p => p, Person.IdComparer). This keeps the comparer near the type it is designed for, improving discoverability and maintainability.

Using Comparer<T>.Create

Since .NET Framework 4.7.2 and .NET Core 2.0, the simplest way to create a comparer without writing a dedicated class is Comparer<T>.Create. This method takes a comparison delegate and returns an IComparer<T> instance.

var comparer = Comparer<Person>.Create((x, y) => x.Id.CompareTo(y.Id)); var sortedPeople = people.OrderBy(p => p, comparer);

For more complex ordering, the delegate can call multiple properties:

var comparer = Comparer<Person>.Create((x, y) => { var nameComparison = string.Compare(x.Name, y.Name, StringComparison.OrdinalIgnoreCase); if (nameComparison != 0) return nameComparison; return x.Id.CompareTo(y.Id); });

This avoids repetitive null checks because Comparer<T>.Create wraps the delegate and handles the standard null semantics for you. It is the recommended approach for most scenarios where you need a one-off comparer and do not want to define a full class.

The IEqualityComparer<T> Interface

For equality operations such as Distinct(), Contains(), or using a HashSet<T>, implement IEqualityComparer<T>:

public class PersonIdEqualityComparer : IEqualityComparer<Person> { public bool Equals(Person? x, Person? y) { if (ReferenceEquals(x, y)) return true; if (x is null || y is null) return false; return x.Id == y.Id; } public int GetHashCode(Person obj) { return obj.Id.GetHashCode(); } }

The GetHashCode method must return the same value for two objects that are considered equal. If you base equality on Id, the hash code must be derived from Id. A common mistake is to use the base hash code or a property that is not part of the equality comparison, which breaks the hash-based collections like Dictionary<TKey, TValue>.

Using EqualityComparer<T>.Create

Similar to Comparer<T>.Create, EqualityComparer<T>.Create lets you create an equality comparer from two delegates:

var equalityComparer = EqualityComparer<Person>.Create( (x, y) => x.Id == y.Id, p => p.Id.GetHashCode());

The first delegate defines equality, the second generates the hash code. This is concise and avoids writing a full class. However, the delegate-based versions have slightly more overhead than a dedicated IComparer or IEqualityComparer class because they invoke delegates indirectly. For most applications the impact is negligible, but for high-throughput sorting of large collections, a hard-coded comparer class may perform marginally better.

Comparer and Equality in LINQ

LINQ methods like OrderBy, ThenBy, Distinct, GroupBy, and Join accept comparers. The sorting methods use IComparer<T> and IEqualityComparer<T> in the others. For example, to group people by their name case-insensitively:

var grouped = people.GroupBy(p => p.Name, StringComparer.OrdinalIgnoreCase);

Here, StringComparer.OrdinalIgnoreCase is a built-in comparer that applies the desired equality rule. Creating your own comparer for a custom type follows the same pattern: define equality, then pass it to the method.

A Practical Example: Sorting and Deduplicating

Suppose you have a list of Product objects and you want to sort them by price, then filter out duplicates based on the product's SKU. You need two comparers: one for sorting and one for equality.

public class Product { public string Sku { get; set; } = ""; public decimal Price { get; set; } } // Sorting by price ascending var priceComparer = Comparer<Product>.Create((x, y) => x.Price.CompareTo(y.Price)); var sortedProducts = products.OrderBy(p => p, priceComparer); // Equality based on SKU var skuEqualityComparer = EqualityComparer<Product>.Create( (x, y) => string.Equals(x.Sku, y.Sku, StringComparison.OrdinalIgnoreCase), p => p.Sku.ToUpperInvariant().GetHashCode()); var distinctProducts = sortedProducts.Distinct(skuEqualityComparer);

Notice that the hash code uses ToUpperInvariant() to match the case-insensitive equality. If you use ToLowerInvariant() consistently that works too, as long as both Equals and GetHashCode agree on the normalization.

Performance and Allocation Considerations

Creating a comparer instance for a single operation does not carry a significant overhead. The real cost lies in the work the comparer does per element. When sorting a list, the comparer is called multiple times per element (roughly O(n log n) calls). Therefore, the implementation should be cheap: avoid string parsing or reflection inside the Compare method if possible.

Also be aware of boxing. If your comparer is generic (e.g., IComparer<int>) and you pass a value type, the calls are generic and do not box. But if you use a non-generic IComparer, value types are boxed, incurring allocation. Stick with the generic interfaces for value types.

Another subtle point: the StringComparison you choose matters. StringComparison.Ordinal is faster than culture-aware comparisons because it does not consult the thread's culture. For machine-based sorting or case-insensitive matching where culture semantics are not required (such as SKUs, email addresses, or file names), Ordinal is preferable.

Common Pitfalls with Custom Comparers

A frequent mistake is returning only 1 or -1 without considering equality. If two elements are equal, Compare must return 0, or the sorting algorithm may behave unpredictably, including infinite loops in some implementations. Always return 0 when the two compared objects are equivalent according to your rule.

Another issue is inconsistent hash codes. If the property used for equality changes after the object is added to a HashSet or used as a dictionary key, the object becomes unreachable by lookup because its hash code no longer matches the bucket. For mutable objects, either make the key properties immutable or avoid using them as keys.

When implementing IComparer<T> for nullable types, remember that CompareTo on nullable value types behaves differently than on reference types. int? has a CompareTo that treats null as less than any non-null value, but if you write a custom rule, you must decide how to handle null explicitly.

When to Choose a Dedicated Class Over Create

The delegate-based Create methods are convenient, but they have a limitation: they are not serializable and cannot be reused across disconnected boundaries like web services or distributed caches. If your comparer contains additional logic, such as a fallback to a secondary sort key, a dedicated class makes that logic explicit and testable.

Also, a dedicated class can implement both IComparer<T> and IEqualityComparer<T> if needed. For example, a ProductComparer that defines both ordering by price and equality by SKU can be passed to both sorting and grouping operations without duplicating logic. This reduces the number of comparer types you maintain.

In a production environment, you often need to log or debug how comparisons are made. A dedicated class gives you a natural place to add tracing without altering the consumer code. For example, you might add a conditional Trace.WriteLine inside Compare to capture unexpected ordering results. The delegate-based approach makes this harder because you would have to wrap the delegate with additional logic.

For most development efforts, starting with Comparer<T>.Create is the pragmatic choice. If you later find the comparer needs to be reused in multiple places or evolves beyond a simple comparison, refactoring it into a dedicated class is straightforward.

Comparer Behavior with Null and Default Values

When the comparer is used in List<T>.Sort or OrderBy, the runtime expects the comparer to handle null elements consistently. The pattern used by the BCL is to consider null as less than any non-null reference. If your comparer does not account for null, the Compare method will throw a NullReferenceException when it encounters a null element.

The Comparer<T>.Create method automatically handles null for you: it checks for null before invoking your delegate. If x is null and y is not, it returns -1; if both are null, it returns 0; if y is null and x is not, it returns 1. This matches the standard behavior, so you do not need to repeat null checks inside the delegate.

For value types, nullability is handled through Nullable<T>, which has a defined comparison order: a null value is less than any non-null value. This is consistent with the reference type behavior, so you can rely on default null semantics when using value types.

Final Implementation: A Reusable Comparer Class

To illustrate a production-ready pattern, consider a comparer that sorts by a primary key and then a secondary key, while also providing equality based on the same keys:

public class PersonComparer : IComparer<Person>, IEqualityComparer<Person> { public int Compare(Person? x, Person? y) { if (ReferenceEquals(x, y)) return 0; if (x is null) return -1; if (y is null) return 1; var lastNameComparison = string.Compare(x.LastName, y.LastName, StringComparison.Ordinal); if (lastNameComparison != 0) return lastNameComparison; return string.Compare(x.FirstName, y.FirstName, StringComparison.Ordinal); } public bool Equals(Person? x, Person? y) { if (ReferenceEquals(x, y)) return true; if (x is null || y is null) return false; return string.Equals(x.LastName, y.LastName, StringComparison.Ordinal) && string.Equals(x.FirstName, y.FirstName, StringComparison.Ordinal); } public int GetHashCode(Person obj) { unchecked { var hash = 17; hash = hash * 31 + obj.LastName.GetHashCode(); hash = hash * 31 + obj.FirstName.GetHashCode(); return hash; } } }

This class can be used with OrderBy, Distinct, GroupBy, and as a key comparer for a Dictionary. It is testable, explicit, and follows the same null-handling conventions as the BCL. Use it when you need a comparer that will be reused across multiple operations or when the comparison logic is complex enough to warrant its own unit tests.

While creating a comparer with Comparer<T>.Create is the quickest path, understanding the underlying interfaces and their semantics ensures your code behaves correctly in all scenarios, especially with nulls, hash codes, and culture-specific string comparisons.

c# comparer create: Sorting and Equality Rules | RYUSLOG DEV