C# & .NET (C# 14, .NET 10 LTS) Module 5 – Arrays and collections
Read-only, immutable and frozen collections
What a read-only view still lets change, how ImmutableArray and ImmutableList make new versions, and when FrozenDictionary and FrozenSet speed up lookups.
What you will learn
- Distinguish a read-only view, an immutable collection and a frozen collection by what can still change
- Build ImmutableArray<T> and ImmutableList<T> values one change at a time and with a builder
- Create a FrozenDictionary or FrozenSet for data that never changes after start-up
- Choose a collection that many threads can read without locks
Before you start
What you’ll learn · Before you start
What you will learn
- Distinguish a read-only view, an immutable collection and a frozen collection by what can still change
- Build ImmutableArray<T> and ImmutableList<T> values one change at a time and with a builder
- Create a FrozenDictionary or FrozenSet for data that never changes after start-up
- Choose a collection that many threads can read without locks
Before you start
On this page
A timetable class handed out its station list as a ReadOnlyCollection<string>, and a moment later the same object
showed 4 stations instead of 3. Read-only means the reader cannot change it; it does not mean nobody can.
You will tell apart the three promises .NET makes, a read-only view, an immutable collection and a frozen one, build immutable values one change at a time and with a builder, and choose the kind that fits data that is built once and read by many threads.
A read-only view still shows every change to the list behind it
list.AsReadOnly() returns a read-only view: A wrapper such as the ReadOnlyCollection<T> that list.AsReadOnly() returns: changes made through it throw NotSupportedException, but it reads the collection behind it, so it shows every change the owner makes., a ReadOnlyCollection<T> that wraps the
list. The documentation says such a wrapper prevents changes made through it and reflects changes made to the collection
behind it, and the run shows both:
using System.Collections.ObjectModel;
// A timetable class keeps its stations in a list and hands out a read-only view of it.
List<string> stations = ["NDLS", "AGC", "GWL"];
ReadOnlyCollection<string> view = stations.AsReadOnly();
Console.WriteLine($"view Count {view.Count}: {string.Join(", ", view)}");
// The view refuses changes made through it...
try
{
((IList<string>)view).Add("JHS");
}
catch (NotSupportedException e)
{
Console.WriteLine($"Add through the view {e.GetType().Name}");
}
// ...but it is a window onto the list, so it shows every change its owner makes.
stations.Add("JHS");
stations[0] = "DLI";
Console.WriteLine($"after the owner's two changes Count {view.Count}: {string.Join(", ", view)}");
// A reader in the middle of a foreach over the view feels the change too.
try
{
foreach (string code in view)
{
if (code == "AGC")
{
stations.Add("BPL"); // the owner adds while someone reads
}
}
}
catch (InvalidOperationException e)
{
Console.WriteLine($"owner adds during a reader's foreach {e.GetType().Name}");
}
// The same holds for dictionaries: AsReadOnly gives a ReadOnlyDictionary over the same entries.
var platforms = new Dictionary<string, int> { ["12951"] = 1 };
IReadOnlyDictionary<string, int> platformView = platforms.AsReadOnly();
platforms["12002"] = 3;
Console.WriteLine($"ReadOnlyDictionary Count {platformView.Count}, after the owner added a train");
// A copy is the only way to hand out stations that will not change afterwards.
string[] snapshot = [.. stations];
stations.Clear();
Console.WriteLine($"after Clear() view Count {view.Count}, snapshot Length {snapshot.Length}"); Output
view Count 3: NDLS, AGC, GWL Add through the view NotSupportedException after the owner's two changes Count 4: DLI, AGC, GWL, JHS owner adds during a reader's foreach InvalidOperationException ReadOnlyDictionary Count 2, after the owner added a train after Clear() view Count 0, snapshot Length 5
Recorded with .NET SDK 10.0.401 (runtime 10.0.12) on macOS 26 arm64. To run it yourself: dotnet run views.cs
Add through the view threw NotSupportedException, yet after the owner added JHS and renamed the first station, the
view showed 4 stations, DLI first. A reader in the middle of a foreach felt the owner’s Add as an
InvalidOperationException, and a ReadOnlyDictionary made with AsReadOnly() behaved the same way. Only the copy,
[.. stations], kept its 5 stations when the list was cleared. Read-only views are the right tool when callers should
see live data but not change it; they promise nothing about the data staying put.
Checkpoint
Check: Read-only views
Immutable collections give you a new collection for every change
An immutable collection: A collection that never changes after it is made, such as ImmutableArray<T> or ImmutableList<T>. Its Add and Remove return a new collection and leave the old one as it was, so any number of threads can read it without locks. never changes after it is made. Its Add, Remove and
SetItem return a new collection and leave the old one as it was, so the documentation of
System.Collections.Immutable lists sharing a collection that never changes, and reading it from many threads without
locks, among its uses. Step through three versions of one list:
using System.Collections.Immutable;
// An immutable list never changes: every "change" returns a new list, and the old one stays as it was.
ImmutableList<string> v1 = ["NDLS", "AGC"];
ImmutableList<string> v2 = v1.Add("GWL");
ImmutableList<string> v3 = v2.SetItem(0, "DLI").Remove("AGC");
Console.WriteLine($"v1 {string.Join(", ", v1)}");
Console.WriteLine($"v2 {string.Join(", ", v2)}");
Console.WriteLine($"v3 {string.Join(", ", v3)}");
// Calling a method and dropping its result changes nothing, as with strings.
v1.Add("JHS");
Console.WriteLine($"after v1.Add(\"JHS\") on a line of its own, v1 still has {v1.Count} stations");
// ImmutableArray<T> is a struct around an array: fast to read, and every change copies the whole array.
ImmutableArray<int> fares = [450, 520, 610];
ImmutableArray<int> raised = fares.SetItem(0, 480);
Console.WriteLine($"fares [{string.Join(", ", fares)}], raised [{string.Join(", ", raised)}], fares[2] = {fares[2]}");
// Many changes at once: a builder collects them and makes one immutable result.
ImmutableArray<int>.Builder builder = ImmutableArray.CreateBuilder<int>(5);
for (int seat = 1; seat <= 5; seat++)
{
builder.Add(seat * 10);
}
ImmutableArray<int> seats = builder.MoveToImmutable();
Console.WriteLine($"from a builder [{string.Join(", ", seats)}], builder Count now {builder.Count}");
// Two lists that look equal are still compared by reference, unless you compare their items.
ImmutableList<string> again = ["NDLS", "AGC"];
Console.WriteLine($"v1 == again: {v1 == again}; same items: {v1.SequenceEqual(again)}"); Output
v1 NDLS, AGC
v2 NDLS, AGC, GWL
v3 DLI, GWL
after v1.Add("JHS") on a line of its own, v1 still has 2 stations
fares [450, 520, 610], raised [480, 520, 610], fares[2] = 610
from a builder [10, 20, 30, 40, 50], builder Count now 0
v1 == again: False; same items: True
Recorded with .NET SDK 10.0.401 (runtime 10.0.12) on macOS 26 arm64. To run it yourself: dotnet run immutable.cs
The drawing shows the whole scene; its 5 steps are listed below.
Every step as text
- The first version. A collection expression makes v1, an immutable list of two stations. Nothing will ever change it.
- v2 = v1.Add("GWL"). Add does not change v1. It returns a new list with the extra station, and the program stores it in v2.
- Two changes, one more version. SetItem(0, "DLI") and then Remove("AGC") each return a new list; v3 keeps the last one, DLI and GWL. v2 still holds three stations.
- v1.Add("JHS") on its own. This call makes a fourth list and throws it away, because nothing stores the result. v1 still has 2 stations, as the run printed.
- Every version stays valid. All three lists exist side by side. Code that was handed v1 keeps seeing NDLS and AGC, whatever any other code does, which is what makes the list safe to share.
v1.Add("JHS") on a line of its own changed nothing, the same slip as calling a string method and dropping its result.
The two main types differ inside, and the documentation’s table gives their costs:
| What differs | ImmutableArray<T> |
ImmutableList<T> |
|---|---|---|
| Inside | an array, in a struct | a balanced tree |
| Read by index | O(1) | O(log n) |
Add |
O(n): copies the array | O(log n): shares most of the tree |
| Choose it when | updates are rare or there are few items | updates are common |
For many changes at once, a builder such as ImmutableArray.CreateBuilder<int>(5) collects them like a list and then
makes one immutable result; MoveToImmutable() handed over its array and left the builder empty.
Checkpoint
Check: Immutable versions
Frozen collections are built once for fast reading
frozen collection: A FrozenDictionary<TKey,TValue> or FrozenSet<T> (.NET 8): an immutable collection that studies its keys when it is built, which takes longer than building a Dictionary, so that every later lookup is fast., FrozenDictionary<TKey,TValue> and FrozenSet<T> (.NET 8),
are immutable too, but built for one job. The documentation says a frozen dictionary has a relatively high cost to
create and excellent lookup performance, which makes it ideal for data created once, at start-up, and read for the
life of the application:
using System.Collections.Frozen;
// Station codes and names, loaded once when a service starts and read on every request after that.
var source = new Dictionary<string, string>
{
["NDLS"] = "New Delhi",
["MAS"] = "Chennai Central",
["HWH"] = "Howrah",
["SBC"] = "KSR Bengaluru",
["CSMT"] = "Mumbai CSMT",
};
FrozenDictionary<string, string> stations = source.ToFrozenDictionary(StringComparer.OrdinalIgnoreCase);
Console.WriteLine($"Count {stations.Count}; stations[\"ndls\"] = {stations["ndls"]}; TryGetValue(\"BPL\") {stations.TryGetValue("BPL", out _)}");
// A frozen dictionary has no Add or Remove, and its interface methods that would change it throw.
try
{
((IDictionary<string, string>)stations).Add("BPL", "Bhopal");
}
catch (NotSupportedException e)
{
Console.WriteLine($"Add through IDictionary {e.GetType().Name}");
}
// Changing the source afterwards does not reach the frozen copy.
source["BPL"] = "Bhopal";
Console.WriteLine($"source has {source.Count} keys, the frozen dictionary still {stations.Count}");
// When it is created, it looks at the keys it was given and picks a lookup strategy for them.
Console.WriteLine($"picked for these keys {stations.GetType().Name}");
// FrozenSet<T> is the same idea for a set.
FrozenSet<string> junctions = new[] { "NDLS", "ET", "BZA", "NGP" }.ToFrozenSet();
Console.WriteLine($"FrozenSet Contains(\"ET\") {junctions.Contains("ET")}, Contains(\"et\") {junctions.Contains("et")}: no comparer, so case counts"); Output
Count 5; stations["ndls"] = New Delhi; TryGetValue("BPL") False
Add through IDictionary NotSupportedException
source has 6 keys, the frozen dictionary still 5
picked for these keys LengthBucketsFrozenDictionary`1
FrozenSet Contains("ET") True, Contains("et") False: no comparer, so case counts
Recorded with .NET SDK 10.0.401 (runtime 10.0.12) on macOS 26 arm64. To run it yourself: dotnet run frozen.cs
ToFrozenDictionary(StringComparer.OrdinalIgnoreCase) found ndls, refused an Add through IDictionary, and kept 5
stations after the source gained a sixth. It also looked at its keys and picked an implementation for them,
LengthBucketsFrozenDictionary, which, as its name says, files the keys by their length. Frozen collections suit
settings, code tables and lookups that are written at start-up and read on every request. Answer three questions about
your data, and the table rules out the kinds that would get it wrong:
Answer the questions above: the table then marks what fits your case and says why the rest does not.
| Kind | Callers cannot change it | Stays as handed out | One change is cheap | Fast reads | Many threads may read |
|---|---|---|---|---|---|
| Read-only view Made with: list.AsReadOnly() | Good changes through it throw NotSupportedException | Poor shows every change the owner makes: Count went from 3 to 4 | Good the owner changes its own list in place | Good reads go straight to the list behind it | Poor not while the owner changes the list: a foreach threw |
| A copy Made with: [.. list] or ToArray() | Good callers get an array of their own | Good a snapshot: it kept 5 stations after the list was cleared | Poor every hand-out copies all the data again | Good an array: reading by index is O(1) | Mixed safe while nobody changes the copy, which nothing prevents |
| ImmutableArray<T> Made with: an array that never changes | Good it has no method that changes it | Good never changes once made | Poor each change copies the whole array: O(n) | Good an array inside: O(1) by index | Good immutable, so any thread may read it without a lock |
| ImmutableList<T> Made with: a tree that never changes | Good it has no method that changes it | Good never changes once made | Mixed O(log n): a new path through the tree, the rest shared | Mixed O(log n) by index; foreach is fast | Good immutable, so any thread may read it without a lock |
| Frozen collection Made with: ToFrozenDictionary() | Good no Add or Remove; IDictionary.Add threw | Good the source changed and the frozen copy did not | Poor it never changes: a new version means building it again | Good built for fast lookups by key, at a higher cost to create | Good immutable, so any thread may read it without a lock |
How the answers decide
- “Must a reader keep exactly what it was given, even if the owner changes later?” Yes, a fixed snapshot: rules out Read-only view. A view reads the live list, so a reader sees every later change instead of keeping what it was given.
- “How often does the data change after it is first built?” Often: rules out Frozen collection and adds a caveat to ImmutableArray<T> and A copy. A frozen collection cannot change at all, and copying a whole array on every change grows with its size.
- “How often does the data change after it is first built?” Rarely, far less than it is read: adds a caveat to Frozen collection. Each new version means building the frozen collection again, which costs more than building a dictionary.
- “How often does the data change after it is first built?” Never, it is built once and “How is it read?” By key or by membership: adds a caveat to ImmutableList<T>. A tree pays O(log n) on every lookup for changes that never come; a frozen collection is built for this.
- “How is it read?” By position or in order: rules out Frozen collection. FrozenDictionary and FrozenSet find by key or membership; for positions use an array or ImmutableArray<T>.
In short: a view for live data that callers must not change, an immutable collection for values that change by making new versions, and a frozen collection for lookups that never change once built.
Key idea
A read-only view stops the reader, an immutable collection stops everyone, and a frozen collection stops everyone and spends its build time on making lookups fast.
Checkpoint
Check: Frozen collections
Lookups measured: Dictionary, FrozenDictionary and ImmutableDictionary Pro: opens with a Pro, Premium or Ultimate pass
Read-heavy data is where the three dictionaries differ most, and the run below puts numbers on it. It builds each one from 1,000 branch codes and then asks it for a million codes.
In this part:
- Building and reading 1,000 branch codes, timed
- What the frozen dictionary chose for these keys
1 runnable example (C#) · about 4 minutes
Opening this part with your pass…
This part opens with any MySmartCoPilot pass.
- Pro pass · 1 week₹79$2.99
- 30 days₹179$5.99
- 1 year₹1,699$45
One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.
Passes can’t be bought right now, so this part can’t be opened at the moment. The free part above stands on its own. If you have a pass, enter your licence key.
It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).
Changing immutable collections in bulk, measured Pro: opens with a Pro, Premium or Ultimate pass
An immutable collection makes a new version for every change, and code that adds items one at a time pays for every
version. The run below counts the bytes of 1,000 Adds against a builder, then shows a trap of ImmutableArray<T>.
In this part:
- One change at a time against a builder, in bytes
- The default ImmutableArray and other traps
2 runnable examples (C#) · about 3 minutes
Opening this part with your pass…
This part opens with any MySmartCoPilot pass.
- Pro pass · 1 week₹79$2.99
- 30 days₹179$5.99
- 1 year₹1,699$45
One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.
Passes can’t be bought right now, so this part can’t be opened at the moment. The free part above stands on its own. If you have a pass, enter your licence key.
It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).
Sharing data across threads without locks Pro: opens with a Pro, Premium or Ultimate pass
Immutable and frozen collections earn their place when several threads read the same data while it is being replaced. The run below swaps a fare table 1,000 times under four readers, then lets four writers change one shared dictionary at once.
In this part:
- Swap a whole snapshot instead of changing one in place
- Small changes from many threads with ImmutableInterlocked
1 runnable example (C#) · about 3 minutes
Opening this part with your pass…
This part opens with any MySmartCoPilot pass.
- Pro pass · 1 week₹79$2.99
- 30 days₹179$5.99
- 1 year₹1,699$45
One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.
It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).
Practise: a station lookup built at start-up
lookup.cs checks a method that you write. BuildLookup turns the rows of a station file into a lookup from code to
name, built once and then read by many threads, which finds a code whatever its case. As given, the method throws
NotImplementedException, and the 6 checks fail:
using System.Collections.Frozen;
// Practice: write BuildLookup so that every check below passes, then run this file again.
// BuildLookup turns the rows of a station file into a lookup from code to name that is built once at start-up and
// then only read, by many threads: a FrozenDictionary that finds a code whatever its case.
(string Code, string Name)[] rows = [("NDLS", "New Delhi"), ("MAS", "Chennai Central"), ("HWH", "Howrah")];
var checks = new List<(string Name, Func<string> Run, string Expected)>
{
("finds NDLS", () => BuildLookup(rows)["NDLS"], "New Delhi"),
("finds mas in lower case", () => BuildLookup(rows)["mas"], "Chennai Central"),
("misses BPL without throwing", () => BuildLookup(rows).TryGetValue("BPL", out _).ToString(), "False"),
("holds three stations", () => BuildLookup(rows).Count.ToString(), "3"),
("refuses Add", () =>
{
IDictionary<string, string> asDictionary = BuildLookup(rows);
try
{
asDictionary.Add("BPL", "Bhopal");
return "added";
}
catch (NotSupportedException)
{
return "refused";
}
}, "refused"),
("an empty file", () => BuildLookup([]).Count.ToString(), "0"),
};
int passed = 0;
foreach (var (name, run, expected) in checks)
{
string verdict;
try
{
string got = run();
verdict = got == expected ? "ok" : $"FAIL, got \"{got}\"";
}
catch (Exception e)
{
verdict = $"FAIL, {e.GetType().Name}";
}
if (verdict == "ok")
{
passed++;
}
Console.WriteLine($"{name}: {verdict}");
}
Console.WriteLine($"{passed} of {checks.Count} checks pass");
return passed == checks.Count ? 0 : 1;
static FrozenDictionary<string, string> BuildLookup(IEnumerable<(string Code, string Name)> rows)
{
throw new NotImplementedException("Make key-value pairs from the rows, then call ToFrozenDictionary with a comparer.");
} Output (exit status 1)
finds NDLS: FAIL, NotImplementedException finds mas in lower case: FAIL, NotImplementedException misses BPL without throwing: FAIL, NotImplementedException holds three stations: FAIL, NotImplementedException refuses Add: FAIL, NotImplementedException an empty file: FAIL, NotImplementedException 0 of 6 checks pass
Recorded with .NET SDK 10.0.401 (runtime 10.0.12) on macOS 26 arm64. To run it yourself: dotnet run lookup.cs
Write BuildLookup
Copy lookup.cs and replace the body of BuildLookup: turn each row into a key and a value and call
ToFrozenDictionary with StringComparer.OrdinalIgnoreCase. Run dotnet run lookup.cs until it prints
6 of 6 checks pass.
These questions return to views, versions and frozen lookups, the idea you know least first:
Practice that adapts to you
Practise this lesson
6 questions about 6 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.
Interview questions
Fresher · What is the difference between a read-only collection and an immutable collection?
A read-only collection, such as the ReadOnlyCollection<T> that list.AsReadOnly() returns, is a view: it refuses
changes made through it, but it reads the collection behind it, so it shows every change the owner makes, and a reader
in a foreach can be broken by one. An immutable collection, such as ImmutableList<T>, can never change at all: Add
and Remove return a new collection and leave the old one as it was. That makes an immutable collection safe to keep,
to share and to read from many threads without locks, at the price of a new version for every change. A frozen
collection is immutable as well, and spends extra time when it is built to make lookups fast.
-
Mid to senior
A settings class exposes its values as a ReadOnlyDictionary over an internal Dictionary, and a background task refreshes the internal dictionary every minute. Request handlers sometimes throw InvalidOperationException in a foreach, or see some old and some new settings. Why, and what would you use instead?
-
Mid to senior
A service loads 50,000 product codes at start-up and looks them up about 20,000 times a second. A colleague proposes FrozenDictionary instead of Dictionary. When does that pay off, and how would you decide with measurements?
-
Fresher to mid
A batch job builds an ImmutableArray<Booking> of 200,000 bookings by calling bookings = bookings.Add(b) in a loop. It is slow and allocates far more memory than the bookings need. Why, and how do you fix it?
Key takeaways
- A read-only view such as
AsReadOnly()blocks changes through it but shows every change the owner makes. - An immutable collection never changes: its methods return new versions,
ImmutableArray<T>reads in O(1) and copies on change,ImmutableList<T>changes in O(log n), and builders make many changes at once. - A frozen collection is immutable and built once for fast lookups, which suits data written at start-up and read by every request.
Terms in this lesson
- frozen collection
- A FrozenDictionary<TKey,TValue> or FrozenSet<T> (.NET 8): an immutable collection that studies its keys when it is built, which takes longer than building a Dictionary, so that every later lookup is fast.
- immutable collection
- A collection that never changes after it is made, such as ImmutableArray<T> or ImmutableList<T>. Its Add and Remove return a new collection and leave the old one as it was, so any number of threads can read it without locks.
- read-only view
- A wrapper such as the ReadOnlyCollection<T> that list.AsReadOnly() returns: changes made through it throw NotSupportedException, but it reads the collection behind it, so it shows every change the owner makes.
References
- System.Collections.Immutable namespace (Microsoft Learn)
- ImmutableArray<T> struct (Microsoft Learn)
- ImmutableList<T> class (Microsoft Learn)
- ImmutableInterlocked class (Microsoft Learn)
- FrozenDictionary<TKey,TValue> class (Microsoft Learn)
- FrozenSet<T> class (Microsoft Learn)
- ReadOnlyCollection<T> class (Microsoft Learn)
- Collections and data structures (algorithmic complexity of collections) (Microsoft Learn)
- Dictionary<TKey,TValue> class (thread safety) (Microsoft Learn)
- Volatile class (Microsoft Learn)
- .NET memory model (dotnet/runtime design specification) (.NET Foundation)
Report a problem with this lesson
Kept only in this browser. Your Learn progress