Learning outcome
By the end of this lesson, you can identify workloads suited to FrozenDictionary<TKey,TValue> and FrozenSet<T>, create a stable snapshot from initialization data, preserve the correct equality rules, and plan safe replacement when the data changes. You can also explain why frozen collections are neither read-only wrappers nor substitutes for collections that must support continuing updates.
Intuition
Model the lifetime of lookup data as four stages: assemble, seal, serve, and replace.
During assemble, application startup or configuration code gathers routes, feature names, permissions, parsers, or other indexed data. Ordinary mutable collections are convenient here because entries can still be corrected or added.
During seal, the completed data is converted into a frozen collection. Conversion creates a separate structure and may perform additional analysis so the runtime can choose a representation suited to the keys and comparer.
During serve, the same snapshot answers many queries without structural changes. This is the phase in which the initial conversion cost may be recovered through repeated lookups and efficient enumeration.
During replace, changed configuration is not applied to the frozen instance. The application assembles and freezes another complete snapshot, then publishes the new reference to future readers.
This lifecycle is more important than collection size alone. A large table rebuilt constantly can be a poor frozen-collection candidate, while a modest table created once and queried millions of times can be a strong candidate.
Deep dive
FrozenDictionary<TKey,TValue> and FrozenSet<T> are provided by System.Collections.Frozen. They are normally produced through the ToFrozenDictionary and ToFrozenSet conversion methods rather than populated one element at a time.
A frozen dictionary maps each distinct key to one value. Its normal read operations include key membership tests, indexed access, and non-throwing lookup. A frozen set stores distinct elements and supports membership plus relations such as overlap, subset, and set equality.
The equality comparer is part of the collection's meaning. For example, a command registry using ordinal, case-insensitive equality treats START and start as the same command. A registry using ordinal case-sensitive equality treats them as separate keys. This affects duplicate detection, stored element count, and every later query; it is not merely a tuning option.
Pass the intended comparer during conversion instead of assuming that an earlier collection's comparer will be inferred. Once created, a frozen collection's comparer cannot be exchanged. If dictionary input contains two keys that the selected comparer considers equal, conversion is not a conflict-resolution strategy. Resolve that ambiguity before freezing. Set conversion, by contrast, naturally retains one representative of equivalent elements.
Conversion creates a new structural snapshot. Adding an entry to the source dictionary afterward does not add it to the frozen dictionary. Removing an element from the source set does not remove it from the frozen set. Source data also should not be modified concurrently while conversion is reading it; complete the mutable build phase before beginning the conversion.
Structural immutability is shallow. Suppose a frozen dictionary maps identifiers to mutable settings objects. The set of identifiers and their associated object references cannot be edited through the frozen dictionary, but another reference can still mutate a settings object. If consumers require an unchanging object graph, values must themselves be immutable, copied, or otherwise protected.
Frozen collections are suitable for concurrent read access after safe publication because no reader can change their structure. Safe publication still matters: startup synchronization, dependency-injection initialization, a volatile field, or an interlocked reference exchange can establish how readers receive a completed instance. Freezing does not make mutable values thread-safe and does not repair unsafe publication practices.
The word “frozen” also means more than a read-only view. A read-only wrapper can sit on top of a mutable dictionary; changes to that backing dictionary may then appear through the wrapper. A frozen collection owns an independent lookup structure created from the source at conversion time.
It is also different from a general-purpose immutable collection. Immutable dictionaries and sets are designed to produce new versions as updates occur, often sharing internal structure between versions. Frozen collections target a terminal version: prepare the data, convert it once, and keep reading it. Replacing one frozen snapshot with another is valid, but each replacement requires a fresh conversion.
Performance should be evaluated as a total lifecycle cost. The relevant question is not simply whether one lookup is fast. Account for source construction, freezing, retained memory, query volume, enumeration, and replacement frequency. Results can vary with runtime version, key type, comparer, key distribution, and access pattern. The runtime's chosen representation is an implementation detail, so applications must not depend on bucket layout or a particular internal specialization.
Enumeration order is likewise not a contract to build business logic around. Sort explicitly when output requires a stable presentation order. If the data is tiny or usually scanned in sequence, an array or list may remain clearer and sufficiently efficient. If writes continue, use a mutable collection. If successive persistent versions are central to the design, prefer an immutable collection.
Failure modes
- Converting inside a hot request path. Repeated freezing can dominate the work that a handful of later lookups were meant to save.
- Selecting equality by accident. Default string equality may disagree with a domain that expects ordinal case-insensitive identifiers.
- Expecting duplicate dictionary keys to be reconciled. Normalize or reject conflicting input before conversion.
- Treating the snapshot as deeply immutable. Objects stored as values or elements can retain mutable state.
- Retaining the source as an authority. After publication, define clearly whether the mutable builder is discarded or merely unrelated to the active snapshot.
- Attempting updates through collection interfaces. Compatibility interfaces do not turn a frozen collection back into a writable one; mutating operations are unsupported.
- Depending on iteration order. An observed order can change with data, comparer, or runtime implementation.
- Publishing a partially prepared replacement. Build and freeze the complete next version before making it visible to readers.
- Assuming a universal speed improvement. A workload with few reads or frequent rebuilds may perform better with a conventional collection.
- Accepting unlimited external construction input. Validate size and key rules before creating a long-lived snapshot, especially when comparer or hashing work can be influenced by untrusted data.
Interview drill
What problem do frozen collections solve? They serve stable lookup data that is expensive or unnecessary to keep writable and that will receive enough reads to justify a one-time conversion.
Why is a read-only dictionary wrapper not equivalent? A wrapper can continue reflecting changes made to its backing dictionary. A frozen dictionary is a separately created structural snapshot.
Can a frozen dictionary be updated by creating a modified version? Not through a functional update operation on that instance. Build the desired data and create a new frozen dictionary. Use an immutable dictionary when frequent version-producing updates are a core requirement.
Does a frozen set make its elements immutable? No. It prevents structural insertion and removal. A referenced element can still have mutable fields or properties.
Why should string comparison usually be explicit? Text identifiers can have domain-specific casing rules. An explicit ordinal comparer makes duplicate and membership behavior deliberate and avoids culture-sensitive surprises.
Are frozen collections automatically the fastest option? No. Their benefit depends on construction frequency, number and shape of reads, key characteristics, comparer behavior, memory costs, and the runtime version. Representative benchmarking is required for a performance claim.
How should live configuration reload work? Create the replacement from validated configuration, freeze it completely, and publish the new reference atomically. Existing readers may finish with the old snapshot while later readers receive the new one.
Revision checklist
- Separate the mutable assembly phase from the read-only service phase.
- Import the frozen collections namespace and use the appropriate conversion method.
- Choose equality rules from the business domain, especially for strings.
- Resolve dictionary key collisions before conversion.
- Do not mutate the source while conversion is in progress.
- Remember that source changes after conversion do not alter the snapshot.
- Protect or replace mutable objects stored inside the frozen structure.
- Publish completed snapshots through an appropriate synchronization mechanism.
- Rebuild and replace rather than attempting in-place edits.
- Avoid relying on enumeration order or internal representation.
- Measure the entire build-and-read lifecycle before claiming a performance gain.
Production code
A realistic service might assemble an operation table keyed by protocol verb and a set of privileged role names during startup. Both collections should use ordinal, case-insensitive comparison if the protocol defines identifiers that way. Validation should detect conflicting operation definitions before dictionary conversion, while equivalent role spellings can intentionally collapse into one set member.
The service should expose only the finished snapshots to request handlers. On configuration reload, it should validate a separate candidate model, create both replacement frozen collections, package them into one immutable configuration holder, and publish that holder in a single reference exchange. Grouping related lookups prevents readers from observing a new route table paired with an old role set.
Performance tests should model startup or reload cost as well as sustained requests. They should use representative key lengths, successful and unsuccessful searches, actual comparers, realistic collection sizes, and the expected ratio of reads to replacements.
Code walkthrough
The associated executable example follows the assemble-seal-serve sequence. It first prepares route mappings and role names in mutable inputs, then converts them using explicit ordinal case-insensitive comparers. Queries with different casing demonstrate that membership follows the selected equality contract.
After conversion, the example changes the mutable route source and checks that the new source entry is absent from the frozen dictionary. It also checks that equivalent role spellings produce one logical set member. Finally, it exercises the read-only boundary through a collection interface and confirms that a requested mutation is rejected rather than applied.
These checks are deterministic behavioral invariants, not timing claims. They demonstrate snapshot independence, comparer-driven lookup, set deduplication, and structural immutability without assuming anything about internal layout or machine-dependent performance.
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}");
}
}