Some collections are built once and then read millions of times. Configuration maps, lookup tables, feature flags, route dictionaries — all follow the same pattern: populate at startup, then query at high frequency. .NET 8 introduced FrozenDictionary<TKey, TValue> and FrozenSet<T> in System.Collections.Frozen specifically for this pattern.

The Trade-Off

Frozen collections are immutable — once created, they cannot be modified. In exchange, they optimise their internal layout for the specific data they contain. Construction is slower than a regular Dictionary<TKey, TValue>, but every subsequent lookup is faster.

This makes them a poor choice for data that changes frequently, and an excellent choice for data that is loaded once at startup.

Basic Usage

Example.cs
using System.Collections.Frozen;

// Build from any IEnumerable<KeyValuePair<,>>
var lookup = new Dictionary<string, int>
{
    ["alpha"] = 1,
    ["bravo"] = 2,
    ["charlie"] = 3,
    ["delta"] = 4
}.ToFrozenDictionary();

// Lookups work the same as Dictionary
if (lookup.TryGetValue("bravo", out var value))
{
    Console.WriteLine(value); // 2
}

For sets:

Example.cs
var allowedOrigins = new[]
{
    "https://example.com",
    "https://app.example.com",
    "https://api.example.com"
}.ToFrozenSet();

if (allowedOrigins.Contains(requestOrigin))
{
    // allow the request
}

Why Are They Faster?

FrozenDictionary analyses the keys at construction time and selects an optimised strategy. For example:

This adaptive behaviour is why construction is expensive — the collection is essentially compiling a lookup strategy tailored to the specific data.

Benchmarking the Difference

Here is a representative benchmark comparing Dictionary and FrozenDictionary for string key lookups:

FrozenVsDictionary.cs
[MemoryDiagnoser]
public class FrozenVsDictionary
{
    private Dictionary<string, int> _dict = null!;
    private FrozenDictionary<string, int> _frozen = null!;
    private string _key = null!;

    [GlobalSetup]
    public void Setup()
    {
        var data = Enumerable.Range(0, 100)
            .ToDictionary(i => $"key-{i:D4}", i => i);

        _dict = data;
        _frozen = data.ToFrozenDictionary();
        _key = "key-0050";
    }

    [Benchmark(Baseline = true)]
    public bool DictionaryLookup() => _dict.ContainsKey(_key);

    [Benchmark]
    public bool FrozenLookup() => _frozen.ContainsKey(_key);
}

Typical results show frozen lookups completing in 40-60% of the time taken by regular dictionary lookups, with zero allocations in both cases. The gap widens further with string keys, where the frozen collection can often avoid computing the full hash.

Real-World Use Cases

Route matching in web frameworks. ASP.NET Core's routing system could benefit from frozen dictionaries since routes are registered at startup and never change.

Feature flags and configuration.

FeatureService.cs
public class FeatureService
{
    private readonly FrozenDictionary<string, bool> _features;

    public FeatureService(IConfiguration config)
    {
        _features = config.GetSection("Features")
            .GetChildren()
            .ToFrozenDictionary(
                c => c.Key,
                c => bool.Parse(c.Value ?? "false"));
    }

    public bool IsEnabled(string feature) =>
        _features.TryGetValue(feature, out var enabled) && enabled;
}

HTTP header validation.

Example.cs
private static readonly FrozenSet<string> _allowedHeaders =
    new[] { "Content-Type", "Authorization", "Accept", "X-Request-Id" }
    .ToFrozenSet(StringComparer.OrdinalIgnoreCase);

public bool IsHeaderAllowed(string header) =>
    _allowedHeaders.Contains(header);

When Not to Use Them

Summary

FrozenDictionary and FrozenSet are purpose-built for the "write once, read many" pattern. They analyse their contents at construction time to select the fastest possible lookup strategy. If you have a static lookup table that is queried on every request, replacing Dictionary with FrozenDictionary is a straightforward, zero-risk optimisation.