Mert Özen Further With Every Line
Backend LINQ — #03

Filtering and Sorting with LINQ: Where, OrderBy, ThenBy and Reverse

Mert Özen Nov 7, 2026 10 min 2 views
Filtering and Sorting with LINQ: Where, OrderBy, ThenBy and Reverse

Where and its index overload, why a second OrderBy wipes out the first, multi-key sorting with ThenBy, stable OrderBy versus unstable List.Sort, culture-sensitive string ordering, and how Reverse differs from OrderByDescending.

Version note: The code in this post uses C# 10 and the .NET 6 APIs; it was built and run with the .NET 10 SDK.

Guess First

We want to sort the books by category first and by price within each category. Below are two attempts. Which one gives the order we want? What does the other one do? We pin the culture at the top so prices print the same way on every machine.

using System.Globalization;

CultureInfo.CurrentCulture = CultureInfo.InvariantCulture;

var books = new List<Book>
{
    new("Clean Code", "Craft", 37.90m),
    new("Domain-Driven Design", "Architecture", 54.99m),
    new("The Pragmatic Programmer", "Craft", 42.50m),
    new("C# in Depth", "Language", 44.99m),
    new("Design Patterns", "Architecture", 49.95m),
    new("Refactoring", "Craft", 42.50m),
    new("CLR via C#", "Language", 59.99m),
};

var first = books.OrderBy(b => b.Category).OrderBy(b => b.Price);
var second = books.OrderBy(b => b.Category).ThenBy(b => b.Price);

Console.WriteLine("OrderBy + OrderBy:");
foreach (Book b in first)
    Console.WriteLine($"  {b.Category,-12} {b.Price,6}  {b.Title}");

Console.WriteLine("OrderBy + ThenBy:");
foreach (Book b in second)
    Console.WriteLine($"  {b.Category,-12} {b.Price,6}  {b.Title}");

record Book(string Title, string Category, decimal Price);

A Second OrderBy Wipes Out the First

Here is the output:

OrderBy + OrderBy:
  Craft         37.90  Clean Code
  Craft         42.50  The Pragmatic Programmer
  Craft         42.50  Refactoring
  Language      44.99  C# in Depth
  Architecture  49.95  Design Patterns
  Architecture  54.99  Domain-Driven Design
  Language      59.99  CLR via C#
OrderBy + ThenBy:
  Architecture  49.95  Design Patterns
  Architecture  54.99  Domain-Driven Design
  Craft         37.90  Clean Code
  Craft         42.50  The Pragmatic Programmer
  Craft         42.50  Refactoring
  Language      44.99  C# in Depth
  Language      59.99  CLR via C#

The first query forgot the category entirely; the books are ordered by price alone. Every call to OrderBy sets up a brand new primary ordering from scratch and ignores the previous one. This behavior is documented, and the compiler doesn't warn either, because the result of OrderBy is still a sequence and sorting it again is a valid operation.

For the second key you use ThenBy. OrderBy doesn't return a plain IEnumerable<T> but an IOrderedEnumerable<T>. That type carries the question "I am already sorted, how should I break the ties?", and ThenBy and ThenByDescending are defined only on it. Think of a phone book: it is ordered by surname, and among people with the same surname you look at the first name. Looking at the first name doesn't disturb the surname order; it only breaks ties. In the second query the Architecture, Craft and Language blocks stay together, and each block is ordered by price inside.

Where: Let Matching Elements Through

Where takes a predicate and lets through the elements for which it returns true. In the previous posts we used its most common overload. There is also a second overload that gives you the element's index in the source; it comes in handy for things like skipping the header line of a CSV file:

string[] lines =
{
    "Isbn;Title;Stock",
    "978-0132350884;Clean Code;12",
    "",
    "978-0135957059;The Pragmatic Programmer;0",
    "978-0321125217;Domain-Driven Design;3",
};

// The second parameter is the element's index in the source: we skip the header line
IEnumerable<string> dataLines = lines.Where((line, index) => index > 0 && line.Length > 0);

IEnumerable<string> inStock = dataLines.Where(line => int.Parse(line.Split(';')[2]) > 0);

Console.WriteLine($"data lines: {dataLines.Count()}");
foreach (string line in inStock)
    Console.WriteLine(line);
data lines: 3
978-0132350884;Clean Code;12
978-0321125217;Domain-Driven Design;3

The index is the position before filtering: even though the empty line was dropped, the indexes of the following lines don't shift. This overload has no query syntax equivalent; you write it only in method syntax.

We wrote the second Where separately, but combining the conditions with && in a single Where gives the same result. Which you choose is a readability question: independent business rules read more comfortably in separate Wheres and can be switched on and off one by one. Since .NET combines consecutive Where calls into a single iterator internally, the difference is not worth measuring.

When you combine it with sorting, order matters. Filter first, then sort: as we saw in the previous post, OrderBy pulls the whole source into memory and sorts it, and the fewer elements reach it, the less work it does. books.OrderBy(...).Where(...) gives the right result but also sorts the books that are about to be dropped, for nothing.

Descending Order and Multiple Keys

Each key can have its own direction. Let's set up an order that is ascending by category, descending by price within a category, and ascending by title among books with the same price:

var methodSyntax = books
    .OrderBy(b => b.Category)
    .ThenByDescending(b => b.Price)
    .ThenBy(b => b.Title);

var querySyntax =
    from b in books
    orderby b.Category, b.Price descending, b.Title
    select b;

foreach (Book b in methodSyntax)
    Console.WriteLine($"{b.Category,-12} {b.Price,6}  {b.Title}");

Console.WriteLine(methodSyntax.SequenceEqual(querySyntax));
Architecture  54.99  Domain-Driven Design
Architecture  49.95  Design Patterns
Craft         42.50  Refactoring
Craft         42.50  The Pragmatic Programmer
Craft         37.90  Clean Code
Language      59.99  CLR via C#
Language      44.99  C# in Depth
True

In query syntax the keys follow orderby, separated by commas, and each carries its own ascending or descending marker; without a marker, ascending is assumed. The compiler turns the first key into OrderBy and the rest into ThenBy/ThenByDescending, which is why SequenceEqual says True. The two Craft books priced 42.50 are ordered alphabetically thanks to the third key: Refactoring comes before The Pragmatic Programmer.

To sort by the element itself, you pass x => x as the key selector. .NET 7 brought Order() and OrderDescending(), which take no selector; we will see them in the version posts of the LINQ series.

Stability and Cost

Had we not written the third key, in which order would the two Craft books have come out? The documentation answers clearly: OrderBy performs a stable sort, which means elements with equal keys keep their order from the source. For List<T>.Sort the documentation says the opposite: it performs an unstable sort, and there is no guarantee that equal elements keep their order. To see the difference, let's sort 40 orders by status:

var orders = Enumerable.Range(1, 40)
    .Select(id => new Order(id, id % 3 == 0 ? "Shipped" : "Pending"))
    .ToList();

// OrderBy: source order is kept for equal keys
List<Order> linqSorted = orders.OrderBy(o => o.Status).ToList();

// List<T>.Sort: in-place and unstable
var listSorted = new List<Order>(orders);
listSorted.Sort((x, y) => string.CompareOrdinal(x.Status, y.Status));

Console.WriteLine("OrderBy:   " + string.Join(" ", linqSorted.Take(8).Select(o => o.Id)));
Console.WriteLine("List.Sort: " + string.Join(" ", listSorted.Take(8).Select(o => o.Id)));

record Order(int Id, string Status);
OrderBy:   1 2 4 5 7 8 10 11
List.Sort: 1 38 37 35 34 32 31 29

Both put the "Pending" orders first. But OrderBy gave them in source order, 1, 2, 4, 5, while List<T>.Sort shuffled them. On small lists Sort often looks as if it keeps the order, which is why the bug slips past tests. If your screen assumes something like "newest order on top" and you didn't spell it out with a second key, the order can change the day you change the sorting method. The safest thing is to always state how ties are broken with a ThenBy.

On the cost side there is one more detail worth knowing. A sorting algorithm compares elements many times; if the key selector were called on every comparison, an expensive key would be a serious burden:

int keyCalls = 0;

decimal PriceWithTax(Book b)
{
    keyCalls++;
    return b.Price * 1.2m;
}

List<Book> sorted = books.OrderBy(PriceWithTax).ToList();
Console.WriteLine($"{books.Count} books, {keyCalls} key calls");
7 books, 7 key calls

For seven books the key was computed seven times. OrderBy first computes each element's key once and stores it, then does the comparisons on those stored keys. Still, if computing the key means a database or service call, it is better to move it out of the query.

String Ordering and Culture

When sorting by string keys, OrderBy uses Comparer<string>.Default, and that comparer looks at the current culture. With Turkish names the result is striking:

using System.Globalization;

string[] authors = { "Zorlu", "Çelik", "Cengiz", "Ilgaz", "İnce", "Sarı", "Şahin" };

CultureInfo.CurrentCulture = new CultureInfo("tr-TR");
Console.WriteLine("tr-TR:     " + string.Join(", ", authors.OrderBy(a => a)));

CultureInfo.CurrentCulture = CultureInfo.InvariantCulture;
Console.WriteLine("Invariant: " + string.Join(", ", authors.OrderBy(a => a)));

Console.WriteLine("Ordinal:   " + string.Join(", ", authors.OrderBy(a => a, StringComparer.Ordinal)));

// Writing the culture into the query itself: independent of the environment
StringComparer turkish = StringComparer.Create(new CultureInfo("tr-TR"), ignoreCase: false);
Console.WriteLine("Explicit:  " + string.Join(", ", authors.OrderBy(a => a, turkish)));
tr-TR:     Cengiz, Çelik, Ilgaz, İnce, Sarı, Şahin, Zorlu
Invariant: Çelik, Cengiz, Ilgaz, İnce, Şahin, Sarı, Zorlu
Ordinal:   Cengiz, Ilgaz, Sarı, Zorlu, Çelik, İnce, Şahin
Explicit:  Cengiz, Çelik, Ilgaz, İnce, Sarı, Şahin, Zorlu

Same array, same OrderBy(a => a), three different results. In the Turkish alphabet Ç is a separate letter that comes after C, so in tr-TR Cengiz comes first. The invariant culture treats Ç as an accented form of C and looks at the following letters first: comparing "Çel" with "Cen", l comes before n, so Çelik moves ahead. Ordinal comparison looks at character codes and pushes the Turkish letters after Z. These outputs were taken on macOS. Since .NET 5, culture-sensitive comparisons on Windows also use ICU (Windows 10 1903 and later), so results are generally consistent across platforms.

The real danger is in the first two lines. Without anything changing in the code, the order changes just because the server's culture changed. Your development machine may be tr-TR and the container in production invariant. If you are sorting a list shown to users, write the culture into the query itself with StringComparer.Create. If you are sorting machine-facing values like codes, ISBNs or file names, use StringComparer.Ordinal; it is fast and gives the same result everywhere. We looked at comparers in detail in the collections series, and the same objects do the job here.

Reverse and OrderByDescending Are Not the Same

Two ways come to mind for getting the reverse order: OrderByDescending, or OrderBy followed by Reverse(). As long as there are no ties, both look the same:

var byPriceDesc = books.OrderByDescending(b => b.Price);
var byPriceThenReversed = books.OrderBy(b => b.Price).Reverse();

Console.WriteLine(string.Join(" | ", byPriceDesc.Skip(4).Select(b => $"{b.Title} {b.Price}")));
Console.WriteLine(string.Join(" | ", byPriceThenReversed.Skip(4).Select(b => $"{b.Title} {b.Price}")));
The Pragmatic Programmer 42.50 | Refactoring 42.50 | Clean Code 37.90
Refactoring 42.50 | The Pragmatic Programmer 42.50 | Clean Code 37.90

We looked at the last three books. OrderByDescending is stable too: The Pragmatic Programmer and Refactoring, which have the same price, come in their source order. Reverse() flips the sorted result as it is, so it flips the ties as well. Reverse is also a buffering operator; it has to read the whole sequence before it can hand out the last element. If you want descending order, write OrderByDescending.

Reverse has one more pitfall. List<T> has its own Reverse() method, and the compiler prefers an instance method over an extension method:

var titles = new List<string> { "Clean Code", "Refactoring", "Domain-Driven Design" };

var reversed = titles.Reverse();
Console.WriteLine(string.Join(", ", reversed));
Program.cs(3,5): error CS0815: Cannot assign void to an implicitly-typed variable

List<T>.Reverse() reverses the list in place and returns void; the compiler is telling you it cannot assign void to a variable. If you want LINQ's Reverse, you first have to see the list as an IEnumerable<T>:

var titles = new List<string> { "Clean Code", "Refactoring", "Domain-Driven Design" };

IEnumerable<string> reversed = titles.AsEnumerable().Reverse();
Console.WriteLine(string.Join(", ", reversed));
Console.WriteLine(string.Join(", ", titles));

titles.Reverse();
Console.WriteLine(string.Join(", ", titles));
Domain-Driven Design, Refactoring, Clean Code
Clean Code, Refactoring, Domain-Driven Design
Domain-Driven Design, Refactoring, Clean Code

After AsEnumerable() the call went to LINQ and the list didn't change. The titles.Reverse() on the last lines reversed the list itself. Had you declared the variable with an explicit type, as IEnumerable<string>, the compiler would have stopped you with CS0029 this time, saying it cannot convert void to IEnumerable<string>. The truly silent danger is the line where you never use the return value: write titles.Reverse(); thinking you are calling LINQ, and you change the list itself without noticing.

Common Pitfalls

Writing OrderBy again for the second key. It wipes out the previous ordering; use ThenBy or ThenByDescending to break ties.

Relying on the order of ties. OrderBy is stable, but that means the result depends on the source order. If the source is a database query with no order specified, "source order" isn't guaranteed either. Write every order that matters as a key.

Leaving ordering to the culture. With string keys, OrderBy can give different results depending on the culture of the machine it runs on. Pass the culture explicitly for user-facing lists and use StringComparer.Ordinal for technical values.

Filtering after sorting. OrderBy(...).Where(...) works correctly but also sorts the elements that are about to be dropped. Move the Where first.

Confusing List<T>.Reverse() with LINQ's Reverse(). One changes the list in place and returns void, the other returns a new sequence.

A Small Experiment

★☆☆ Sort the books from the opening example by category descending and by title ascending within each category. Write it first in method syntax, then in query syntax.

★★☆ Make List<T>.Sort stable in the 40-order example: when the statuses are equal, compare by Id in the comparison function. Are the first eight Ids now the same as with OrderBy?

★★★ Add "Ilgaz", "ılık" and "İnce" to the author names array and compare the tr-TR, invariant and ordinal orderings again. Then sort case-insensitively with StringComparer.Create(new CultureInfo("tr-TR"), ignoreCase: true) and explain where the letters "I/ı/İ" end up.

We have filtered and sorted, but every time we returned the Book objects as they were. In the next post we move on to shaping the result: projection with Select, flattening nested collections with SelectMany, and when anonymous types are useful.