Skip to content
Search lessons, topics, tests…
Esc

    ↑ ↓ moveEnter openEsc close
    Cheat sheets: all cheat sheets

    Frozen Collections in .NET: Comparers and Trade-offs

    Build stable lookup snapshots with frozen collections.

    Choose the lookup contract

    Must "get" find the same handler as "GET", and what should duplicate initialization keys mean? Use this reference for comparer, duplicate-input and construction/read trade-offs. Preserve those policies explicitly when freezing.

    For the separate object-mutation question, use Snapshots and Mutation Traps. The common construction program is retained in both references so each can be run independently.

    Version and example baseline

    Frozen collections were introduced in .NET 8. The examples here target a .NET 10 console application and use only framework libraries.

    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 from a mutable builder 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. Omitting the comparer uses the default equality comparer, not the builder's comparer. Once created, a frozen collection's comparer cannot be exchanged. Duplicate handling depends on the overload: the key/value-pair ToFrozenDictionary overload keeps the last value for equivalent keys; the selector overloads used below reject duplicates on .NET 10. Neither behavior decides the correct business policy for conflicting definitions. Validate that policy before freezing. Set conversion, by contrast, naturally retains one representative of equivalent elements.

    Converting a mutable builder creates an independent 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

    1. Converting inside a hot request path. Repeated freezing can dominate the work that a handful of later lookups were meant to save.
    2. Selecting equality by accident. Default string equality may disagree with a domain that expects ordinal case-insensitive identifiers.
    3. Assuming one duplicate policy for every overload. A last-value-wins conversion can silently discard a conflicting definition. Validate or deliberately resolve conflicts before conversion.
    4. Treating the snapshot as deeply immutable. Objects stored as values or elements can retain mutable state.
    5. Retaining the source as an authority. After publication, define clearly whether the mutable builder is discarded or merely unrelated to the active snapshot.
    6. Attempting updates through collection interfaces. Compatibility interfaces do not turn a frozen collection back into a writable one; mutating operations are unsupported.
    7. Depending on iteration order. An observed order can change with data, comparer, or runtime implementation.
    8. Publishing a partially prepared replacement. Build and freeze the complete next version before making it visible to readers.
    9. Assuming a universal speed improvement. A workload with few reads or frequent rebuilds may perform better with a conventional collection.
    10. 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 internal command table and a set of privileged role names during startup. Both collections should use ordinal, case-insensitive comparison only if the application contract defines identifiers that way. The sample command strings do not specify HTTP method semantics or implement authorization. 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

    C#
    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}");
        }
    }

    Expected output of the snapshot example

    Text
    GET=Read
    POST=Write
    DELETE=missing
    ROLE=True
    SOURCE_MUTATION_ISOLATED=True
    SET_DEDUPLICATED=True
    COMPARER_PRESERVED=True
    READ_ONLY=True

    DELETE=missing shows that a later builder entry is absent from this snapshot. ROLE=True and the two comparer/deduplication checks follow the explicit case-insensitive contract. READ_ONLY=True records the rejected interface setter, not a timing or allocation measurement.

    Worked comparer and duplicate exercise

    Run this as a separate Program.cs. The builder contains one command, BUILD, with a case-insensitive comparer. The two later definitions deliberately conflict under that same policy.

    C#
    using System;
    using System.Collections.Frozen;
    using System.Collections.Generic;
    
    var builder = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase)
    {
        ["BUILD"] = 1
    };
    var defaultSnapshot = builder.ToFrozenDictionary();
    var explicitSnapshot = builder.ToFrozenDictionary(builder.Comparer);
    Console.WriteLine($"DEFAULT_LOWERCASE={defaultSnapshot.ContainsKey("build")}");
    Console.WriteLine($"EXPLICIT_LOWERCASE={explicitSnapshot.ContainsKey("build")}");
    
    KeyValuePair<string, int>[] definitions =
    {
        new("START", 1),
        new("start", 2)
    };
    var pairsSnapshot = definitions.ToFrozenDictionary(StringComparer.OrdinalIgnoreCase);
    Console.WriteLine($"PAIRS_WINNER={pairsSnapshot["START"]}");
    
    bool selectorRejected;
    try
    {
        _ = definitions.ToFrozenDictionary(
            pair => pair.Key, pair => pair.Value, StringComparer.OrdinalIgnoreCase);
        selectorRejected = false;
    }
    catch (ArgumentException)
    {
        selectorRejected = true;
    }
    Console.WriteLine($"SELECTOR_DUPLICATE_REJECTED={selectorRejected}");

    Expected output on .NET 10:

    Text
    DEFAULT_LOWERCASE=False
    EXPLICIT_LOWERCASE=True
    PAIRS_WINNER=2
    SELECTOR_DUPLICATE_REJECTED=True

    Worked answer: build is absent when conversion falls back to default string equality, but present when the builder's comparer is passed explicitly. The pair-sequence overload leaves value 2 for START; the selector overload throws for these equivalent keys. If duplicate command definitions are invalid, enforce that rule before publishing either snapshot rather than accepting whichever API happens to return a result.

    Worked lifecycle decision

    A configuration service receives 200,000 lookups between reloads. Suppose your own measurements show that freezing adds 4 milliseconds per reload and saves 40 nanoseconds per lookup. These are invented exercise inputs, not benchmark results.

    Worked answer: 4 milliseconds is 4,000,000 nanoseconds. Dividing by 40 gives a break-even point of 100,000 lookups. At 200,000 lookups, the lookup saving is 8 milliseconds, leaving 4 milliseconds after the extra build cost. At 20,000 lookups, it saves only 0.8 milliseconds and loses 3.2 milliseconds overall. If lookups are not faster, there is no positive break-even point under this model. Real acceptance still needs the existing checklist's memory, reload-latency, and workload checks.

    A mutable key or set element is another lifecycle risk: the equality-relevant state must stay stable while it belongs to the snapshot. Changing that state, or changing a stateful comparer's rules, violates the lookup assumptions even though no collection method inserts or removes anything. Prefer stable identifiers and immutable lookup data.

    TechInterviewNotes Editorial Team · Updated 28 Oct 2026 ·