Trying Frozen Dictionary in .NET
Last week my company had a hackathon. One week of free time to build or try something new. I did not want a big project. I had a few small ideas, and one of them was to try Frozen Dictionary.
I had seen the name before but never used it in real code. This was a good chance to try it, and also to measure if it really helps.
A quick look at Dictionary
Most .NET developers use Dictionary<TKey, TValue> a lot. You put keys and values in it, and you can read a value by its key very fast. You can also add or remove items any time.
That last part is the key point. A normal dictionary can change. Because it can change, .NET keeps it ready for edits. That is fine for most cases.
But sometimes you have a map that you build one time and never change again. For example, a small table of codes and names that you load when the app starts. After that you only read from it. Here, “ready for edits” is extra work that you do not need.
What is a Frozen Dictionary
FrozenDictionary came in .NET 8. It lives in the System.Collections.Frozen namespace. The idea is simple. You build it one time, and after that it is read-only. You cannot add or remove items.
Because it is read-only, .NET can do more work at build time to make reads faster later. So a frozen dictionary is slower to create but faster to read. If you read from it many times, this is a good deal.
There is also FrozenSet. It works the same way, but for a set of values, like HashSet.
Making one is easy:
using System.Collections.Frozen;
var codes = new Dictionary<string, string>
{
["US"] = "United States",
["CZ"] = "Czech Republic",
["JP"] = "Japan",
};
// build once
FrozenDictionary<string, string> frozenCodes = codes.ToFrozenDictionary();
// read many times
string name = frozenCodes["CZ"];
And for a set:
FrozenSet<string> methods = new[] { "GET", "POST", "PUT" }.ToFrozenSet();
bool ok = methods.Contains("POST");
One small note. On .NET 8 and newer this is built in. If you target something older, like netstandard2.0, you can still get it from the System.Collections.Immutable NuGet package.
What I changed at the hackathon
I did not change every map and set in the code. That was never the goal. I only looked at hot paths, the code that runs a lot, like on almost every request. A table that is read one time at startup does not gain much from this. The win shows up when the same read happens again and again. So I spent my time there and left the rest of the code alone.
On those hot paths, I looked for maps and sets that get built one time and then only get read, many times, while the app runs. Small lookup tables that never change after start. I moved those to FrozenDictionary and FrozenSet.
I set a few rules for myself:
- Only touch fields that are set one time and never change. Frozen is read-only, so anything that reloads from config had to stay a normal
Dictionary. - Keep the same comparer as before, so the matching stays the same. Case-sensitive stayed case-sensitive. Case-insensitive stayed case-insensitive.
- Keep all of it internal. I only changed private fields, so no public method changed and callers did not notice.
The goal was zero behavior change. Same results, lower cost.
A small trick with string keys
Here is one thing I liked. Say your keys are strings and you want to match them without caring about upper or lower case. A common way is to call ToUpper() on the input before every lookup:
var value = map[input.ToUpperInvariant()]; // makes a new string every time
That ToUpperInvariant() call makes a new string on every lookup. If this code runs a lot, that adds up to a lot of small memory work.
You can skip it. Just give the dictionary a comparer when you build it:
var frozen = source.ToFrozenDictionary(StringComparer.OrdinalIgnoreCase);
var value = frozen[input]; // no ToUpper needed
Now the dictionary handles the case for you, and you stop making a new string on each call.
What I measured
I did not want to only trust the docs, so I wrote a small benchmark with BenchmarkDotNet. For each shape I built two versions: the normal collection and the frozen one. Then I read from both in a loop and compared the time and the memory.
Each row below is one pass over a small batch of keys, not a single lookup. The numbers are the interesting part:
| Lookup shape | Normal | Frozen | Change |
|---|---|---|---|
| Small enum map (6 keys) | 18.96 ns | 8.27 ns | about 2.3x faster |
| Set contains (3 values) | 71.9 ns | 20.9 ns | about 3.4x faster |
| Bigger string map (about 28 keys) | 272 ns | 219 ns | about 1.2x faster |
| String map with ToUpper on every read (16 keys) | 467.7 ns, 544 B | 151.9 ns, 0 B | about 3.1x faster, no memory |
The last row is my favorite. That map used to call ToUpper on the input for every lookup, so it made a new string each time. After I moved to a frozen dictionary with a case-insensitive comparer, the ToUpper was gone. The memory per batch dropped from 544 bytes to zero, and the read got about 3x faster.
The smaller maps still got faster, just by less. That fits the idea. Frozen collections help most when reads are hot and run often. And in my case, the biggest single win came from dropping the extra ToUpper string work. The frozen part then made the reads faster on top of that.
What I got from the week
The change itself was small. Swap a few build-once maps and sets to frozen ones, add a comparer, and drop some repeated string work on the hot read paths. Nothing fancy. But it was a nice way to learn a modern .NET API, and the numbers made it clear when the change is worth it.
If you have static lookup tables on a hot path and you are on .NET 8 or newer, Frozen Dictionary is worth a look. Build once, read fast.