C# 13–14 and .NET 9–10: OrderedDictionary, params Collections and More
Why Dictionary promises no order and .NET 9's generic OrderedDictionary, .NET 10's index-returning TryAdd/TryGetValue, PriorityQueue.Remove, allocation-free alternate lookup, C# 13 params collections and C# 14 extension members and span conversions.
Version note: This post covers the collection features that came with C# 13–14 and .NET 9–10; the code targets C# 13 / .NET 9 and C# 14 / .NET 10 and was built and run with the .NET 10 SDK.
Guess First
We remove the first book from the stock dictionary, then add a new book. In what order are they printed? Does the new book end up last?
var stock = new Dictionary<string, int>
{
["Clean Code"] = 3,
["Refactoring"] = 0,
["Domain-Driven Design"] = 5,
};
stock.Remove("Clean Code");
stock["C# in Depth"] = 2;
Console.WriteLine(string.Join(", ", stock.Keys));
Dictionary Makes No Promise About Order
On .NET 9 the output is:
C# in Depth, Refactoring, Domain-Driven Design
The new book comes first. Dictionary<TKey, TValue> keeps its elements in an array internally. As long as you never remove anything, the enumeration order matches the insertion order, and many people assume that is a guarantee. When you remove an element, the freed slot goes onto a "free slots" list, and the next insertion reuses that slot. The documentation is clear about this: the enumeration order is undefined. Code that works this way today may run in a different order on another implementation tomorrow.
If insertion order really matters (the order in which items were added to a cart, the order of a form's fields, the key order of an object to be written as JSON), you had two options in C# 10/.NET 6. Either you kept a List<TKey> and a Dictionary<TKey, TValue> side by side and updated both in sync, or you used the non-generic OrderedDictionary left over from the early days of .NET Framework:
using System.Collections;
using System.Collections.Specialized;
var stock = new OrderedDictionary
{
["Refactoring"] = 0,
["Domain-Driven Design"] = 5,
};
stock["C# in Depth"] = 2;
int ddd = (int)stock["Domain-Driven Design"]!;
object? first = stock[0];
foreach (DictionaryEntry entry in stock)
Console.WriteLine($"{entry.Key}: {entry.Value}");
Console.WriteLine($"{ddd}, first value: {first}");
Refactoring: 0
Domain-Driven Design: 5
C# in Depth: 2
5, first value: 0
The order is right, but the price is high: keys and values are object, every read is a cast, every int value goes to the heap through boxing, and the compiler does not stop you from putting in a value of the wrong type.
OrderedDictionary<TKey, TValue> (.NET 9)
.NET 9 added a generic OrderedDictionary<TKey, TValue> to the System.Collections.Generic namespace. In the guessing program I only changed the type:
var stock = new OrderedDictionary<string, int>
Refactoring, Domain-Driven Design, C# in Depth
The new book is now last. The type keeps O(1) average access by key, stores its elements in an ordered array, and adds an API that reaches them by index:
var reading = new OrderedDictionary<string, int>
{
["Refactoring"] = 120,
["Domain-Driven Design"] = 80,
};
reading.Insert(0, "Clean Code", 45);
reading.SetAt(2, 95);
KeyValuePair<string, int> next = reading.GetAt(0);
Console.WriteLine($"next: {next.Key} ({next.Value} pages left)");
Console.WriteLine($"Refactoring is #{reading.IndexOf("Refactoring") + 1}");
reading.RemoveAt(0);
foreach ((string title, int pages) in reading)
Console.WriteLine($"{title}: {pages}");
next: Clean Code (45 pages left)
Refactoring is #2
Refactoring: 120
Domain-Driven Design: 95
Insert adds at a given position, SetAt changes the value at a position, GetAt returns the key/value pair at a position, IndexOf tells you a key's position, and RemoveAt removes by position. Since the type also implements IList<KeyValuePair<TKey, TValue>>, it behaves both as a dictionary and as a list. The price: inserting in the middle or removing from the middle shifts the elements behind, so it is proportional to the element count. As long as you add at the end and read by key, it behaves close to Dictionary.
What .NET 10 Added
.NET 10 added two overloads to OrderedDictionary<TKey, TValue> that return an index: TryAdd(key, value, out int index) and TryGetValue(key, out value, out int index). For jobs such as increasing the quantity when the same book is added to a cart again, they save you from looking up the key twice:
var cart = new OrderedDictionary<string, int>();
cart.Add("978-0132350884", 1);
cart.Add("978-0135957059", 2);
if (!cart.TryAdd("978-0132350884", 1, out int index))
{
cart.SetAt(index, cart.GetAt(index).Value + 1);
Console.WriteLine($"already in the cart at line {index + 1}");
}
if (cart.TryGetValue("978-0135957059", out int quantity, out int line))
Console.WriteLine($"line {line + 1}: {quantity} pcs");
HashSet<string> tags = ["csharp", "dotnet"];
IReadOnlySet<string> readOnlyTags = tags.AsReadOnly();
Console.WriteLine($"{cart.GetAt(0).Value} pcs, {readOnlyTags.Count} tags, {readOnlyTags.GetType().Name}");
already in the cart at line 1
line 2: 2 pcs
2 pcs, 2 tags, ReadOnlySet`1
Because TryAdd gives you the index of the existing element even when it fails, SetAt goes straight to that line. The same program does not compile when targeting net9.0:
Program.cs(5,11): error CS1501: No overload for method 'TryAdd' takes 3 arguments
Program.cs(11,10): error CS1501: No overload for method 'TryGetValue' takes 3 arguments
Program.cs(15,42): error CS0411: The type arguments for method 'CollectionExtensions.AsReadOnly<T>(IList<T>)' cannot be inferred from the usage. Try specifying the type arguments explicitly.
The AsReadOnly on the last line also came in .NET 10: an extension method for ISet<T> that returns the ReadOnlySet<T> wrapper added in .NET 9. On .NET 9 you would have had to write new ReadOnlySet<string>(tags). When I compared the reference assemblies of .NET 9 and 10, more additions showed up on the collections side: a PriorityQueue<TElement, TPriority>.Capacity property, a FrozenDictionary.Create that takes a ReadOnlySpan, ImmutableDictionary.CreateRangeWithOverwrite, which keeps the last value when the same key comes again, and ReadOnlyCollection.CreateCollection and ReadOnlyCollection.CreateSet for collection expressions.
.NET 9's Other Collection Additions
When PriorityQueue<TElement, TPriority> arrived in .NET 6, there was no way to change an element's priority. With Remove in .NET 9, you can build the "take it out and add it again with a new priority" pattern:
var queue = new PriorityQueue<string, int>();
queue.Enqueue("ORD-1001", 3);
queue.Enqueue("ORD-1002", 2);
queue.Enqueue("ORD-1003", 1);
if (queue.Remove("ORD-1001", out string? order, out int oldPriority))
{
queue.Enqueue(order, 0);
Console.WriteLine($"{order}: priority {oldPriority} -> 0");
}
while (queue.TryDequeue(out string? next, out int priority))
Console.WriteLine($"{next} ({priority})");
ORD-1001: priority 3 -> 0
ORD-1001 (0)
ORD-1003 (1)
ORD-1002 (2)
Remove scans the queue from start to end to find the matching element, so it runs in O(n); keep that in mind if you update priorities often.
The second addition is alternate lookup. When you wanted to split a line coming from a file or an HTTP request and look up each part in a dictionary, you had to create a string for every part. In .NET 9, Dictionary, HashSet, ConcurrentDictionary, FrozenDictionary and FrozenSet can give you a view that looks things up with a different key type:
var stock = new Dictionary<string, int>
{
["978-0132350884"] = 3,
["978-0135957059"] = 0,
["978-0321125217"] = 5,
};
Dictionary<string, int>.AlternateLookup<ReadOnlySpan<char>> lookup =
stock.GetAlternateLookup<ReadOnlySpan<char>>();
ReadOnlySpan<char> line = "978-0132350884;978-0321125217;978-1617294532";
foreach (Range range in line.Split(';'))
{
ReadOnlySpan<char> isbn = line[range];
string status = lookup.TryGetValue(isbn, out int count) ? $"{count} in stock" : "unknown";
Console.WriteLine($"{isbn}: {status}");
}
978-0132350884: 3 in stock
978-0321125217: 5 in stock
978-1617294532: unknown
line[range] does not create a new string; it gives you a span that looks at a part of the original string, and the dictionary does the lookup with that span. For this to work, the dictionary's comparer must support IAlternateEqualityComparer<ReadOnlySpan<char>, string>; the default string comparer and StringComparer.Ordinal/OrdinalIgnoreCase do. Split on a span is also an API that came in .NET 9.
In addition, Dictionary, HashSet, Queue and Stack gained a Capacity property in .NET 9, and ReadOnlySet<T> joined the ReadOnlyCollection<T> and ReadOnlyDictionary<TKey, TValue> wrappers.
params Collections (C# 13)
In C# 10, params worked only with arrays: you wrote params string[] isbns and accepted that the compiler created an array on every call. C# 13 extended params to the types that collection expressions can target: Span<T>, ReadOnlySpan<T>, IEnumerable<T>, IReadOnlyList<T>, List<T> and others:
var stock = new Dictionary<string, int>
{
["978-0132350884"] = 3,
["978-0135957059"] = 0,
["978-0321125217"] = 5,
};
Console.WriteLine(CountInStock(stock, "978-0132350884", "978-0135957059"));
Console.WriteLine(CountInStock(stock));
Console.WriteLine(Describe(["Clean Code", "Refactoring"]));
Console.WriteLine(Describe(stock.Keys));
static int CountInStock(Dictionary<string, int> stock, params ReadOnlySpan<string> isbns)
{
int count = 0;
foreach (string isbn in isbns)
{
if (stock.TryGetValue(isbn, out int quantity) && quantity > 0)
count++;
}
return count;
}
static string Describe(params IEnumerable<string> items) => string.Join(" | ", items);
1
0
Clean Code | Refactoring
978-0132350884 | 978-0135957059 | 978-0321125217
With params ReadOnlySpan<string>, the arguments in the call can reach the method without creating an array on the heap. params IEnumerable<string>, on the other hand, accepts both individual arguments and any sequence you already have; we passed stock.Keys as it is. The same file does not compile with LangVersion 12:
Program.cs(13,56): error CS9202: Feature 'params collections' is not available in C# 12.0. Please use language version 13.0 or greater.
Program.cs(24,24): error CS9202: Feature 'params collections' is not available in C# 12.0. Please use language version 13.0 or greater.
This feature is not only about your own methods. .NET 9 added overloads that take a ReadOnlySpan to frequently used BCL methods such as string.Join, string.Concat, Task.WhenAll and Task.WhenAny. Your existing string.Join(", ", a, b, c) call binds to the overload that allocates no array once you upgrade the project to net9.0 and recompile, without changing the code.
C# 14: Extension Members and Span Conversions
C# 14, which came with .NET 10, has three changes that help when working with collections. The first is extension members: you can now write not only extension methods but also extension properties, static extension members and operators. The second is new implicit conversions for spans; you can use an array as the receiver of an extension method that takes a ReadOnlySpan<T>. The third is nameof working with unbound generic types:
decimal[] prices = [37.90m, 42.50m, 54.00m];
List<string> wishlist = [];
Console.WriteLine(prices.CountAbove(40m));
Console.WriteLine(wishlist.IsEmpty);
Console.WriteLine($"{nameof(List<>)}, {nameof(Dictionary<,>)}");
static class BookstoreExtensions
{
extension<T>(IReadOnlyCollection<T> source)
{
public bool IsEmpty => source.Count == 0;
}
public static int CountAbove(this ReadOnlySpan<decimal> values, decimal limit)
{
int count = 0;
foreach (decimal value in values)
{
if (value > limit)
count++;
}
return count;
}
}
2
True
List, Dictionary
The extension<T>(IReadOnlyCollection<T> source) block makes the members inside it look as if they belonged to that type; wishlist.IsEmpty reads like a property. In C# 13 there was no way to write an extension property, so you would have written a method called IsEmpty(). With LangVersion 13, the same file gives this error:
Program.cs(10,5): error CS9260: Feature 'extensions' is not available in C# 13.0. Please use language version 14.0 or greater.
The span part is a quieter change. When I removed the extension block and kept only CountAbove, compiling with C# 13 gave this error:
Program.cs(2,19): error CS1929: 'decimal[]' does not contain a definition for 'CountAbove' and the best extension method overload 'BookstoreExtensions.CountAbove(ReadOnlySpan<decimal>, decimal)' requires a receiver of type 'System.ReadOnlySpan<decimal>'
In C# 13 the conversion from array to span was a user-defined conversion and was not considered for extension method receivers; you had to write prices.AsSpan().CountAbove(40m). C# 14 brought this conversion into the language. It has a side effect too: in some calls C# 14 prefers overloads that take spans. .NET 10 added overloads such as Enumerable.Reverse(T[]) to balance this; if you force this combination on a target older than .NET 10, a call may bind to an overload you did not expect.
Upgrading and Support Dates
.NET 9 is an STS release, and its support ends on November 10, 2026. All the .NET 9 APIs in this post are also in .NET 10; .NET 10 is LTS and supported until November 14, 2028. The rule from the previous post holds here too: language features depend on LangVersion (the default is C# 13 for net9.0 and C# 14 for net10.0), APIs depend on TargetFramework, and the cleanest upgrade is to change the TFM and leave LangVersion at its default.
The series does not end here. .NET 11 and C# 15 are scheduled for release on November 10, 2026; Microsoft's "What's new in C# 15" page already lists passing arguments to collection expressions (such as [with(capacity: n), .. values]). When the release is out, we will add a new post to this series.
Common Pitfalls
Relying on Dictionary order. The order that works in your test changes after the first removal. If order is a requirement, use OrderedDictionary<TKey, TValue>; if you only want output sorted by key, use SortedDictionary or sort when you write the output.
Changing an OrderedDictionary heavily in the middle. Insert, RemoveAt and Remove by key shift the elements behind. If you often remove from the front of a structure with thousands of elements, the cost grows.
Using a params ReadOnlySpan<T> inside a lambda. A span is a ref struct; it cannot move to the heap, and it cannot go into lambdas, async methods or iterators:
Console.WriteLine(FindFirst(new Dictionary<string, int>(), "978-0132350884"));
static string? FindFirst(Dictionary<string, int> stock, params ReadOnlySpan<string> isbns)
{
Func<string?> first = () => isbns.Length > 0 ? isbns[0] : null;
return first();
}
Program.cs(5,33): error CS9108: Cannot use parameter 'isbns' that has ref-like type inside an anonymous method, lambda expression, query expression, or local function
Program.cs(5,52): error CS9108: Cannot use parameter 'isbns' that has ref-like type inside an anonymous method, lambda expression, query expression, or local function
If you need the value inside a lambda, copy isbns[0] into a local variable first, or make the parameter params IEnumerable<string>.
Thinking every comparer supports alternate lookup. An IEqualityComparer<string> you wrote yourself does not know how to look things up with a span:
var ignoreCase = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase) { ["Clean Code"] = 3 };
Console.WriteLine(ignoreCase.TryGetAlternateLookup<ReadOnlySpan<char>>(out _));
var custom = new Dictionary<string, int>(new IsbnComparer()) { ["978-0132350884"] = 3 };
Console.WriteLine(custom.TryGetAlternateLookup<ReadOnlySpan<char>>(out _));
try
{
custom.GetAlternateLookup<ReadOnlySpan<char>>();
}
catch (InvalidOperationException ex)
{
Console.WriteLine(ex.Message);
}
class IsbnComparer : IEqualityComparer<string>
{
public bool Equals(string? x, string? y) => string.Equals(x?.Replace("-", ""), y?.Replace("-", ""));
public int GetHashCode(string obj) => obj.Replace("-", "").GetHashCode();
}
True
False
The collection's comparer does not support the requested operation.
If the comparer comes from outside, use TryGetAlternateLookup; if you want span support in your own comparer, implement IAlternateEqualityComparer<ReadOnlySpan<char>, string> as well.
A Small Experiment
★☆☆ Run the guessing program with OrderedDictionary<string, int>, then put the removed book back at the front with Insert(0, ...) and print the order.
★★☆ Write a Promote(string orderId) method that updates an order's priority with PriorityQueue.Remove when the customer becomes VIP. What should it do if the order is not in the queue?
★★★ Add an IAlternateEqualityComparer<ReadOnlySpan<char>, string> implementation to IsbnComparer; write Equals and GetHashCode that ignore dashes on the span, without creating a string, and see that TryGetAlternateLookup now returns true.
The C# Collections and Generics series is complete with this post. You now know how to build, choose and walk collections; next comes querying them. The next series is LINQ: you will see how the IEnumerable<T>, yield return and deferred execution from this series work behind Where, Select, GroupBy and the other operators.