BrandGhost
LINQ Ordering in C#: OrderBy, ThenBy, Order, and Custom Comparers

LINQ Ordering in C#: OrderBy, ThenBy, Order, and Custom Comparers

LINQ ordering in C# is how you sort collections in a declarative, composable way without writing comparison loops or calling Array.Sort with custom delegates. The OrderBy, OrderByDescending, ThenBy, and ThenByDescending operators have been staples since LINQ's introduction. .NET 7 added Order() and OrderDescending() to remove the redundant x => x key selector when sorting naturally comparable types. And for advanced scenarios, custom IComparer<T> implementations let you control sort behavior precisely -- from locale-aware string sorting to multi-tier business rules.

This article covers every LINQ ordering operator with before/after comparisons for .NET 7 additions, practical guidance on stable sort semantics, StringComparer usage, and custom comparers -- all with realistic domain examples.


OrderBy and OrderByDescending: Single-Key Sorting

OrderBy(keySelector) returns elements in ascending order of the projected key. OrderByDescending(keySelector) returns them in descending order. Both return IOrderedEnumerable<T>, which supports further sorting with ThenBy.

namespace ECommerce.Inventory;

public sealed record Product(
    string Sku,
    string Name,
    decimal Price,
    int Stock,
    DateTimeOffset AddedAt);

// Sort by price ascending
IOrderedEnumerable<Product> byPriceAsc =
    products.OrderBy(p => p.Price);

// Sort by date added, newest first
IOrderedEnumerable<Product> newestFirst =
    products.OrderByDescending(p => p.AddedAt);

// Chaining with other LINQ operators -- ordering is lazy
IEnumerable<string> topFiveNames =
    products
        .Where(p => p.Stock > 0)
        .OrderBy(p => p.Price)
        .Take(5)
        .Select(p => p.Name);

OrderBy is deferred -- the sort does not run until you iterate the result. When combined with Take or First, the runtime must still sort the entire sequence before picking the top results (unlike a database query that can use an index). For large in-memory collections, prefer MinBy / MaxBy when you only need the single minimum or maximum element.


ThenBy and ThenByDescending: Secondary and Tertiary Sorts

ThenBy and ThenByDescending add secondary sort keys. They are only valid on an IOrderedEnumerable<T> -- the result of OrderBy or OrderByDescending. You can chain as many ThenBy calls as you need.

namespace ECommerce.Catalog;

public sealed record Employee(
    string Department,
    string LastName,
    string FirstName,
    decimal Salary,
    DateTimeOffset HireDate);

// Primary: department ascending
// Secondary: salary descending within each department
// Tertiary: last name ascending within same department + salary
IOrderedEnumerable<Employee> sortedEmployees =
    employees
        .OrderBy(e => e.Department)
        .ThenByDescending(e => e.Salary)
        .ThenBy(e => e.LastName);

// Common mistake: calling OrderBy twice -- second OrderBy resets the sort
var wrongSort =
    employees
        .OrderBy(e => e.Department)
        .OrderBy(e => e.Salary);    // WRONG: discards the department sort

// Correct: use ThenBy for secondary keys
var correctSort =
    employees
        .OrderBy(e => e.Department)
        .ThenBy(e => e.Salary);     // Correct: department primary, salary secondary

Calling OrderBy on an already-ordered sequence throws away all previous sorting. This is a common bug -- always use ThenBy for secondary sorts.


.NET 7: Order() and OrderDescending()

Before .NET 7, sorting a collection of naturally comparable values (integers, strings, dates, decimals) required a trivial x => x key selector:

namespace ECommerce.Analytics;

// Before .NET 7 -- redundant identity key selector
IEnumerable<decimal> sortedPricesOld = prices.OrderBy(x => x);
IEnumerable<string> sortedSkusOld = skus.OrderByDescending(x => x);
IEnumerable<DateTimeOffset> sortedDatesOld = dates.OrderBy(x => x);

// .NET 7 -- Order() and OrderDescending() remove the boilerplate
IEnumerable<decimal> sortedPrices = prices.Order();
IEnumerable<string> sortedSkusDesc = skus.OrderDescending();
IEnumerable<DateTimeOffset> sortedDates = dates.Order();

Order() and OrderDescending() work on any type implementing IComparable<T>. For complex types, you still use OrderBy(keySelector) -- the new operators are specifically for the common case where you want to sort a sequence of primitives or value types by their natural ordering.

namespace ECommerce.Reporting;

// Practical example: sort a sequence of IDs before batching
IEnumerable<int> orderedIds = customerIds.Order();

// Sort extracted values before display
IEnumerable<string> sortedTags =
    products
        .SelectMany(p => p.Tags)
        .Distinct()
        .Order();

// Sort DateTimeOffset values -- natural ordering is chronological
IEnumerable<DateTimeOffset> sortedTimestamps = eventTimestamps.Order();

Stable Sort Semantics

LINQ's sorting operators are stable -- when two elements compare as equal by the sort key, they retain their original relative order from the input sequence. This matters when you have a secondary sort that wasn't specified:

namespace ECommerce.Fulfillment;

// Orders arrive in the sequence: [O1 (high), O2 (low), O3 (high), O4 (low)]
// Sorting by priority preserves the original arrival order within each priority group
var stableSort = orders.OrderByDescending(o => o.Priority);

// Output order: [O1 (high), O3 (high), O2 (low), O4 (low)]
// O1 appears before O3 because it came first in the input -- stable sort preserved this

Stable sort is critical in scenarios like leaderboards (ties shown in sign-up order), product listings (equal-priced items in catalog order), or audit logs (same-priority events in arrival order).


Custom IComparer<T>: Controlling Sort Logic

When the natural ordering of a type is insufficient, implement IComparer<T> and pass it to OrderBy:

namespace ECommerce.Catalog;

public sealed record Product(string Sku, string Name, decimal Price, ProductTier Tier);
public enum ProductTier { Budget, Standard, Premium, Enterprise }

// Custom comparer: sort products by business-defined tier priority
public sealed class TierPriorityComparer : IComparer<ProductTier>
{
    private static readonly Dictionary<ProductTier, int> _priority = new()
    {
        [ProductTier.Enterprise] = 0,
        [ProductTier.Premium] = 1,
        [ProductTier.Standard] = 2,
        [ProductTier.Budget] = 3
    };

    public int Compare(ProductTier x, ProductTier y) =>
        _priority[x].CompareTo(_priority[y]);
}

// Use the custom comparer in OrderBy
var tierComparer = new TierPriorityComparer();

IEnumerable<Product> prioritySorted =
    products.OrderBy(p => p.Tier, tierComparer);

// Multi-key: primary by tier, secondary by price descending
IEnumerable<Product> fullSort =
    products
        .OrderBy(p => p.Tier, tierComparer)
        .ThenByDescending(p => p.Price);

The IComparer<T> approach keeps comparison logic encapsulated and independently testable -- the comparer class has no dependencies and can be unit-tested in isolation. This is consistent with the Factory Method Pattern approach of keeping construction and logic encapsulated behind small, focused objects.

You can also build comparer composition using the Decorator Pattern -- wrapping an inner IComparer<T> with an outer one that adds a tiebreaker or reverses the result. This produces a chain of sort behaviors without duplicating comparison logic.


Sorting Strings with StringComparer

String sorting in .NET is affected by locale, case sensitivity, and cultural conventions. Using the default OrderBy(x => x) sorts strings with StringComparer.CurrentCulture, which may produce unexpected results for international data. Always specify the comparer explicitly:

namespace ECommerce.Catalog;

// Ordinal -- Unicode code unit comparison, fastest, culture-insensitive
IEnumerable<string> ordinalSorted =
    skus.Order(StringComparer.Ordinal);

// OrdinalIgnoreCase -- case-insensitive, no locale rules
IEnumerable<string> caseInsensitiveSorted =
    productNames.Order(StringComparer.OrdinalIgnoreCase);

// InvariantCulture -- consistent across all locales (good for stored data)
IEnumerable<string> invariantSorted =
    productNames.Order(StringComparer.InvariantCulture);

// CurrentCulture -- respects the user's locale (good for UI display)
IEnumerable<string> cultureSorted =
    productNames.Order(StringComparer.CurrentCulture);

// Practical example: sort product names for display, respecting user's locale
IEnumerable<Product> displaySorted =
    products.OrderBy(p => p.Name, StringComparer.CurrentCultureIgnoreCase);

For internal keys, SKUs, and identifiers, StringComparer.Ordinal is fastest and most predictable. For user-facing display lists, StringComparer.CurrentCultureIgnoreCase respects the user's locale and produces naturally sorted output regardless of case.


Sorting by Enum Values

Enum values sort by their underlying integer value by default. This is correct when the enum is defined with intentional ordinal meaning, but problematic when the ordinal doesn't match the desired sort order:

namespace ECommerce.Orders;

public enum OrderStatus
{
    Cancelled = 0,   // ordinal: 0 -- will sort first by default
    Pending = 1,
    Processing = 2,
    Shipped = 3,
    Delivered = 4
}

// Default enum sort: Cancelled appears first (ordinal 0)
var defaultSort = orders.OrderBy(o => o.Status);

// Business rule: show active orders first, completed/cancelled last
// Use a key projection to override ordinal order
int BusinessSortKey(OrderStatus s) => s switch
{
    OrderStatus.Pending => 0,
    OrderStatus.Processing => 1,
    OrderStatus.Shipped => 2,
    OrderStatus.Delivered => 3,
    OrderStatus.Cancelled => 4,
    _ => 99
};

var businessSort = orders.OrderBy(o => BusinessSortKey(o.Status));

For more complex enum behavior and exhaustive pattern matching in switch expressions, the C# Enum: Complete Guide and C# Enum Switch patterns cover the full toolbox. The switch expression in the BusinessSortKey method above is idiomatic for this kind of mapping.


Before/After: .NET 7 Order() Improvements

The following table shows common sort patterns before and after .NET 7:

namespace ECommerce.Migrations;

// ── Sorting integers ──────────────────────────────────────────────────────────
// Before .NET 7
IEnumerable<int> sortedBefore = ids.OrderBy(x => x);

// .NET 7
IEnumerable<int> sortedAfter = ids.Order();

// ── Sorting strings ───────────────────────────────────────────────────────────
// Before .NET 7
IEnumerable<string> namesBefore = names.OrderBy(x => x, StringComparer.Ordinal);

// .NET 7 -- Order() accepts an optional IComparer<T>
IEnumerable<string> namesAfter = names.Order(StringComparer.Ordinal);

// ── Sorting dates descending ──────────────────────────────────────────────────
// Before .NET 7
IEnumerable<DateTimeOffset> datesBefore = timestamps.OrderByDescending(x => x);

// .NET 7
IEnumerable<DateTimeOffset> datesAfter = timestamps.OrderDescending();

// ── Sorting a collection of records by a comparable property ─────────────────
// Order() does NOT apply to Product directly unless Product is IComparable<Product>
// For complex types, OrderBy(keySelector) is still the right choice
IEnumerable<Product> productsSorted = products.OrderBy(p => p.Price); // no change

Pluggable Sort Strategies

When sort order needs to be configurable at runtime -- for example, a UI where users choose their own sort column -- a dictionary of named comparers makes the approach explicit and extensible:

namespace ECommerce.Catalog;

public sealed class ProductSortOptions
{
    public static readonly IReadOnlyDictionary<string, Func<IEnumerable<Product>, IEnumerable<Product>>> Strategies =
        new Dictionary<string, Func<IEnumerable<Product>, IEnumerable<Product>>>
        {
            ["price-asc"]   = products => products.OrderBy(p => p.Price),
            ["price-desc"]  = products => products.OrderByDescending(p => p.Price),
            ["name-asc"]    = products => products.OrderBy(p => p.Name, StringComparer.CurrentCultureIgnoreCase),
            ["name-desc"]   = products => products.OrderByDescending(p => p.Name, StringComparer.CurrentCultureIgnoreCase),
            ["newest"]      = products => products.OrderByDescending(p => p.AddedAt),
            ["stock-asc"]   = products => products.OrderBy(p => p.Stock)
        };

    public static IEnumerable<Product> Apply(
        IEnumerable<Product> products,
        string sortKey)
    {
        return Strategies.TryGetValue(sortKey, out var strategy)
            ? strategy(products)
            : products.OrderBy(p => p.Name, StringComparer.CurrentCultureIgnoreCase);
    }
}

This pattern keeps all sort definitions in one place, makes adding a new sort option a single-line change, and produces a naturally testable structure. It is consistent with the extensibility philosophy behind Plugin Architecture in C# -- each sort strategy is an independent unit that can be swapped, added, or replaced without modifying the call site.

In a Feature Slicing in C# architecture, the sort options for a given feature live within that feature's slice -- a product catalog sort definition doesn't bleed into the employee directory feature, and vice versa.


Performance Notes on LINQ Ordering

OrderBy always sorts the entire sequence

Unlike databases that can use an index, LINQ to Objects OrderBy must materialize the source and perform a full sort before returning the first element. For Take(n) after OrderBy, the entire sort still runs before Take reduces the result. If you only need the single minimum or maximum element, use MinBy / MaxBy instead:

namespace ECommerce.Inventory;

// Expensive -- sorts all products to find the cheapest one
Product cheapestSlow = products.OrderBy(p => p.Price).First();

// Efficient -- scans once without sorting
Product cheapestFast = products.MinBy(p => p.Price)!;

Avoid re-sorting materialized collections unnecessarily

If you need to sort the same collection in multiple different orders (e.g., displaying two sorted views), sort from the original source each time rather than re-sorting an already-sorted IOrderedEnumerable. The intermediate sort order has no benefit for an unrelated secondary sort.

ThenBy is not the same as chaining OrderBy

As noted earlier, a second OrderBy call on an IOrderedEnumerable<T> discards all prior sort keys. Only ThenBy respects the previously established primary sort.


Frequently Asked Questions

What is the difference between OrderBy and ThenBy in LINQ?

OrderBy establishes the primary sort key and returns an IOrderedEnumerable<T>. ThenBy adds a secondary sort key that is only consulted when two elements compare equal by all previously established keys. Calling OrderBy a second time on an already-ordered sequence discards all prior sort information -- always use ThenBy for multi-key sorts.

What does the .NET 7 Order() method add over OrderBy?

Order() and OrderDescending() (introduced in .NET 7) sort a sequence without a key selector, using the element's natural IComparable<T> ordering. They replace the redundant OrderBy(x => x) pattern that was required before .NET 7 for primitive types and value types. For complex types where a key selector is needed, OrderBy(keySelector) is still the correct operator.

Is LINQ ordering stable?

Yes. LINQ's OrderBy, OrderByDescending, ThenBy, and ThenByDescending all implement stable sort semantics. Elements that compare equal by all sort keys retain their original relative order from the input sequence. This is guaranteed for LINQ to Objects. For LINQ to SQL/EF, stability depends on the database engine and the presence of an ORDER BY clause.

How do I sort strings case-insensitively in LINQ?

Pass StringComparer.OrdinalIgnoreCase or StringComparer.CurrentCultureIgnoreCase to OrderBy:

products.OrderBy(p => p.Name, StringComparer.OrdinalIgnoreCase);

Use Ordinal or OrdinalIgnoreCase for internal identifiers and keys. Use CurrentCulture or CurrentCultureIgnoreCase for text displayed to users, so the sort respects locale-specific rules.

How do I sort by a custom business rule that does not match natural ordering?

Project the sort key using a function or switch expression that maps each value to an ordinal representing your desired order:

products.OrderBy(p => p.Status switch
{
    ProductStatus.Active => 0,
    ProductStatus.LowStock => 1,
    ProductStatus.OutOfStock => 2,
    ProductStatus.Discontinued => 3,
    _ => 99
});

For more complex comparison logic, implement IComparer<T> and pass it as the second argument to OrderBy.

What is the performance difference between OrderBy().First() and MinBy()?

OrderBy().First() sorts the entire sequence (O(n log n)) before returning the first element. MinBy() scans the sequence once (O(n)) to find the element with the minimum key value. For any scenario where you only need the single minimum or maximum element, MinBy / MaxBy (introduced in .NET 6) are significantly faster on large collections.

Can I sort by multiple enum values with different priorities?

Yes. Map each enum value to an integer priority inside a switch expression used as the OrderBy key selector. For reusability, extract the mapping into a method or a static dictionary. If the priority logic is complex or varies by context, implement a dedicated IComparer<TEnum> class and pass it to OrderBy(p => p.EnumProperty, yourComparer).


Conclusion

LINQ ordering in C# covers everything from simple single-key ascending sorts to multi-key, locale-aware, custom-comparer-driven sort pipelines. OrderBy and OrderByDescending establish the primary sort; ThenBy and ThenByDescending add secondary keys without discarding prior sort information. The .NET 7 Order() and OrderDescending() operators clean up the OrderBy(x => x) boilerplate for naturally comparable types. Custom IComparer<T> implementations and StringComparer constants let you handle the full range of real-world sorting requirements -- from locale-aware product names to business-priority enum values.

Keep MinBy and MaxBy in mind whenever you only need the single extremal element -- sorting an entire collection to pick one value is always wasteful when a linear scan will do.

LINQ Projection in C#: Select, SelectMany, and Flattening Collections

Learn LINQ projection in C# with Select, SelectMany, and the .NET 9 Index() method. Covers anonymous types, records, flattening nested collections, and real examples.

LINQ in C#: Complete Guide to Language Integrated Query (.NET 6-9)

Master LINQ in C# with this complete guide covering filtering, projection, ordering, grouping, joins, and every new operator added in .NET 6 through .NET 10.

LINQ Filtering in C#: Where, Any, All, Contains, and OfType

Learn LINQ filtering in C# with Where, Any, All, Contains, and OfType. Covers compound predicates, null handling, and performance tips with .NET 6-9 examples.

An error has occurred. This application may no longer respond until reloaded. Reload