BrandGhost
Collections in C#: Access, Mutation, and Ownership Explained

Collections in C#: Access, Mutation, and Ownership Explained

Collections in C# are easier to understand when you start with the questions your application asks about its data. Do you need the third item, the item associated with an identifier, or an answer about membership? Does the order matter? Can callers change the data? These are different requirements, even when the values happen to be identical.

A collection is not just a container with a familiar method name. Its public behavior establishes a contract around access, equality, ordering, and change. Understanding that contract helps explain why replacing one collection with another can change application behavior without changing the element type.

This guide connects those ideas through a small application scenario. It is not a ranking of collection performance, a Dictionary syntax tutorial, or an implementation of the Iterator design pattern. Examples use ordinary language features available in C# 12 and APIs documented for stable .NET 10. They are complete console examples, reviewed against documentation but not executed.

Collections in C# Begin With the Reader's Question

Imagine an application displaying submitted request identifiers. The display needs to show requests in arrival order, including repeated submissions. A second part of the application checks whether an identifier has already appeared. A third finds the current status associated with an identifier. All three use strings, but they ask different questions.

A list represents a sequence with positions. A set represents membership under an equality rule. A dictionary represents associations between unique keys and values. Treating these as interchangeable storage choices hides information that the application actually needs.

Microsoft documents List as an indexed collection that permits duplicates. Its current positions are observable, but it is not automatically sorted. If a screen requires alphabetical order rather than arrival order, the application must establish that order deliberately.

Application question Useful collection shape Important boundary
What is the item at this position? List<T> or array Position is not a stable identity
Has this value appeared? HashSet<T> Equality defines uniqueness; no ordering promise
What value belongs to this key? Dictionary<TKey,TValue> Keys are unique; values can repeat
Which pending item arrived first? Queue<T> FIFO describes removal order
Which item was added most recently? Stack<T> LIFO describes removal order
What can callers enumerate? IEnumerable<T> No snapshot or immutability promise

The table describes semantics, not a benchmark. A requirement to preserve duplicate events cannot be solved by dropping them into a set simply because membership checks are attractive. A requirement to associate values with identifiers cannot be solved by remembering list positions when insertions can move those positions.

A Sequence and a Set Can Describe the Same Input Differently

Consider three submissions, two of which differ only in letter case. The display keeps every submission. The membership check uses a case-insensitive identifier policy. Both views are useful, but they are not the same data model.

using System;
using System.Collections.Generic;

var submissions = new List<string> { "REQ-1", "req-1", "REQ-2" };
var knownIds = new HashSet<string>(StringComparer.OrdinalIgnoreCase);

foreach (var submission in submissions)
{
    var firstAppearance = knownIds.Add(submission);
    Console.WriteLine($"{submission}: first appearance = {firstAppearance}");
}

Console.WriteLine($"Submissions: {submissions.Count}");
Console.WriteLine($"Distinct identifiers: {knownIds.Count}");
Console.WriteLine($"Second submission: {submissions[1]}");

The list retains three entries. The set represents two distinct identifiers under its comparer. These counts answer different questions: how many submissions occurred, and how many identifiers occurred. Neither count is a correction of the other.

The comparer belongs to the meaning of the identifier. OrdinalIgnoreCase is appropriate here because the example explicitly defines identifiers as case-insensitive. It should not be applied automatically to passwords, case-sensitive tokens, or arbitrary user-facing text. Collection selection does not replace a domain decision about equality.

Microsoft's HashSet documentation describes elements as being in no particular order. Do not turn whatever order appears during a test run into a display guarantee. If both uniqueness and presentation order matter, maintain an ordered result alongside membership tracking, or establish an explicit ordering after membership is determined.

The same distinction appears when choosing when and how to use a Dictionary. Its key-based access answers an association question, not a positional or chronological question.

Keyed Access Requires Stable Equality

A dictionary maps each key to a value. Different keys may refer to equal values, so unique keys do not imply unique values. Two request identifiers can both have the status "Pending" without violating the dictionary's contract.

The key's equality and hash behavior must remain suitable while that key is stored. Microsoft's Dictionary contract requires stable key hash values and defines enumeration order as undefined. A mutable object's changing name can therefore be a poor key if its name participates in hashing.

using System;
using System.Collections.Generic;

var statuses = new Dictionary<string, string>(StringComparer.Ordinal)
{
    ["REQ-1"] = "Pending",
    ["REQ-2"] = "Pending"
};

if (statuses.TryGetValue("REQ-1", out var status))
{
    Console.WriteLine(status);
}
else
{
    Console.WriteLine("No status exists for that request.");
}

statuses["REQ-1"] = "Complete";
Console.WriteLine($"Associations: {statuses.Count}");

The indexer assignment replaces the value associated with an existing key. It does not add another occurrence of the same key. TryGetValue makes absence explicit without treating a missing request as a successful lookup.

Strings make this example straightforward because their contents do not change. Custom keys require an equally deliberate identity policy. A record can be useful for value-oriented keys, but a record containing mutable nested objects does not automatically make every equality-relevant property safe. The entire equality policy needs to remain stable.

Dictionary lookup is commonly described in average or typical terms near constant complexity with suitable hashing. That is not a fixed-duration promise. Hash quality, comparisons, collisions, resizing, and the operation being performed all matter. The existing Dictionary how-to covers the basic operations without requiring another general Dictionary tutorial here.

Mutation Has More Than One Meaning

Changing a collection can mean adding an element, removing an element, replacing a position, or modifying an object already referenced by an element. Those actions affect different layers of the data.

A list of mutable request objects can keep the same count while request properties change. Conversely, a list of immutable strings can change structurally when strings are added or removed. Describing either list merely as “mutable data” loses useful precision.

That precision becomes essential when a method returns an interface. IEnumerable<T> exposes enumeration, not Add or an indexer. It does not establish that the implementation is immutable. The IEnumerable beginner guide explains the traversal surface; ownership is a separate promise.

IReadOnlyCollection<T> adds a count, while IReadOnlyList<T> also exposes positional reading. Microsoft's read-only list remarks explicitly exclude an element-content guarantee. A caller may be unable to replace the collection's third element through that interface while still holding a reference to a mutable object at that position.

Read-Only Views Are Not Independent Snapshots

A read-only wrapper can prevent mutation through its own interface while reflecting changes made through another reference. A copy separates the outer collection storage, but does not necessarily clone referenced objects.

using System;
using System.Collections.Generic;

var source = new List<string> { "REQ-1", "REQ-2" };
IReadOnlyList<string> liveView = source.AsReadOnly();
IReadOnlyList<string> snapshot = Array.AsReadOnly(source.ToArray());

source.Add("REQ-3");

Console.WriteLine($"Live view count: {liveView.Count}");
Console.WriteLine($"Snapshot count: {snapshot.Count}");

The wrapper reflects the added request, while the copied array does not acquire that new member. The distinction comes from ownership of the backing storage, not from the two variables' identical declared interfaces. Microsoft documents that a ReadOnlyCollection reflects underlying changes.

This snapshot is shallow. If the element type were a mutable class instead of string, copying the list would normally copy references to the same objects. A stable set of members would not freeze their properties. A detached data-transfer representation can provide stronger separation when that is genuinely required.

There is also a cost boundary. Copying a collection acquires all of its current elements and requires separate storage for the copied structure. That can be appropriate for a bounded response. It is not automatically appropriate for an unbounded source or a large query when the caller only needs one answer.

The iterator and collection traps explore consumption costs. This guide instead asks what the returned collection promises after it reaches the caller.

Consumption Order Is Different From Display Order

A queue removes the oldest remaining item first. A stack removes the most recently added remaining item first. These are useful when consuming data rather than selecting arbitrary positions.

An arrival-ordered list could represent pending work, but allowing unrestricted indexing and removal makes the consumption policy less explicit. A queue's operations communicate FIFO directly. A stack's operations communicate LIFO directly. The policy becomes visible in the API instead of being hidden in an agreement about list indexes.

This does not make a queue a complete background-processing system. It has no inherent durability, acknowledgement, retry policy, or asynchronous waiting contract. Those concerns surround collection operations rather than follow from their names.

Likewise, a stack can represent undo history without implementing every aspect of undo. The application still needs an operation that reverses a prior action and a policy for what happens when a new action invalidates redo history. A collection supplies order; the surrounding workflow supplies meaning.

For ordinary queue and stack consumption, the try APIs make an expected empty state explicit. A false result means no item was removed at that operation. It does not mean all future producers have finished or that a larger workflow has completed.

Enumeration Is a Collection-Specific Contract

foreach asks a source to provide elements. It does not universally copy the source at the beginning of the loop. A materialized list, a generated sequence, and a concurrent dictionary can therefore behave differently under change.

The foreach guide covers traversal syntax. When change matters, inspect the actual collection and runtime contract rather than applying one rule to every implementation of IEnumerable<T>.

For example, Microsoft's ConcurrentDictionary enumerator documentation says concurrent enumeration is not a moment-in-time snapshot. A traversal can be supported during writes without representing one consistent historical version.

Avoid the opposite universal rule too: it is inaccurate to say every mutation always invalidates every collection enumerator. Behavior depends on the operation, collection, and version. Follow the specific documentation when a loop and changes overlap.

This is why a collection returned for reporting needs more than an enumerable interface if the report depends on consistent membership. The provider must establish the snapshot or synchronization policy, and the consumer must know its boundary.

Shared Collections Need Shared Rules

Ordinary collection APIs are not automatically safe for concurrent mutation. A read-only wrapper does not fix this if another reference can still write to its backing collection. Even copying requires a safe acquisition policy if writers can run during the copy.

A lock can protect a workflow when every relevant reader and writer uses the same lock. A concurrent collection can protect supported operations. Neither choice should be interpreted as a universal guarantee that all surrounding application behavior is atomic.

Consider reading a request status and then deciding to add a notification. The status could change between the read and the notification. Making the dictionary lookup thread-safe does not combine the lookup, decision, and external notification into one indivisible action.

The existing Dictionary variations provide background on keyed collections. Shared workflow correctness still requires identifying exactly which condition must hold together, and which actions can tolerate another thread intervening.

Follow One Requirement Through the Whole Contract

An application response can require several properties at once. Suppose a screen needs the first twenty requests in arrival order, including repeated submissions, and those displayed identifiers should not change after the response is created. A list-shaped response describes positions and duplicates. A detached outer copy describes membership stability. Neither property implies the other.

If request objects include mutable status histories, the response needs another decision. Does the screen intentionally display current history through shared objects, or does it display values captured for this response? A shallow collection copy supports the first possibility but cannot silently promise the second.

The same requirement becomes more demanding when the response is assembled from shared state. A safe copy needs coordination during acquisition, not just a read-only type after acquisition. The collection selection, return interface, element representation, and synchronization policy are four connected decisions.

This exercise is useful because it prevents a familiar type from absorbing unrelated promises. List<T> does not mean “owned by the caller.” IReadOnlyList<T> does not mean “permanently unchanged.” ConcurrentDictionary does not mean “everything around this lookup is transactional.”

There is a practical maintenance benefit too. A later developer can change the internal representation while preserving the visible contract when that contract is explicit. If callers secretly depend on current enumeration order or mutable casts, replacing the implementation can break behavior that the API never meant to promise.

Start with a sentence about the data: “These are captured identifiers in arrival order, with duplicates retained.” Then check whether the code actually establishes every part of that sentence. That is more precise than describing the result only as a collection.

Frequently Asked Questions

Is List the default collection for every application?

No. It is a useful representation for ordered, indexed data that may contain duplicates. A set, dictionary, queue, or stack communicates different semantics. Begin with the question the application asks, not a general belief that one familiar container is always sufficient.

Does HashSet preserve insertion order?

Do not rely on it as an insertion-ordered collection. Its documented model has no particular element order. If output order is observable, establish a sequence or an explicit sorting policy separately from the set's membership policy.

Is Dictionary enumeration order guaranteed in .NET 10?

No. The .NET 10 documentation describes its enumeration order as undefined. An order observed in an experiment is not a public contract. Applications requiring presentation order should model that requirement explicitly.

Does IReadOnlyList make the elements immutable?

No. It restricts the declared collection operations, not the mutability of referenced objects. Another alias may also change a mutable backing collection. Read-only access, structural independence, and deep immutability are distinct properties.

Is a copied collection automatically thread-safe?

No. The copy must first be acquired safely, and mutable objects can still be shared between the original and the copy. A detached structure helps with one ownership boundary, but does not establish every concurrency or element-state guarantee.

Does foreach create a snapshot?

Not universally. It uses the source's enumeration behavior. Some collections provide snapshot-style traversal and others do not. Consult the exact collection's contract instead of inferring snapshot semantics from the loop syntax.

Understanding Collections in C# as Contracts

Collection names become more useful when they explain application behavior: sequence, membership, association, FIFO, or LIFO. Return interfaces explain accessible operations. Ownership and synchronization explain what can change and who can change it.

Those ideas fit together without requiring a universal winner. A collection is suitable when its documented behavior matches the meaning the application needs, and its ownership boundaries are clear to the next developer reading the code.

An error has occurred. This application may no longer respond until reloaded. Reload