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

LINQ and .NET 7–8: Order, ToDictionary Without Selectors and Performance

Mert Özen Nov 7, 2026 10 min 2 views
LINQ and .NET 7–8: Order, ToDictionary Without Selectors and Performance

.NET 7's Order and OrderDescending, which need no key selector, why a missing IComparable only blows up at runtime, culture-sensitive versus ordinal string sorting, .NET 8's selector-less ToDictionary, and vectorized Max/Sum plus Order versus OrderBy(x => x) measured with BenchmarkDotNet.

Version note: This post covers the LINQ additions that came with .NET 7 and .NET 8; the Order examples target C# 11 / .NET 7 and the ToDictionary and measurement examples target C# 12 / .NET 8, all built and run with the .NET 10 SDK.

Guess First

.NET 7 brought two sorting operators that need no key selector: Order() and OrderDescending(). What the first two lines print is obvious. The real question is in the third part: Book is a record and we never told it how to sort. Does this code compile? If it does, what happens when it runs?

int[] stock = { 12, 0, 3, 7 };
Console.WriteLine(string.Join(", ", stock.Order()));
Console.WriteLine(string.Join(", ", stock.OrderDescending()));

var books = new List<Book>
{
    new("978-0321125217", "Domain-Driven Design", 54.00m, 3),
    new("978-0132350884", "Clean Code", 37.90m, 12),
};

try
{
    foreach (Book book in books.Order())
        Console.WriteLine(book.Title);
}
catch (InvalidOperationException ex)
{
    Console.WriteLine($"{ex.GetType().Name}: {ex.Message}");
    Console.WriteLine($"Inner: {ex.InnerException?.Message}");
}

public record Book(string Isbn, string Title, decimal Price, int Stock);

Order and OrderDescending (.NET 7)

0, 3, 7, 12
12, 7, 3, 0
InvalidOperationException: Failed to compare two elements in the array.
Inner: At least one object must implement IComparable.

The code compiled and the numbers were sorted as expected. books.Order(), however, blew up not at compile time but at the moment it was enumerated. Order<T> has no constraint; sorting is done with Comparer<T>.Default, and that comparer only notices that Book implements neither IComparable<Book> nor IComparable when it tries to compare two books. Both the exception's message and the inner exception's message say so. I compiled this program targeting net7.0; since the machine has no .NET 7 runtime, it ran on .NET 8.0.25 through roll-forward.

In .NET 6 we wrote the same sort by passing the element itself as the key selector: stock.OrderBy(x => x). It works, but it makes the reader pause for a moment: "is a key being selected here?". Order() states the intent directly: arrange the elements in their natural order. When I compiled the same file targeting net6.0, the compiler didn't recognize the new methods:

Program.cs(2,43): error CS1061: 'int[]' does not contain a definition for 'Order' and no accessible extension method 'Order' accepting a first argument of type 'int[]' could be found (are you missing a using directive or an assembly reference?)
Program.cs(3,43): error CS1061: 'int[]' does not contain a definition for 'OrderDescending' and no accessible extension method 'OrderDescending' accepting a first argument of type 'int[]' could be found (are you missing a using directive or an assembly reference?)
Program.cs(13,33): error CS1061: 'List<Book>' does not contain a definition for 'Order' and no accessible extension method 'Order' accepting a first argument of type 'List<Book>' could be found (are you missing a using directive or an assembly reference?)

An interesting detail: the same file compiled without errors targeting net7.0 with LangVersion 10. Order() isn't a language feature but a library API; what it needs isn't C# 11 but .NET 7's System.Linq.

Natural Order: IComparable and Comparers

"Natural order" is indisputable for numbers and dates. For strings it depends on culture, and for your own types it depends on you:

using System.Globalization;

CultureInfo.CurrentCulture = CultureInfo.InvariantCulture;

string[] tags = { "refactoring", "DDD", "architecture", "Clean Code" };
Console.WriteLine(string.Join(", ", tags.Order()));
Console.WriteLine(string.Join(", ", tags.Order(StringComparer.Ordinal)));

var books = new List<Book>
{
    new("978-0321125217", "Domain-Driven Design", 54.00m, 3),
    new("978-0132350884", "Clean Code", 37.90m, 12),
    new("978-0201633610", "Design Patterns", 49.90m, 7),
};

foreach (Book book in books.Order())
    Console.WriteLine($"{book.Isbn}  {book.Title}");

public record Book(string Isbn, string Title, decimal Price, int Stock) : IComparable<Book>
{
    // The natural order of a book: its ISBN
    public int CompareTo(Book? other) => string.CompareOrdinal(Isbn, other?.Isbn);
}
architecture, Clean Code, DDD, refactoring
Clean Code, DDD, architecture, refactoring
978-0132350884  Clean Code
978-0201633610  Design Patterns
978-0321125217  Domain-Driven Design

The default string comparer does a culture-sensitive comparison: it treats casing as secondary and looks at alphabetical order. StringComparer.Ordinal, on the other hand, compares character codes, and uppercase letters come before lowercase ones. That's why I pinned the culture to InvariantCulture; under a culture like tr-TR the order of letters can differ. For lists shown to users on screen, culture-sensitive sorting is the right choice; for values meaningful to machines, like keys, codes or file names, use ordinal.

Once we added IComparable<Book> to the Book record, Order() worked and arranged the books by ISBN. You made a design decision here: a book's "natural order" is its ISBN. That decision isn't always obvious; title, price, publication date? If your type doesn't have a single indisputable order, don't implement IComparable; write an explicit key like OrderBy(b => b.Price). Save Order() for types whose order is clear, like numbers, strings, dates and Version.

ToDictionary Without Selectors (.NET 8)

When you filter a dictionary, you're left with an IEnumerable<KeyValuePair<TKey, TValue>>. To turn it back into a dictionary, for years we wrote the same two lambdas: p => p.Key and p => p.Value. .NET 8 removed that repetition:

var prices = new Dictionary<string, decimal>
{
    ["978-0132350884"] = 37.90m,
    ["978-0135957059"] = 42.50m,
    ["978-0321125217"] = 54.00m,
};

// .NET 7 and earlier
Dictionary<string, decimal> before = prices
    .Where(p => p.Value > 40m)
    .ToDictionary(p => p.Key, p => p.Value);

// .NET 8
Dictionary<string, decimal> expensive = prices.Where(p => p.Value > 40m).ToDictionary();
Console.WriteLine(string.Join(", ", expensive.Keys));

var books = new List<Book>
{
    new("978-0132350884", "Clean Code", 37.90m, 12),
    new("978-0135957059", "The Pragmatic Programmer", 42.50m, 0),
};

Dictionary<string, int> stock = books.Select(b => (b.Isbn, b.Stock)).ToDictionary();
Console.WriteLine(stock["978-0132350884"]);

Dictionary<string, decimal> ignoreCase = new[] { KeyValuePair.Create("isbn-a", 1m) }
    .ToDictionary(StringComparer.OrdinalIgnoreCase);
Console.WriteLine(ignoreCase.ContainsKey("ISBN-A"));

try
{
    var duplicate = new[] { ("978-0132350884", 12), ("978-0132350884", 5) }.ToDictionary();
}
catch (ArgumentException ex)
{
    Console.WriteLine(ex.Message);
}

public record Book(string Isbn, string Title, decimal Price, int Stock);
978-0135957059, 978-0321125217
12
True
An item with the same key has already been added. Key: 978-0132350884

The selector-less ToDictionary() accepts two kinds of sources: sequences of KeyValuePair<TKey, TValue> and sequences of two-element tuples. The tuple version is especially handy together with Select; we wrote (b.Isbn, b.Stock) and got a Dictionary<string, int> directly. Both have an overload that takes a comparer. The behavior is the same as before: if the same key arrives twice it throws an ArgumentException and the message tells you which key.

When I compiled the same file targeting net7.0, I got this error for every parameterless call:

Program.cs(16,74): error CS1501: No overload for method 'ToDictionary' takes 0 arguments

If you're staying on .NET 6 or 7, there's another short path besides writing two lambdas. The Dictionary<TKey, TValue> constructor that takes an IEnumerable<KeyValuePair<TKey, TValue>> exists in .NET 6 too:

var prices = new Dictionary<string, decimal>
{
    ["978-0132350884"] = 37.90m,
    ["978-0135957059"] = 42.50m,
    ["978-0321125217"] = 54.00m,
};

var expensive = new Dictionary<string, decimal>(prices.Where(p => p.Value > 40m));
Console.WriteLine(string.Join(", ", expensive.Keys));
978-0135957059, 978-0321125217

Performance: Don't Talk Without Measuring

.NET 7 and 8 brought very few new operators to LINQ; the real investment went into performance. According to Stephen Toub's .NET 7 and .NET 8 performance posts, aggregate operators such as Min, Max and Sum were vectorized for sources that sit contiguously in memory, like arrays and lists: the processor handles several numbers at once with a single instruction. In .NET 8, ToList was also reworked to write elements directly into the list's internal array. This machine doesn't have the .NET 6 and 7 runtimes, so instead of comparing versions with each other, I compared against a hand-written loop. The project targets net8.0, in Release mode:

using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;

BenchmarkRunner.Run([typeof(AggregateBenchmarks), typeof(SortBenchmarks)]);

[MemoryDiagnoser]
public class AggregateBenchmarks
{
    private int[] _stock = null!;

    [GlobalSetup]
    public void Setup()
    {
        var random = new Random(42);
        _stock = Enumerable.Range(0, 10_000).Select(_ => random.Next(0, 1_000)).ToArray();
    }

    [Benchmark]
    public int MaxLoop()
    {
        int max = int.MinValue;
        foreach (int value in _stock)
            if (value > max)
                max = value;
        return max;
    }

    [Benchmark]
    public int MaxLinq() => _stock.Max();

    [Benchmark]
    public int SumLoop()
    {
        int sum = 0;
        foreach (int value in _stock)
            sum += value;
        return sum;
    }

    [Benchmark]
    public int SumLinq() => _stock.Sum();
}

[MemoryDiagnoser]
public class SortBenchmarks
{
    private int[] _stock = null!;

    [GlobalSetup]
    public void Setup()
    {
        var random = new Random(42);
        _stock = Enumerable.Range(0, 1_000).Select(_ => random.Next(0, 1_000)).ToArray();
    }

    [Benchmark(Baseline = true)]
    public int[] OrderByIdentity() => _stock.OrderBy(x => x).ToArray();

    [Benchmark]
    public int[] Order() => _stock.Order().ToArray();
}
BenchmarkDotNet v0.15.8, macOS 27.0.1 (26A434) [Darwin 27.0.0]
Apple M5, 1 CPU, 10 logical and 10 physical cores
.NET SDK 10.0.402
  [Host]     : .NET 8.0.25 (8.0.25, 8.0.2526.11203), Arm64 RyuJIT armv8.0-a
  DefaultJob : .NET 8.0.25 (8.0.25, 8.0.2526.11203), Arm64 RyuJIT armv8.0-a

| Method  | Mean     | Error     | StdDev    | Median   | Allocated |
|-------- |---------:|----------:|----------:|---------:|----------:|
| MaxLoop | 2.339 us | 0.0200 us | 0.0177 us | 2.331 us |         - |
| MaxLinq | 1.095 us | 0.0219 us | 0.0277 us | 1.078 us |         - |
| SumLoop | 2.359 us | 0.0337 us | 0.0299 us | 2.378 us |         - |
| SumLinq | 1.164 us | 0.0127 us | 0.0119 us | 1.168 us |         - |

| Method          | Mean      | Error     | StdDev    | Ratio | Gen0   | Allocated | Alloc Ratio |
|---------------- |----------:|----------:|----------:|------:|-------:|----------:|------------:|
| OrderByIdentity | 14.675 us | 0.1510 us | 0.1413 us |  1.00 | 1.9379 |  15.89 KB |        1.00 |
| Order           |  5.337 us | 0.0703 us | 0.0658 us |  0.36 | 0.4807 |   3.96 KB |        0.25 |

In the first table, LINQ came out about twice as fast as a hand-written foreach over a 10,000-element int array: 1.10 µs versus 2.34 µs for Max, 1.16 µs versus 2.36 µs for Sum. The reason is vectorization; the loop processes the numbers one by one, LINQ several at a time. This is an exception to the rule of thumb that "LINQ is always slower than a loop". BenchmarkDotNet also raised a MultimodalDistribution warning for MaxLinq in this run; other builds were running on the machine during the measurement, so don't read into small differences, look at the big picture.

The second table is more surprising. Sorting the same 1,000 numbers with Order() took a little more than a third of the time of OrderBy(x => x) and allocated a quarter of the memory. 3.96 KB is roughly the 1,000-int result array itself. OrderBy, on the other hand, allocates extra arrays for the keys and the sort map. Knowing that the key is the element itself lets the runtime sort without building those intermediate structures. So in this measurement Order() isn't just more readable, it's also cheaper.

What to Know When Upgrading

Everything in this post is a library API; it depends on the target framework, not the language version. When you move TargetFramework to net8.0, both Order() and the selector-less ToDictionary() come along. Performance improvements come without changing the code: the same Max() call runs faster on the new runtime. Since Microsoft doesn't support a LangVersion newer than the target framework's default, the cleanest path is to upgrade the TFM and not write LangVersion at all.

Don't forget the support dates. .NET 7's support ended in May 2024. .NET 8 is LTS, but its support ends on November 10, 2026. Everything in this post works unchanged on .NET 10 (LTS, supported until November 14, 2028); in a new project that should be your target. We cover moving an old project step by step separately in the C# Versions series.

Common Pitfalls

Assuming the compiler checks Order(). As we saw in the opening, a missing IComparable surfaces not at compile time but when the query runs. Code that uses Order() on your own type and reaches production without passing through a unit test blows up on the first enumeration.

Sorting strings without thinking about culture. Like OrderBy, Order() uses the current culture's rules by default. When the server's culture changes, the sort order can change too. For values meaningful to machines, write Order(StringComparer.Ordinal).

Assuming the selector-less ToDictionary() tolerates duplicate keys. The new overload only shortens the code; a duplicate key is still an ArgumentException. If the source can contain duplicates, first decide which one to keep with DistinctBy(p => p.Key), or group and merge.

A Small Experiment

★☆☆ In a .NET 6 project you have, find the places that use OrderBy(x => x) and ToDictionary(p => p.Key, p => p.Value). Count how many of them you could simplify after moving the project to net8.0.

★★☆ Write an IComparer<Book> that sorts by price, then by title, and use it with books.Order(comparer). Get the same result with OrderBy(...).ThenBy(...); which one reads better?

★★★ Add sources given as a List<int> and as an IEnumerable<int> (for example _stock.Where(_ => true)) to the AggregateBenchmarks from the measurement. On which source does vectorization kick in, and on which doesn't it?

In the next post we move on to .NET 9: CountBy, which counts without collecting groups in memory, AggregateBy, which accumulates per key, and Index(), which replaces the Select((x, i) => ...) trick.