Mastering FrozenDictionary in .NET 10

FrozenDictionary<TKey, TValue> is built for a very specific workload, create a lookup table rarely, then read from it repeatedly. It spends more time during construction so that the resulting data structure can be specialised for the keys it contains. That trade-off makes it useful in routing tables, protocol metadata, parsers, feature definitions and other long lived application data. It also makes it easy to misuse. Replacing every Dictionary<TKey, TValue> with a frozen equivalent can increase startup cost and memory use without producing a measurable improvement. FrozenDictionary was introduced in .NET 8, so it isn't a new .NET 10 collection. .NET 10 does, however, improve how it can be constructed by adding span-based Create factories and collection expression support. Combined with alternate lookups and the implementation refinements made since .NET 8, it is now a mature option for read heavy paths.
This article looks beyond the basic ToFrozenDictionary() example. We'll examine what freezing does, how .NET selects an internal representation, how to avoid allocations with span based lookups, how to refresh frozen data safely and how to benchmark the real break even point.
What frozen means
A frozen dictionary cannot have entries added, removed or replaced after construction. Its public type implements dictionary interfaces for compatibility, but mutation through those interfaces throws NotSupportedException. This immutability applies to the collection structure. It doesn't recursively freeze the objects stored inside it:
FrozenDictionary<string, List<string>> routes =
new Dictionary<string, List<string>>
{
["claims"] = ["validate", "enrich"]
}
.ToFrozenDictionary();
routes["claims"].Add("publish"); // This is still allowed.
The key cannot be removed and the list reference cannot be replaced, but the list remains mutable. If the dictionary will be shared across threads, its keys and values should also be immutable or otherwise safe for concurrent access.
That distinction separates FrozenDictionary from several related types:
| Collection | Can update entries? | Construction profile | Best fit |
|---|---|---|---|
Dictionary<TKey, TValue> |
Yes | Cheap | General purpose mutable lookup |
ReadOnlyDictionary<TKey, TValue> |
The wrapper cannot, but the underlying dictionary can | Cheap wrapper | Exposing a read only view |
ImmutableDictionary<TKey, TValue> |
Returns a new version with structural sharing | Incremental | Immutable data that evolves over time |
ConcurrentDictionary<TKey, TValue> |
Yes, concurrently | Normal | Shared lookup with concurrent writes |
FrozenDictionary<TKey, TValue> |
No | Relatively expensive | Long lived, read heavy lookup |
An ImmutableDictionary is often the better choice when updates are part of the normal lifecycle. A FrozenDictionary is more appropriate when construction and consumption are distinct phases.
Why freezing can improve reads
A normal dictionary must retain a general purpose layout capable of accepting future keys. A frozen dictionary knows the complete key set before its first lookup. Construction can inspect those keys, choose a representation and organise storage for the workload it will actually serve.
Rather than relying on a single frozen hash table implementation, the .NET 10 runtime selects from several specialised implementations based on the dictionary’s contents. Empty dictionaries and small collections have dedicated paths, with smaller sets sometimes using linear searches where hashing would add unnecessary overhead. Further optimisations cover dense integral keys, int keys, value types using their default comparer, and strings using ordinal or ordinal ignore case comparison. Other key types and comparer combinations use a general-purpose fallback.
String keys receive particularly detailed analysis. The runtime records their lengths and may use length buckets or distinctive substrings to reject misses or reduce the amount of each key that must be hashed. For example, a set of route names such as claims-create, claims-search and claims-update may be distinguishable using a smaller section of each string. This explains both sides of the trade off. Construction is slower because .NET is analysing the completed key set. Lookups can be faster because the chosen structure contains no machinery for later insertions and can exploit characteristics discovered during that analysis. It also explains why benchmark results vary. Key type, comparer, collection size, string shape and the ratio of hits to misses can change the implementation selected and the work performed by each lookup.
Creating a FrozenDictionary
For data obtained at runtime, the usual approach is to build or load a sequence and freeze it once:
using System.Collections.Frozen;
internal sealed record RouteDefinition(
string Code,
string Queue,
int Priority);
internal readonly record struct RouteTarget(
string Queue,
int Priority);
static FrozenDictionary<string, RouteTarget> BuildRoutes(
IEnumerable<RouteDefinition> definitions)
{
return definitions.ToFrozenDictionary(
static route => route.Code,
static route => new RouteTarget(route.Queue, route.Priority),
StringComparer.OrdinalIgnoreCase);
}
The comparer is part of the dictionary's behaviour and its optimisation strategy, so pass it deliberately. StringComparer.Ordinal and StringComparer.OrdinalIgnoreCase are normally the correct options for machine-readable identifiers. Culture-aware comparison is usually inappropriate for route names, header names, codes and configuration keys.
A comparer trap
Calling ToFrozenDictionary() without a comparer uses EqualityComparer<TKey>.Default. It doesn't automatically inherit a custom comparer from a source Dictionary<TKey, TValue>:
Dictionary<string, int> source = new(StringComparer.OrdinalIgnoreCase)
{
["Active"] = 1
};
// Uses EqualityComparer<string>.Default, which is case-sensitive.
FrozenDictionary<string, int> incorrect = source.ToFrozenDictionary();
FrozenDictionary<string, int> correct =
source.ToFrozenDictionary(StringComparer.OrdinalIgnoreCase);
The explicit comparer prevents a subtle semantic change during freezing. It is worth treating the comparer as required whenever keys are strings.
Duplicate keys aren't handled identically by every overload
The key value pair overload of ToFrozenDictionary() and the .NET 10 Create factory use last write wins behaviour when the input contains duplicate keys. This differs from LINQ's ToDictionary(), which throws.
The selector overloads currently build through ToDictionary(), so duplicate keys produced by a key selector throw ArgumentException:
// Throws if two definitions have the same code under this comparer.
FrozenDictionary<string, RouteDefinition> routes =
definitions.ToFrozenDictionary(
static route => route.Code,
StringComparer.OrdinalIgnoreCase);
Neither policy should be left to chance for business configuration. Validate duplicates explicitly and fail with an error that identifies the conflicting records.
What .NET 10 adds
.NET 10 adds FrozenDictionary.Create overloads that accept a ReadOnlySpan<KeyValuePair<TKey, TValue>>. The generic FrozenDictionary<TKey, TValue> type is also marked with CollectionBuilderAttribute, allowing it to be the target of a collection expression.
For fixed, compile time data, the result is concise:
using System.Collections.Frozen;
private static readonly FrozenDictionary<string, int> StatusIds =
[
KeyValuePair.Create("draft", 10),
KeyValuePair.Create("quoted", 20),
KeyValuePair.Create("bound", 30)
];
The target type tells the compiler that each element is a KeyValuePair<string, int>. The generated span is passed to the collection builder, which creates the frozen dictionary.
For a custom comparer in C# 14, call the factory explicitly:
private static readonly FrozenDictionary<string, int> StatusIds =
FrozenDictionary.Create(
StringComparer.OrdinalIgnoreCase,
[
KeyValuePair.Create("draft", 10),
KeyValuePair.Create("quoted", 20),
KeyValuePair.Create("bound", 30)
]);
This is more than syntactic cleanup. The new factory accepts a span directly, which gives the compiler an efficient construction path for known elements and avoids requiring an intermediate IEnumerable<KeyValuePair<TKey, TValue>> API at the call site. For data loaded from JSON, a database or configuration, ToFrozenDictionary() remains the natural option. The new factory is most useful for small static tables and APIs already working with spans.
A practical application pattern
Consider an API that resolves a submission type into its processing target. The routes are loaded once during startup and used on every request:
using System.Collections.Frozen;
WebApplicationBuilder builder = WebApplication.CreateBuilder(args);
builder.Services.AddSingleton<FrozenDictionary<string, RouteTarget>>(
static serviceProvider =>
{
IConfiguration configuration =
serviceProvider.GetRequiredService<IConfiguration>();
RouteDefinition[] definitions =
configuration
.GetSection("SubmissionRoutes")
.Get<RouteDefinition[]>() ?? [];
return definitions.ToFrozenDictionary(
static route => route.Code,
static route => new RouteTarget(route.Queue, route.Priority),
StringComparer.OrdinalIgnoreCase);
});
WebApplication app = builder.Build();
app.MapGet("/routes/{code}", (
string code,
FrozenDictionary<string, RouteTarget> routes) =>
{
return routes.TryGetValue(code, out RouteTarget target)
? Results.Ok(target)
: Results.NotFound();
});
app.Run();
The singleton factory is executed once, so the application pays the freezing cost once. Every request then reads the same immutable instance without locks.
Freezing per request would reverse the economics:
// Avoid: repeated construction dominates any lookup saving.
app.MapGet("/routes/{code}", (
string code,
IEnumerable<RouteDefinition> definitions) =>
{
FrozenDictionary<string, RouteDefinition> routes =
definitions.ToFrozenDictionary(
static route => route.Code,
StringComparer.OrdinalIgnoreCase);
return routes.TryGetValue(code, out RouteDefinition? route)
? Results.Ok(route)
: Results.NotFound();
});
The lifecycle is more important than the collection's raw lookup time. Freeze at an application boundary: after configuration is loaded, after metadata is discovered, after a model is compiled or after a cache snapshot has been assembled.
Allocation free lookups with ReadOnlySpan<char>
An advanced but practical feature is alternate lookup. A dictionary keyed by string can be queried with a ReadOnlySpan<char> when its comparer supports the alternate key type. The built-in ordinal string comparers provide this support.
This is valuable when the lookup key is already a slice of a larger buffer. Without alternate lookup, calling ToString() creates a new string solely to query the dictionary:
private static readonly FrozenDictionary<string, RouteTarget> Routes =
new Dictionary<string, RouteTarget>(StringComparer.OrdinalIgnoreCase)
{
["claims"] = new("claims-queue", 10),
["property"] = new("property-queue", 20),
["marine"] = new("marine-queue", 30)
}
.ToFrozenDictionary(StringComparer.OrdinalIgnoreCase);
static bool TryResolve(
ReadOnlySpan<char> code,
out RouteTarget target)
{
var lookup =
Routes.GetAlternateLookup<ReadOnlySpan<char>>();
return lookup.TryGetValue(code, out target);
}
The span can point into a parsed request line, header buffer or larger document without allocating a temporary string. GetAlternateLookup<TAlternateKey>() throws if the dictionary's comparer doesn't implement the required alternate comparer. Where the comparer isn't known, use TryGetAlternateLookup() and handle the unsupported case. Alternate lookup is worth introducing only when allocation profiles show temporary keys on a meaningful path. If the caller already has a string, normal TryGetValue() is clearer and does the right work.
Avoiding value copies with GetValueRefOrNullRef
GetValueRefOrNullRef() returns a read-only reference to the value stored in the dictionary, or a null reference when the key is absent. This can avoid copying a large value type:
using System.Runtime.CompilerServices;
internal readonly record struct RoutePolicy(
TimeSpan Timeout,
int RetryCount,
long MaximumBytes,
Guid PolicyId);
static TimeSpan ResolveTimeout(
FrozenDictionary<string, RoutePolicy> policies,
string code)
{
ref readonly RoutePolicy policy =
ref policies.GetValueRefOrNullRef(code);
return Unsafe.IsNullRef(in policy)
? TimeSpan.FromSeconds(30)
: policy.Timeout;
}
For reference-type values, TryGetValue() only copies an object reference, so this API rarely improves anything. It also makes absence handling less familiar. Keep TryGetValue() as the default and use ref return lookup only after identifying value copy cost in a hot path.
Refreshing a frozen lookup
Some data is read constantly but refreshed occasionally. You don't need to mutate the frozen instance. Build a replacement away from the read path, then publish it atomically:
using System.Collections.Frozen;
using System.Threading;
internal sealed class RouteTable
{
private FrozenDictionary<string, RouteTarget> _current =
FrozenDictionary<string, RouteTarget>.Empty;
public bool TryResolve(
string code,
out RouteTarget target)
{
FrozenDictionary<string, RouteTarget> snapshot =
Volatile.Read(ref _current);
return snapshot.TryGetValue(code, out target);
}
public void Replace(IEnumerable<RouteDefinition> definitions)
{
FrozenDictionary<string, RouteTarget> next =
definitions.ToFrozenDictionary(
static route => route.Code,
static route => new RouteTarget(
route.Queue,
route.Priority),
StringComparer.OrdinalIgnoreCase);
Volatile.Write(ref _current, next);
}
}
Readers see either the old complete dictionary or the new complete dictionary. They never observe a partially updated structure and don't need to acquire a lock. The replacement should be built only after the source has been validated, and stored values must still be safe to share. This snapshot pattern works well for routing rules, reference data and feature metadata refreshed every few minutes or hours. If writes occur continuously, a mutable or persistent collection is a better fit.
Enumeration and ordering
Frozen dictionaries store keys and values in aligned arrays. In .NET 10, the concrete Keys and Values properties expose ImmutableArray<TKey> and ImmutableArray<TValue>, and matching positions belong to the same entry. The order is unspecified. Don't use enumeration order as a serialization contract, display order or tie breaker. Sort explicitly when the consumer needs deterministic ordering:
RouteTarget[] ordered = routes
.OrderBy(static pair => pair.Key, StringComparer.Ordinal)
.Select(static pair => pair.Value)
.ToArray();
This sorting cost belongs at the boundary that requires order, not on every lookup.
Benchmarking the break-even point
Microbenchmarks should separate construction from lookup and should measure different lookup shapes. A miss with a key whose length doesn't occur in the dictionary can be rejected quickly by a string specialised frozen implementation. That is useful in production, but it shouldn't be the only miss in a benchmark. The following BenchmarkDotNet harness measures hits, same length misses, different-length misses and construction independently:
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
using System.Collections.Frozen;
BenchmarkSwitcher
.FromAssembly(typeof(LookupBenchmarks).Assembly)
.Run(args);
[MemoryDiagnoser]
public class LookupBenchmarks
{
private Dictionary<string, int> _dictionary = null!;
private FrozenDictionary<string, int> _frozen = null!;
private string _hit = null!;
private string _sameLengthMiss = null!;
private string _differentLengthMiss = null!;
[Params(32, 1_024, 16_384)]
public int Count { get; set; }
[GlobalSetup]
public void Setup()
{
_dictionary = new Dictionary<string, int>(
Count,
StringComparer.Ordinal);
for (int i = 0; i < Count; i++)
{
_dictionary[$"route-{i:D6}"] = i;
}
_frozen = _dictionary.ToFrozenDictionary(
StringComparer.Ordinal);
_hit = $"route-{Count / 2:D6}";
_sameLengthMiss = $"route-{Count:D6}";
_differentLengthMiss = "x";
}
[Benchmark(Baseline = true)]
public bool DictionaryHit() =>
_dictionary.TryGetValue(_hit, out _);
[Benchmark]
public bool FrozenHit() =>
_frozen.TryGetValue(_hit, out _);
[Benchmark]
public bool DictionarySameLengthMiss() =>
_dictionary.TryGetValue(_sameLengthMiss, out _);
[Benchmark]
public bool FrozenSameLengthMiss() =>
_frozen.TryGetValue(_sameLengthMiss, out _);
[Benchmark]
public bool DictionaryDifferentLengthMiss() =>
_dictionary.TryGetValue(_differentLengthMiss, out _);
[Benchmark]
public bool FrozenDifferentLengthMiss() =>
_frozen.TryGetValue(_differentLengthMiss, out _);
}
[MemoryDiagnoser]
public class ConstructionBenchmarks
{
private KeyValuePair<string, int>[] _pairs = null!;
[Params(32, 1_024, 16_384)]
public int Count { get; set; }
[GlobalSetup]
public void Setup()
{
_pairs = Enumerable
.Range(0, Count)
.Select(static i =>
KeyValuePair.Create($"route-{i:D6}", i))
.ToArray();
}
[Benchmark(Baseline = true)]
public Dictionary<string, int> BuildDictionary()
{
Dictionary<string, int> result = new(
_pairs.Length,
StringComparer.Ordinal);
foreach (KeyValuePair<string, int> pair in _pairs)
{
result[pair.Key] = pair.Value;
}
return result;
}
[Benchmark]
public FrozenDictionary<string, int> BuildFrozen() =>
_pairs.ToFrozenDictionary(StringComparer.Ordinal);
}
Run the benchmark in Release mode without a debugger:
dotnet run -c Release --filter "*"
Use the results to estimate the break-even point:
break-even lookups =
(frozen construction time - dictionary construction time)
/ (dictionary lookup time - frozen lookup time)
The calculation only produces a useful answer when frozen lookup is faster for the distribution that the application actually sees. Weight hits and each type of miss according to production telemetry, then include startup, refresh frequency and memory measurements. Published benchmarks often show impressive lookup ratios, but those figures belong to their exact runtime, hardware, keys and comparer. FrozenDictionary deliberately changes strategy according to the input, so a borrowed ratio is particularly unreliable here.
Things to watch out for!
If a collection is constructed per request, per message or inside a frequently called method, the extra construction work will usually dominate. Move freezing to startup or another infrequent lifecycle event.
.NET has a specialised representation for small frozen collections, but a cold lookup table still offers little opportunity to recover its construction cost. A normal dictionary or even a switch expression may be simpler.
Mutable values remain mutable. Prefer immutable records, immutable collections or value types when the entire snapshot is intended to be stable.
Pass the comparer explicitly. This is essential when freezing a dictionary that was created with case-insensitive or domain-specific equality.
The order of keys and values is unspecified. Sort at the point where order is required.
Microsoft's API documentation recommends constructing frozen collections only from trusted keys because key details influence construction time. If keys originate outside the trust boundary, validate them and place sensible limits on count and length before freezing.
Dictionary<TKey, TValue> is already highly optimised. Some combinations of key type, comparer, collection size and access pattern won't become faster when frozen. Measure the workload rather than the type name.
When you could use it
FrozenDictionary is a strong candidate when all of these are true:
the complete key set is available before normal processing begins;
the collection remains unchanged for a long period;
lookups or enumeration occur frequently;
the construction cost is paid once or only occasionally; and
measurement shows a useful application-level improvement.
Examples include serializer metadata, command dispatch tables, MIME mappings, parser tokens, known protocol headers, route definitions, country or currency reference data and plugin descriptors discovered during startup. I would stay with Dictionary when writes are expected, ConcurrentDictionary when readers and writers operate concurrently, and ImmutableDictionary when the application needs a succession of immutable versions.
(sorry, my daughter made me add it!)
FrozenDictionary moves work from the read path into the construction phase. That is its main advantage and its main cost. .NET 10 makes static construction cleaner through span-based factories and collection expressions, while the runtime continues to select specialised representations from the completed key set. The more advanced APIs, including alternate span lookups and ref return value access, can remove allocations and copies in genuinely hot code. The practical rule is simple: build once, read many times and benchmark with your own keys. Used at the right lifecycle boundary, FrozenDictionary can turn a stable lookup table into a faster and clearer application component. Used indiscriminately, it is just a more expensive dictionary to construct.





