Readonly vs Immutable vs Frozen in C#: differences and (a lot of) benchmarks
The words ReadOnly, Immutable, Frozen seem similar but have distinct meanings in .NET. Here’s a detailed comparison of Readonly, Immutable, and Frozen collections in C#, with benchmarks and typical use cases.
Table of Contents
Just a second! π«·
If you are here, it means that you are a software developer. So, you know that storage, networking, and domain management have a cost .
If you want to support this blog, please ensure that you have disabled the adblocker for this site. I configured Google AdSense to show as few ADS as possible - I don't want to bother you with lots of ads, but I still need to add some to pay for the resources for my site.
Thank you for your understanding.
- Davide
ReadOnly, Immutable, Frozen: three words that sound similar but have very different meanings in the context of collections in .NET.
Frozen collections were released with .NET 8, adding a new way to create immutable stuff. But what are they? How do they differ from Readonly and Immutable collections?
In this article, we will explore the differences between Readonly, Immutable, and Frozen collections in C#. Even though the words might seem similar, they have distinct meanings and implications. We will explore each type of collection, discuss their characteristics, and provide examples to illustrate their differences.
Finally, we will see a HUGE set of benchmarks for those collections, and reason about the memory and performance implications.
Let’s begin!
Readonly collections in C#: a read-only view of a mutable collection
When we talk about Readonly collections in C#, we are referring to collections that cannot be modified through its reference. This means that while you can read from the collection, you cannot directly add, remove, or change its elements. But they can actually change (and we will see how later).
We can have several types of interfaces, like: IReadOnlyList<T>, IReadOnlyCollection<T>, IReadOnlyDictionary<TKey,TValue>, which represent, of course, read-only views of lists, collections, and dictionaries.
And, for concrete types, we have ReadOnlyCollection<T> and ReadOnlyDictionary<TKey,TValue>. It’s interesting to notice that they are just wrappers of List<T> (or any IList<T>) and Dictionary<TKey,TValue>, defined in a way that the methods that generally would allow you to modify a list are “blocked”.
How to create Readonly collections
Once you have a List<T>, you can create a ReadOnlyCollection<T> by calling the AsReadOnly() method, or by using the constructor of ReadOnlyCollection<T> that takes an IList<T> as a parameter.
IList<string> originalGamesList = ["Dixit", "Coloretto", "Azul"];
ReadOnlyCollection<string> readOnlyList = originalGamesList.AsReadOnly();
ReadOnlyCollection<string> readOnlyList2 = new ReadOnlyCollection<string>(originalGamesList);
Print(readOnlyList); // Output: Dixit, Coloretto, Azul
// readOnlyList.Add("Catan"); // compile-time error: there is no Add method
ReadOnlyCollection<T> implements IList<T>, IList and IReadOnlyList<T>, but it throws NotSupportedException for any method that would modify the collection (like Add, Remove, Clear, etc.).
Interestingly, the ReadOnlyCollection<T> does not create a copy of the original list; it simply provides a read-only view of it. This means that if you modify the original list, those changes will be reflected in the ReadOnlyCollection<T>.
List<string> originalGamesList = ["Dixit", "Coloretto", "Azul"];
ReadOnlyCollection<string> readOnlyList = originalGamesList.AsReadOnly();
ReadOnlyCollection<string> readOnlyList2 = new ReadOnlyCollection<string>(originalGamesList);
originalGamesList.Add("Catan"); // Note: I'm modifying the original list, not the read-only collection!
Print(readOnlyList); // Output: Dixit, Coloretto, Azul, Catan
Print(readOnlyList2); // Output: Dixit, Coloretto, Azul, Catan
You can find more details on the source code of the ReadOnlyCollection<T> class, on GitHub.
How to create Readonly dictionaries
Similarly, you can create a ReadOnlyDictionary<TKey,TValue> by using its constructor that takes an IDictionary<TKey,TValue> as a parameter.
Dictionary<string, string> gameDescriptions = new()
{
{ "Dixit", "A storytelling game with beautiful artwork." },
{ "Coloretto", "A card game about collecting colors." },
{ "Azul", "A tile-placement game inspired by Portuguese tiles." }
};
ReadOnlyDictionary<string, string> readonlyGameDescriptions = new ReadOnlyDictionary<string, string>(gameDescriptions);
and, again, you cannot add or modify entries to the ReadOnlyDictionary:
ReadOnlyDictionary<string, string> readonlyGameDescriptions = new ReadOnlyDictionary<string, string>(gameDescriptions);
readonlyGameDescriptions["Dixit"] = "An updated description for Dixit."; // This will throw a compile-time error because ReadOnlyDictionary does not have a setter.
readonlyGameDescriptions.Add("Catan", "A popular strategy board game."); // This will also throw a compile-time error for the same reason.
But if you modify the original dictionary, those changes will be reflected in the ReadOnlyDictionary:
Dictionary<string, string> gameDescriptions = new()
{
{ "Dixit", "A storytelling game with beautiful artwork." },
{ "Coloretto", "A card game about collecting colors." },
{ "Azul", "A tile-placement game inspired by Portuguese tiles." }
};
ReadOnlyDictionary<string, string> readonlyGameDescriptions = new ReadOnlyDictionary<string, string>(gameDescriptions);
gameDescriptions["Dixit"] = "An updated description for Dixit."; // Modifying the original dictionary
gameDescriptions.Add("Catan", "A popular strategy board game."); // Adding a new entry to the original dictionary
Console.WriteLine(readonlyGameDescriptions["Dixit"]); // Output: An updated description for Dixit.
Readonly collections, in summary
If I had to summarize Readonly collections in just one sentence, I would say:
You canβt modify it through this reference.
Readonly wrappers prevent modification through the wrapper, but they do not freeze the underlying collection.
If someone still holds the original List<T> / Dictionary<TKey,TValue> reference, and they mutate the collection, your “readonly” instance will reflect those changes.
So, we can say that Readonly is great for:
- Exposing data from your interfaces/classes without giving callers a way to mutate the collection;
- Communicating intent: Β«callers shouldnβt mutate thisΒ»;
Personally, I often use Readonly collections as return types of methods exposed through interfaces: the concrete implementation can use whichever internal type, but sometimes I don’t want the callers to modify the collection. This way, I can prevent accidental mutations by callers while still allowing internal code to modify the collection as needed, and without too much impact on performance.
Immutable collections in C#: true snapshots that never change
Immutable collections are persistent data structures from System.Collections.Immutable, such as ImmutableList<T>, ImmutableArray<T>, ImmutableDictionary<TKey,TValue> and ImmutableHashSet<T>.
In addition to these concrete types, System.Collections.Immutable also provides interfaces such as IImmutableList<T>, IImmutableDictionary<TKey,TValue>, IImmutableSet<T>, and so on. In practice, you will often expose the interfaces from your own APIs (to keep callers decoupled from specific implementations) while instantiating and working with the concrete types in your code. The concrete types provide the actual implementations, factory methods (like Create and the ToImmutable* extensions), and the mutation-like methods (Add, Remove, SetItem, etc.) that return new immutable instances.
How to create Immutable collections
If you want to create an immutable collection, you can use the ToImmutableList(), ToImmutableArray(), ToImmutableDictionary(), and ToImmutableHashSet() extension methods provided by the System.Collections.Immutable namespace. Or, for the same types, you can use the .CreateRange() static methods.
IList<string> originalGamesList = ["Dixit", "Coloretto", "Azul"];
ImmutableList<string> immutableList = originalGamesList.ToImmutableList();
ImmutableList<string> immutableList2 = ImmutableList.CreateRange(originalGamesList);
Print(immutableList); // Output: Dixit, Coloretto, Azul
What happens when you try to modify an immutable collection?
Immutable collections provide a strong guarantee: once created, an immutable instance never changes. Any operation that would modify the collection (like Add, Remove, SetItem) returns a new collection instance.
IList<string> originalGamesList = ["Dixit", "Coloretto", "Azul"];
ImmutableList<string> immutableList = originalGamesList.ToImmutableList();
Print(immutableList); // Output: Dixit, Coloretto, Azul
ImmutableList<string> newList = immutableList.Add("Catan");
Print(immutableList); // Output: Dixit, Coloretto, Azul (original list is unchanged)
Print(newList); // Output: Dixit, Coloretto, Azul, Catan (new list has the added game)
So, the original instance is actually immutable: every time you call Add, Remove, SetItem (or similar), you create a new instance. Having new instances created after each of these operations guarantees thread safety by design: you will not have concurrent threads add or remove elements from a unique collection, as with every operation you create a brand new instance of the collection.
Again, I suggest you look at the source code of ImmutableList<T> to understand how it works under the hood: you can find it on
GitHub.
Immutable collections, in summary
Immutable collections are useful when you need:
- True snapshots: history/undo, replay, audit trails
- Thread-safety by design for shared read-mostly state
- Functional-style updates without side effects
If I had to summarize Immutable collections in just one sentence, I would say:
Immutable collections are like snapshots: once created, they never change, and any modification produces a new instance.
Frozen collections in C#: read-only, precomputed, lookup-optimised
From .NET 8 onwards we have Frozen collections, which are read-only, precomputed, lookup-optimised collections intended for hot read paths.
The two available types are FrozenDictionary<TKey,TValue> and FrozenSet<T>.
The idea is that you build them once (typically from an existing dictionary/set), then use them many times. I specified that you build them only once because the creation of such collection is quite expensive: so you’d better avoid creating Frozen collections too many times.
To create a Frozen collection, you start with a regular Dictionary<TKey,TValue> or HashSet<T>, and then you call ToFrozenDictionary() or ToFrozenSet() to create the frozen version.
After freezing, the structure is fixed and cannot be modified. Frozen collections can be faster than Dictionary/HashSet for repeated lookups because they can choose specialized internal behavior based on data and precompute data to reduce per-lookup overhead.
The trade-off is that they have a higher build cost and sometimes more memory usage, but they can provide faster reads after creation.
How to create Frozen collections
As mentioned before, if you want to create a FrozenSet<T> starting from an IList<T> or a HashSet<T>, you can use the ToFrozenSet() extension method.
IList<string> originalGamesList = ["Dixit", "Coloretto", "Azul"];
FrozenSet<string> set = originalGamesList.ToFrozenSet();
ImmutableArray<string> items = set.Items;
The items in a FrozenSet<T> are stored in an ImmutableArray<T>, which we saw before.
Usually, the best use case for frozen collections is when you have data that is built once at startup (or cached), and you do a lot of lookups. You can, for example, use the Contains() method to check for the presence of an item in a FrozenSet<T>, which - surprisingly - can be even faster than a HashSet<T> in certain scenarios.
FrozenSet<string> set = originalGamesList.ToFrozenSet();
bool hasDixit = set.Contains("Dixit"); // true
bool hasCatan = set.Contains("Catan"); // false
How to create a FrozenDictionary
Similarly, if you want to create a FrozenDictionary<TKey,TValue> starting from an IDictionary<TKey,TValue>, you can use the ToFrozenDictionary() extension method.
Dictionary<string, string> gameDescriptions = new()
{
{ "Dixit", "A storytelling game with beautiful artwork." },
{ "Coloretto", "A card game about collecting colors." },
{ "Azul", "A tile-placement game inspired by Portuguese tiles." }
};
FrozenDictionary<string, string> frozenDict = gameDescriptions.ToFrozenDictionary(kvp => kvp.Key, kvp => kvp.Value);
Here, too, you cannot add or modify entries to the FrozenDictionary<TKey,TValue>. If you need to update it, you would have to create a new frozen dictionary by rebuilding it from a modified source dictionary.
Frozen collections, in summary
Frozen collections are ideal for scenarios where you build a collection once and then perform a large number of lookups. They are particularly useful for optimizing hot paths, such as frequent lookups.
If I had to summarize Frozen collections in just one sentence, I would say:
Frozen collections are built once, optimised for fast reads/lookup, and never change.
Benchmarking performance and memory allocation
Ok, but what about performance? How do these different collection types compare in terms of speed and memory usage?
To answer this question, I created a (imho, quite complete) benchmark using BenchmarkDotNet that compares the performance of readonly, immutable, and frozen collection for both building the collection and performing lookups and updates. You can find the full source code of the benchmark in this Gist.
The charts are created with Charts for BenchmarkDotNet.
Let’s see how these collections behave in different scenarios. For each operation I created collections of 10, 100, 1000 and 10000 elements.
But before, some important notes.
I’m using the following environment for the benchmarks:
BenchmarkDotNet v0.13.11, Windows 11 (10.0.26200.7623)
13th Gen Intel Core i7-1355U, 1 CPU, 12 logical and 10 physical cores
.NET SDK 10.0.103
[Host] : .NET 10.0.3 (10.0.326.7603), X64 RyuJIT AVX2
ShortRun : .NET 10.0.3 (10.0.326.7603), X64 RyuJIT AVX2
To better showcase the difference between collections, I will show the diagrams using a logarithmic (log2) scale on the Y-axis. This is because the differences in performance can be quite significant, especially as the size of the collections increases.
Also, to simplify the tables showing the benchmark results, I will remove some columns that are not essential for understanding the performance differences (like RatioSD, Error, StdDev), and I will focus on the Mean, Ratio, and Allocated columns. Sometimes I left the Garbage Collector usages as well, since you might find that data interesting. However, you can find the full tables with all the details in the original benchmark source code.
Finally, as I mentioned before, I created collections of 10, 100, 1000 and 10000 elements. While in the charts I will show the results for all sizes, in the tables I will show only the results for 10 and 10000 elements, to better highlight the differences of the tiniest and the biggest examples.
Benchmarking the creation of collections
Let’s start with the creation of collections. This is the cost you pay to build a Readonly, Immutable, or Frozen collection from an existing list.
[Benchmark(Description = "List to ReadOnlyCollection", Baseline = true)
, BenchmarkCategory("Creation of collection")]
public ReadOnlyCollection<string> Build_ReadOnlyCollection()
=> _list.AsReadOnly();
[Benchmark(Description = "Build ImmutableList from array")
, BenchmarkCategory("Creation of collection")]
public ImmutableList<string> Build_ImmutableList()
=> ImmutableList.Create(_gameNames);
[Benchmark(Description = "Build FrozenSet from HashSet")
, BenchmarkCategory("Creation of collection")]
public FrozenSet<string> Build_FrozenSet()
=> _hashSet.ToFrozenSet(StringComparer.Ordinal);
| Method | N | Mean | Ratio |
|---|---|---|---|
| List to ReadOnlyCollection | 10 | 3.4803 ns | 1.00 |
| Build ImmutableList from array | 10 | 88.4492 ns | 25.42 |
| Build FrozenSet from HashSet | 10 | 444.8004 ns | 127.82 |
| List to ReadOnlyCollection | 10000 | 3.3767 ns | 1.00 |
| Build ImmutableList from array | 10000 | 102,208.3435 ns | 30,269.52 |
| Build FrozenSet from HashSet | 10000 | 495,070.8496 ns | 146,618.42 |

As you can see, Readonly is blazing fast to create, as it is just a wrapper around an existing collection. Immutable has a higher cost, as it needs to create a new instance and copy the elements. Frozen has the highest cost, as it needs to build an optimised structure for lookups. Even for a collection with just 10 elements, the FrozenSet is almost 130x slower than the simple ReadonlyCollection.
Have a look at the case of 10,000 elements: creating a ReadonlyCollection takes about 3.4 nanoseconds, while creating an ImmutableList takes about 102 microseconds (which is about 30,000 times slower), and creating a FrozenSet takes about 495 microseconds (which is about 146,000 times slower than Readonly).
The memory allocation follows a similar pattern:
| Method | N | Gen0 | Gen1 | Gen2 | Allocated | Alloc Ratio |
|---|---|---|---|---|---|---|
| List to ReadOnlyCollection | 10 | 0.0038 | - | - | 24 B | 1.00 |
| Build ImmutableList from array | 10 | 0.0802 | 0.0001 | - | 504 B | 21.00 |
| Build FrozenSet from HashSet | 10 | 0.1950 | - | - | 1224 B | 51.00 |
| List to ReadOnlyCollection | 10000 | 0.0038 | - | - | 24 B | 1.00 |
| Build ImmutableList from array | 10000 | 76.4160 | 34.5459 | - | 480024 B | 20,001.00 |
| Build FrozenSet from HashSet | 10000 | 158.2031 | 132.3242 | 113.2813 | 766904 B | 31,954.33 |

Readonly allocates a small amount of memory for the wrapper, while Immutable and Frozen allocate much more memory for the new instances and the optimised structures.
Benchmarking the creation of dictionaries
Let’s see now the creation of dictionaries. This is the cost you pay to build a Readonly, Immutable, or Frozen dictionary from an existing dictionary.
[Benchmark(Description = "Build ReadonlyDictionary from Dictionary", Baseline =true),
BenchmarkCategory("Creation of Dictionary")]
public ReadOnlyDictionary<string, int> Build_ReadOnlyDictionary()
=> new ReadOnlyDictionary<string, int>(_dict);
[Benchmark(Description = "Build ImmutableDictionary from Dictionary"),
BenchmarkCategory("Creation of Dictionary")]
public ImmutableDictionary<string, int> Build_ImmutableDictionary()
=> _dict.ToImmutableDictionary(StringComparer.Ordinal);
[Benchmark(Description = "Build FrozenDictionary from Dictionary")
, BenchmarkCategory("Creation of Dictionary")]
public FrozenDictionary<string, int> Build_FrozenDictionary()
=> _dict.ToFrozenDictionary(StringComparer.Ordinal);
| Method | N | Mean | Ratio |
|---|---|---|---|
| Build ReadonlyDictionary from Dictionary | 10 | 3.6705 ns | 1.00 |
| Build ImmutableDictionary from Dictionary | 10 | 520.5665 ns | 141.82 |
| Build FrozenDictionary from Dictionary | 10 | 453.9815 ns | 123.69 |
| Build ReadonlyDictionary from Dictionary | 10000 | 3.9717 ns | 1.00 |
| Build ImmutableDictionary from Dictionary | 10000 | 2,059,534.8307 ns | 518,594.40 |
| Build FrozenDictionary from Dictionary | 10000 | 516,141.3249 ns | 129,949.29 |
There’s a twist! The creation of an Immutable dictionary actually is slower than Frozen dictionary!

For memory allocation, instead, we follow a pattern we saw earlier: Readonly allocates a small amount of memory, while Immutable and Frozen allocate much more.
| Method | N | Gen0 | Gen1 | Gen2 | Allocated | Alloc Ratio |
|---|---|---|---|---|---|---|
| Build ReadonlyDictionary from Dictionary | 10 | 0.0064 | - | - | 40 B | 1.00 |
| Build ImmutableDictionary from Dictionary | 10 | 0.1287 | - | - | 808 B | 20.20 |
| Build FrozenDictionary from Dictionary | 10 | 0.2151 | - | - | 1352 B | 33.80 |
| Build ReadonlyDictionary from Dictionary | 10000 | 0.0064 | - | - | 40 B | 1.00 |
| Build ImmutableDictionary from Dictionary | 10000 | 101.5625 | 58.5938 | - | 640171 B | 16,004.27 |
| Build FrozenDictionary from Dictionary | 10000 | 173.8281 | 144.5313 | 116.2109 | 847003 B | 21,175.08 |

Benchmarking the access by index, with a collection
In this scenario, we want to see how fast it is to access an element by index. I’m accessing the middle element of the collection, to avoid any bias related to accessing the first or the last element.
[Benchmark(Description = "List index access (middle)")
, BenchmarkCategory("Access by index")]
public string Read_List_Index()
=> _list[N / 2];
[Benchmark(Description = "ReadOnlyCollection index access (middle)")
, BenchmarkCategory("Access by index")]
public string Read_ReadOnlyCollection_Index()
=> _readOnlyCollection[N / 2];
[Benchmark(Description = "ImmutableArray index access (middle)")
, BenchmarkCategory("Access by index")]
public string Read_ImmutableArray_Index()
=> _immutableArray[N / 2];
[Benchmark(Description = "ImmutableList index access (middle)")
, BenchmarkCategory("Access by index")]
public string Read_ImmutableList_Index()
=> _immutableList[N / 2];
[Benchmark(Description = "Hashset index access (middle)", Baseline =true)
, BenchmarkCategory("Access by index")]
public string Read_HashSet_Index()
=> _hashSet.ElementAt(N / 2);
| Method | N | Mean | Ratio |
|---|---|---|---|
| List index access (middle) | 10 | 0.9278 ns | 0.11 |
| ReadOnlyCollection index access (middle) | 10 | 0.9429 ns | 0.12 |
| ImmutableArray index access (middle) | 10 | 0.9507 ns | 0.12 |
| ImmutableList index access (middle) | 10 | 1.0329 ns | 0.13 |
| Hashset index access (middle) | 10 | 8.2025 ns | 1.00 |
| ImmutableArray index access (middle) | 10000 | 0.7562 ns | 0.000 |
| ReadOnlyCollection index access (middle) | 10000 | 0.9635 ns | 0.000 |
| List index access (middle) | 10000 | 0.9801 ns | 0.000 |
| ImmutableList index access (middle) | 10000 | 1.3269 ns | 0.000 |
| Hashset index access (middle) | 10000 | 3,662.3615 ns | 1.000 |
Here’s the chart for the access by index:

As you can see, HashSet is much slower than the other collections for access by index, because it does not support indexed access (of course!!). The other collections (List, ReadOnlyCollection, ImmutableArray, ImmutableList) have similar performance for indexed access, as they all support it.
However, ImmutableArrays are slightly faster than the others, as they are optimised for read-only access and have a more compact memory layout.
Who’s missing? The FrozenSet<T>! This is because FrozenSet<T> does not support access by index, as it is optimised for lookups rather than indexed access.
Benchmarking the Contains method, either with an existing or not existing key
Similar to the previous scenario, we want to see how fast it is to check if a collection contains a specific element that we know exists.
[Benchmark(Description = "HashSet Contains (existing)", Baseline =true)
, BenchmarkCategory("Search existing key")]
public bool Read_HashSet_Contains_Existing()
=> _hashSet.Contains(_existingKey);
[Benchmark(Description = "ImmutableHashSet Contains (existing)")
, BenchmarkCategory("Search existing key")]
public bool Read_ImmutableHashSet_Contains_Existing()
=> _immutableHashSet.Contains(_existingKey);
[Benchmark(Description = "FrozenSet Contains (existing)")
, BenchmarkCategory("Search existing key")]
public bool Read_FrozenSet_Contains_Existing()
=> _frozenSet.Contains(_existingKey);
| Method | N | Mean | Ratio |
|---|---|---|---|
| HashSet Contains (existing) | 10 | 5.8899 ns | 1.00 |
| ImmutableHashSet Contains (existing) | 10 | 6.9038 ns | 1.17 |
| FrozenSet Contains (existing) | 10 | 1.7451 ns | 0.30 |
| HashSet Contains (existing) | 10000 | 6.4451 ns | 1.00 |
| ImmutableHashSet Contains (existing) | 10000 | 13.2918 ns | 2.07 |
| FrozenSet Contains (existing) | 10000 | 2.4166 ns | 0.38 |
As we could imagine, FrozenSet is the fastest for checking the presence of an existing element, followed by HashSet and then ImmutableHashSet.

That’s because, as we said before, FrozenSet is optimised for lookups, while HashSet and ImmutableHashSet are not.
And what if the searched element does not exist in the collection? Let’s see:
[Benchmark(Description = "HashSet Contains (non-existing)", Baseline = true)
, BenchmarkCategory("Search non-existing key")]
public bool Read_HashSet_Contains_NonExisting()
=> _hashSet.Contains(_missingKey);
[Benchmark(Description = "ImmutableHashSet Contains (non-existing)")
, BenchmarkCategory("Search non-existing key")]
public bool Read_ImmutableHashSet_Contains_NonExisting()
=> _immutableHashSet.Contains(_missingKey);
[Benchmark(Description = "FrozenSet Contains (non-existing)")
, BenchmarkCategory("Search non-existing key")]
public bool Read_FrozenSet_Contains_NonExisting()
=> _frozenSet.Contains(_missingKey);
| Method | N | Mean | Ratio |
|---|---|---|---|
| HashSet Contains (non-existing) | 10 | 4.9264 ns | 1.00 |
| ImmutableHashSet Contains (non-existing) | 10 | 9.3023 ns | 1.89 |
| FrozenSet Contains (non-existing) | 10 | 0.9128 ns | 0.19 |
| HashSet Contains (non-existing) | 10000 | 5.3123 ns | 1.00 |
| ImmutableHashSet Contains (non-existing) | 10000 | 13.8555 ns | 2.61 |
| FrozenSet Contains (non-existing) | 10000 | 0.6459 ns | 0.12 |
In this case, the performance gap between FrozenSet and the other collections is even more pronounced, as FrozenSet is optimised for lookups and can quickly determine that an element does not exist, while HashSet and ImmutableHashSet have to do more work to check for the presence of a non-existing element.

Benchmarking getting a value from a dictionary, either existing or not
Ok, and what if we want to retrieve the element from a dictionary, rather than just checking if the element exists or not?
Let’s start with the happy path: the element exists.
[Benchmark(Description = "Dictionary TryGetValue (existing)", Baseline =true)
, BenchmarkCategory("Search existing key in dictionary")]
public int Read_Dictionary_TryGetValue_Existing()
=> _dict.TryGetValue(_existingKey, out var v) ? v : 0;
[Benchmark(Description = "ReadOnlyDictionary TryGetValue (existing)")
, BenchmarkCategory("Search existing key in dictionary")]
public int Read_ReadOnlyDictionary_TryGetValue_Existing()
=> _readOnlyDict.TryGetValue(_existingKey, out var v) ? v : 0;
[Benchmark(Description = "ImmutableDictionary TryGetValue (existing)")
, BenchmarkCategory("Search existing key in dictionary")]
public int Read_ImmutableDictionary_TryGetValue_Existing()
=> _immutableDict.TryGetValue(_existingKey, out var v) ? v : 0;
[Benchmark(Description = "FrozenDictionary TryGetValue (existing)")
, BenchmarkCategory("Search existing key in dictionary")]
public int Read_FrozenDictionary_TryGetValue_Existing()
=> _frozenDict.TryGetValue(_existingKey, out var v) ? v : 0;
| Method | N | Mean | Ratio |
|---|---|---|---|
| Dictionary TryGetValue (existing) | 10 | 3.5528 ns | 1.00 |
| ReadOnlyDictionary TryGetValue (existing) | 10 | 3.4054 ns | 0.96 |
| ImmutableDictionary TryGetValue (existing) | 10 | 6.1793 ns | 1.74 |
| FrozenDictionary TryGetValue (existing) | 10 | 1.9275 ns | 0.54 |
| Dictionary TryGetValue (existing) | 10000 | 4.1378 ns | 1.00 |
| ReadOnlyDictionary TryGetValue (existing) | 10000 | 4.2453 ns | 1.03 |
| ImmutableDictionary TryGetValue (existing) | 10000 | 11.6684 ns | 2.82 |
| FrozenDictionary TryGetValue (existing) | 10000 | 2.8559 ns | 0.69 |

Again, the least performant is the ImmutableDictionary. And, for heavy loads, a FrozenDictionary is twice as fast as the simple Dictionary.
And, for non-existing keys, the results are similar:
[Benchmark(Description = "Dictionary TryGetValue (missing)", Baseline = true)
, BenchmarkCategory("Search missing key")]
public int Read_Dictionary_TryGetValue_Missing()
=> _dict.TryGetValue(_missingKey, out var v) ? v : 0;
[Benchmark(Description = "ImmutableDictionary TryGetValue (missing)")
, BenchmarkCategory("Search missing key")]
public int Read_ImmutableDictionary_TryGetValue_Missing()
=> _immutableDict.TryGetValue(_missingKey, out var v) ? v : 0;
[Benchmark(Description = "FrozenDictionary TryGetValue (missing)")
, BenchmarkCategory("Search missing key")]
public int Read_FrozenDictionary_TryGetValue_Missing()
=> _frozenDict.TryGetValue(_missingKey, out var v) ? v : 0;
| Method | N | Mean | Ratio |
|---|---|---|---|
| Dictionary TryGetValue (missing) | 10 | 3.5900 ns | 1.00 |
| ImmutableDictionary TryGetValue (missing) | 10 | 9.7884 ns | 2.73 |
| FrozenDictionary TryGetValue (missing) | 10 | 0.3435 ns | 0.10 |
| Dictionary TryGetValue (missing) | 10000 | 4.0473 ns | 1.00 |
| ImmutableDictionary TryGetValue (missing) | 10000 | 13.1921 ns | 3.26 |
| FrozenDictionary TryGetValue (missing) | 10000 | 0.5266 ns | 0.13 |

Just as before, there is a HUGE difference in performance when retrieving an element.
Benchmarking adding a new element
Time for the final benchmark: what’s to cost of updating an element?
[Benchmark(Description = "ImmutableDictionary SetItem (creates new)")
, BenchmarkCategory("Update")]
public ImmutableDictionary<string, int> Update_ImmutableDictionary_SetItem()
=> _immutableDict.SetItem(_existingKey, 99);
[Benchmark(Description = "Dictionary update in-place", Baseline =true)
, BenchmarkCategory("Update")]
public int Update_Dictionary_InPlace()
{
_dict[_existingKey] = 99;
return _dict[_existingKey];
}
// Frozen update requires rebuild; this benchmark shows the cost explicitly.
[Benchmark(Description = "FrozenDictionary 'update' (rebuild)")
, BenchmarkCategory("Update")]
public FrozenDictionary<string, int> Update_FrozenDictionary_Rebuild()
{
var copy = new Dictionary<string, int>(_dict, StringComparer.Ordinal)
{
[_existingKey] = 99
};
return copy.ToFrozenDictionary(StringComparer.Ordinal);
}
| Method | N | Mean | Ratio |
|---|---|---|---|
| Dictionary update in-place | 10 | 8.1697 ns | 1.00 |
| ImmutableDictionary SetItem (creates new) | 10 | 67.7431 ns | 8.30 |
| FrozenDictionary update (rebuild) | 10 | 551.6185 ns | 67.55 |
| Dictionary update in-place | 10000 | 9.7677 ns | 1.00 |
| ImmutableDictionary SetItem (creates new) | 10000 | 213.5151 ns | 21.86 |
| FrozenDictionary update (rebuild) | 10000 | 605,496.2891 ns | 61,989.69 |
As we saw before, Immutable dictionaries create a new instance of the dictionary itself. And the same goes for Frozen dictionaries. However, creating a Frozen dictionary is way heavier than creating a new Immutable dictionary.

But, also don’t forget about the memory usage required to re-initialize an element!
| Method | N | Gen0 | Gen1 | Gen2 | Allocated |
|---|---|---|---|---|---|
| Dictionary update in-place | 10 | - | - | - | - |
| ImmutableDictionary SetItem (creates new) | 10 | 0.0370 | - | - | 232 B |
| FrozenDictionaryupdate (rebuild) | 10 | 0.2928 | - | - | 1840 B |
| Dictionary update in-place | 10000 | - | - | - | - |
| ImmutableDictionary SetItem (creates new) | 10000 | 0.1287 | - | - | 808 B |
| FrozenDictionaryupdate (rebuild) | 10000 | 230.4688 | 195.3125 | 166.0156 | 1130445 B |
We have no bytes allocated for the in-place update of the Dictionary, while we have more than 1 Megabyte allocated for the rebuild of the FrozenDictionary!

A quick comparison table
Ok, I have to admit, too much information!
Let’s summarize the differences in a simple table:
| Feature | Readonly wrapper / IReadOnly | Immutable | Frozen |
|---|---|---|---|
| Prevent mutation via exposed API | β | β | β |
| Prevent mutation by anyone | β (unless no one has the backing reference) | β | β |
| Updates create a new instance | N/A (you mutate backing collection) | β | β (by rebuilding) |
| Best for | Encapsulation, cheap views | Snapshots, undo/redo, shared state | High-volume lookups, read-mostly caches |
| Typical cost to create | Very low | Medium | Higher (but it’s usually a one-time operation) |
| Typical lookup speed | Baseline | Often slightly slower than Dictionary |
Often fastest in steady state |
Now that you also have a lot of benchmarks to look at, consider the trade-offs of using one data type compared to the other. And remember that the charts are using log2 scale!
Further readings
There are some interesting resources if you want to dive deeper into this topic, out there.
One I appreciated, not only for the content but also for the discussions in the comments, is this one by Steven Giesel:
π Frozen collections in .NET 8 | Steven Giesel
This article first appeared on Code4IT π§
As promised, here’s the GitHub Gist that contains the source code of the benchmarks, as well as the results in Markdown and CSV.
π Benchmarks used for these charts | GitHub Gist
Ok, I have to admit, I kind-of like benchmarks. I started experimenting with BenchmarkDotNet a while ago, as an excuse to learn about the performance of the Enum.HasFlag method.
π Enum.HasFlag performance with BenchmarkDotNet | Code4IT
Wrapping up
I hope this article helped you understand the differences between Readonly, Immutable, and Frozen collections in .NET.
Each type of collection has its own use cases and trade-offs, so it’s important to choose the right one based on your specific needs.
Personally, I use Readonly collections as return types of methods exposed through interfaces.
Since I seldom need to create true snapshots, I’ve never used Immutable collections too much. But, since they are thread-safe by design, I can see how they can be useful in certain scenarios.
Frozen collections are optimal when you load lots of data at startup and need fast, read-only access throughout the application’s lifetime. Consider using them for things like configuration data!
As you see, there is no one-size-fits-all answer: the best choice depends on your specific use case and requirements.
I hope you enjoyed this article! Let’s keep in touch on LinkedIn, Twitter or BlueSky! π€π€
Happy coding!
π§