Learning outcome
After this lesson, you can construct and query FrozenDictionary<TKey,TValue> and FrozenSet<T>, choose an appropriate equality comparer, explain their construction-versus-read trade-off, and distinguish a frozen collection from both mutable and general-purpose immutable collections.
Intuition
A typical application has two phases: configuration and operation. During configuration, it may discover routes, permissions, command names, country codes, or protocol keywords. During operation, it repeatedly asks questions such as “which handler owns this route?” or “is this role allowed?”
A mutable Dictionary<TKey,TValue> or HashSet<T> works in both phases, but retaining mutation support after initialization may be unnecessary. A frozen collection performs extra work when it is created so that its internal representation can be selected and arranged for efficient lookup and enumeration afterward. Think of freezing as producing a read-only lookup snapshot optimized for a long operational phase.
The intended shape is therefore build rarely, read frequently. Freezing a collection that is rebuilt on every request usually pays construction cost without enough subsequent lookups to justify it.
Deep dive
The types live in System.Collections.Frozen. Enumerable conversion methods such as ToFrozenDictionary and ToFrozenSet create the frozen result. A frozen dictionary supports key operations such as its indexer, ContainsKey, and TryGetValue; a frozen set supports Contains and set-relation operations.
Equality semantics remain fundamental. Case-insensitive protocol words should be frozen with an appropriate comparer, such as StringComparer.OrdinalIgnoreCase. Passing the comparer explicitly makes the intended contract visible and avoids accidental differences between construction and lookup. The comparer cannot later be replaced.
Freezing creates a structurally independent snapshot. Adding or removing entries from the mutable source afterward does not alter the frozen collection. This is not deep immutability: if a stored value is a mutable object, code holding that object can still change its internal state. Prefer immutable values when the entire published data graph must remain stable.
The concrete internal representation is an implementation detail. Do not depend on enumeration order, bucket layout, or one frozen collection always outperforming another collection. Construction is intentionally more expensive than ordinary mutable collection creation, while the payoff depends on key type, comparer, data size, runtime version, lookup distribution, and read count. Benchmark the real workload when performance determines the choice.
Frozen collections are useful for safely publishing a completed structure to concurrent readers because their structure cannot change. That does not make operations on mutable objects stored inside them thread-safe.
Choose among related collections by lifecycle:
- Use
DictionaryorHashSetwhen updates continue. - Use
ImmutableDictionaryorImmutableHashSetwhen the design needs new immutable versions after updates. - Use frozen collections when construction is rare and the same completed snapshot serves many reads.
- Use a simple array or list when data is tiny, sequential traversal dominates, or lookup optimization is unnecessary.
Failure modes
- Freezing too early: later configuration changes require constructing and publishing an entirely new frozen collection.
- Freezing too often: conversion in a request loop can cost more than it saves.
- Wrong comparer: a case-sensitive frozen set will reject differently cased input even if the business domain is case-insensitive.
- Assuming deep immutability: frozen structure does not freeze referenced objects.
- Mutating through an interface: the types expose collection interfaces for compatibility, but mutation operations are unsupported and throw rather than changing the collection.
- Treating optimization as guaranteed: only representative measurement can establish whether freezing improves a specific application.
- Using untrusted, adversarial construction data without limits: key details affect construction work, so validate and bound externally supplied data before building long-lived lookup structures.
Interview drill
Why not always replace dictionaries with frozen dictionaries? Because freezing has a relatively high one-time construction cost and prevents in-place updates. It fits stable structures that receive enough reads to amortize that cost.
Is a frozen dictionary the same as a read-only dictionary wrapper? No. A wrapper can expose a read-only view over a mutable backing dictionary. A frozen dictionary is a separate immutable snapshot with a representation optimized for its read-heavy role.
Does structural immutability make every value immutable? No. A frozen collection can contain references to mutable objects.
Why is the comparer part of the data model? It defines which keys or elements are considered equal. It therefore affects duplicate handling, membership, and lookup results—not merely performance.
When would an immutable collection be preferable? When the application needs functional updates that produce successive immutable versions. Frozen collections target a final, stable snapshot instead.
Revision checklist
- Import
System.Collections.Frozen. - Build mutable data during initialization, then call a frozen conversion method.
- Select and preserve the domain's equality comparer.
- Publish the frozen result only after construction is complete.
- Remember that freezing is shallow.
- Never rely on enumeration order or a presumed internal layout.
- Keep mutable collections when writes continue.
- Benchmark construction plus realistic lookup volume before making performance claims.
Production code
The executable example models route metadata and authorized roles. It explicitly uses ordinal case-insensitive equality, proves that source mutation does not affect the frozen dictionary, demonstrates set deduplication, and verifies that an attempted interface-based mutation is rejected. Its output contains invariant checks rather than environment-dependent timing values.
In a production service, these snapshots would commonly be built once during startup and injected into consumers through read-only abstractions or their concrete frozen types. If configuration must be reloaded, construct a complete replacement and atomically publish the new reference rather than trying to edit the old snapshot.
Code walkthrough
ToFrozenDictionary captures the initial route pairs. A later addition to the source dictionary remains absent from the frozen snapshot. ToFrozenSet collapses role names considered equal by the supplied comparer, so differently cased spellings of the same role represent one member. Queries use TryGetValue and Contains, which avoid mutation and clearly express lookup intent.
The final check casts the dictionary to a mutable-looking interface only to demonstrate its contract boundary. The setter throws NotSupportedException; catching it converts that behavior into deterministic output. Application code should not use such casts for normal reads.
Executable code examples
Build and verify frozen lookup snapshots
Program.cs
using System;
using System.Collections.Frozen;
using System.Collections.Generic;
internal static class Program
{
private static void Main()
{
var mutableRoutes = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
{
["GET"] = "Read",
["POST"] = "Write"
};
FrozenDictionary<string, string> routes =
mutableRoutes.ToFrozenDictionary(StringComparer.OrdinalIgnoreCase);
FrozenSet<string> roles = new[] { "Admin", "admin", "Guest" }
.ToFrozenSet(StringComparer.OrdinalIgnoreCase);
mutableRoutes["DELETE"] = "Remove";
routes.TryGetValue("get", out string? getAction);
routes.TryGetValue("POST", out string? postAction);
Console.WriteLine($"GET={getAction}");
Console.WriteLine($"POST={postAction}");
Console.WriteLine($"DELETE={(routes.ContainsKey("DELETE") ? "present" : "missing")}");
Console.WriteLine($"ROLE={roles.Contains("ADMIN")}");
Console.WriteLine($"SOURCE_MUTATION_ISOLATED={!routes.ContainsKey("DELETE")}");
Console.WriteLine($"SET_DEDUPLICATED={roles.Count == 2}");
Console.WriteLine($"COMPARER_PRESERVED={routes.Comparer.Equals(StringComparer.OrdinalIgnoreCase)}");
bool mutationRejected;
try
{
((IDictionary<string, string>)routes)["PATCH"] = "Update";
mutationRejected = false;
}
catch (NotSupportedException)
{
mutationRejected = true;
}
Console.WriteLine($"READ_ONLY={mutationRejected}");
}
}