Collections and Iterators

This section documents C# 14 on .NET 10 (LTS), as published at learn.microsoft.com/dotnet/csharp, which is the reference these pages are written and verified against. Features introduced by C# 15 / .NET 11 are still in preview and are always flagged as such — never presented as baseline.

This content was generated with the assistance of AI and should be verified against learn.microsoft.com before being relied on in production.

This section’s bibliography lists the reference material consulted while preparing these pages.

Collections are where most C# programs spend their data. The BCL offers a deliberately small set of concrete types over a rich set of interfaces, so code can accept the weakest contract it needs and callers can supply whatever storage suits them.

Arrays

An array is a fixed-length, zero-indexed block of same-typed elements. It is a reference type even when its elements are values.

public static class ArrayBasics
{
    public static void Run()
    {
        int[] byLength = new int[3];                  // all elements are default(int) == 0
        int[] byInitializer = { 1, 2, 3 };            // classic array initializer
        int[] explicitly = new int[] { 1, 2, 3 };
        int[] targetTyped = [1, 2, 3];                // collection expression (C# 12) -- preferred

        Console.WriteLine(targetTyped.Length);        // 3
        Console.WriteLine(targetTyped[0]);            // 1
        Console.WriteLine(byLength[2] + byInitializer[2] + explicitly[2]);

        // Array's static helpers cover the everyday operations.
        int[] values = [5, 3, 9, 1];
        Array.Sort(values);
        Console.WriteLine(string.Join(",", values));            // 1,3,5,9
        Console.WriteLine(Array.IndexOf(values, 5));            // 2
        Console.WriteLine(Array.BinarySearch(values, 9));       // 3 -- requires a sorted array

        int[] copy = new int[values.Length];
        Array.Copy(values, copy, values.Length);
        Array.Reverse(copy);
        Console.WriteLine(string.Join(",", copy));              // 9,5,3,1
    }
}

Multidimensional and Jagged Arrays

C# distinguishes a rectangular array (one object, several dimensions) from a jagged array (an array of array references, rows of independent length):

public static class ArrayShapes
{
    public static void Run()
    {
        // Rectangular: a single allocation, indexed with one bracket pair.
        int[,] grid = new int[2, 3];
        grid[1, 2] = 7;
        Console.WriteLine($"{grid.GetLength(0)}x{grid.GetLength(1)} rank {grid.Rank}");  // 2x3 rank 2

        int[,] initialised =
        {
            { 1, 2, 3 },
            { 4, 5, 6 },
        };
        Console.WriteLine(initialised[1, 1]);   // 5

        // Jagged: an array of arrays -- rows may differ in length, and may be null.
        int[][] jagged =
        [
            [1],
            [2, 3],
            [4, 5, 6],
        ];
        Console.WriteLine(jagged[2][1]);        // 5
        Console.WriteLine(jagged.Length);       // 3 -- rows
        Console.WriteLine(jagged[2].Length);    // 3 -- that row's columns
    }
}

Jagged arrays are usually faster: indexing a rectangular array cannot use the same bounds-check elision the JIT applies to single-dimensional ones. Rectangular arrays are the better fit when the shape really is rectangular and interop or matrix code depends on contiguous layout.

Array Covariance

Arrays are covariant: string[] is assignable to object[]. This predates generics and is unsound — the check moves to run time:

public static class ArrayCovariance
{
    public static void Run()
    {
        string[] names = ["ada", "alan"];
        object[] asObjects = names;                  // legal, but a loaded gun

        try
        {
            asObjects[0] = 42;                       // compiles; throws at run time
        }
        catch (ArrayTypeMismatchException)
        {
            Console.WriteLine("array covariance caught at run time");
        }
    }
}

Prefer IReadOnlyList<T>, which is covariant safely because it has no setter. See Generics for the variance rules.

Collection Expressions (C# 12)

[…] builds any collection type the target expects — array, List<T>, Span<T>, ImmutableArray<T>, or any type with a [CollectionBuilder] attribute. The spread element .. inlines another sequence:

public static class CollectionExpressions
{
    public static void Run()
    {
        int[] array = [1, 2, 3];
        List<int> list = [1, 2, 3];
        Span<int> span = [1, 2, 3];
        ImmutableArray<int> immutable = [1, 2, 3];
        HashSet<int> set = [1, 2, 2, 3];              // duplicates collapse

        int[] head = [1, 2];
        int[] tail = [5, 6];
        int[] joined = [..head, 3, 4, ..tail];        // spread: 1,2,3,4,5,6
        Console.WriteLine(string.Join(",", joined));

        int[] empty = [];
        Console.WriteLine($"{list.Count} {span.Length} {immutable.Length} {set.Count} {empty.Length}");

        // The target type decides -- the same syntax, three different allocations.
        Print([1, 2, 3]);
    }

    private static void Print(ReadOnlySpan<int> values) => Console.WriteLine(values.Length);
}

The compiler is free to choose the cheapest construction for the target type — for a ReadOnlySpan<int> of constants it can emit a reference into the assembly’s data section with no allocation at all.

Collection-Expression Arguments (C# 15 preview)

Preview feature — C# 15 / .NET 11

This requires a .NET 11 preview SDK and <LangVersion>preview</LangVersion>. It is not available in C# 14 / .NET 10, the baseline for this section, and the syntax may still change. The snippet follows the published feature specification and is not compile-verified here.

A collection expression cannot currently pass arguments to the collection it builds — there is no way to say "a Dictionary with this comparer" or "a List with this capacity". C# 15 adds a leading with(…) element carrying those arguments:

// The `with(...)` element supplies constructor arguments to the built collection.
HashSet<string> caseInsensitive = [with(StringComparer.OrdinalIgnoreCase), "Ada", "ADA"];
Console.WriteLine(caseInsensitive.Count);   // 1

List<int> presized = [with(capacity: 1024), 1, 2, 3];

Until it ships, spell the construction out: new HashSet<string>(StringComparer.OrdinalIgnoreCase) { "Ada" }.

The Everyday Collections

Type Ordering Lookup Use it when

List<T>

Insertion order

By index, O(1)

The default sequence — growable, contiguous, cache-friendly

Dictionary<TKey,TValue>

Unordered

By key, O(1) average

Keyed lookup dominates

HashSet<T>

Unordered

Membership, O(1) average

Uniqueness and set algebra

Queue<T>

FIFO

Ends only

Work items processed in arrival order

Stack<T>

LIFO

Top only

Undo, backtracking, depth-first traversal

LinkedList<T>

Insertion order

Sequential, O(n)

Frequent insertion/removal in the middle given a node

SortedDictionary<TKey,TValue>

Key order

By key, O(log n)

Keys must stay sorted; balanced-tree updates

SortedList<TKey,TValue>

Key order

By key, O(log n)

Sorted, mostly read; less memory, slower inserts

SortedSet<T>

Sorted

Membership, O(log n)

Uniqueness and order, range queries

PriorityQueue<TElement,TPriority>

By priority

Minimum only

Scheduling, Dijkstra-style algorithms

public static class EverydayCollections
{
    public static void Run()
    {
        var list = new List<string> { "b", "a" };
        list.Add("c");
        list.Insert(0, "z");
        list.Remove("b");
        list.Sort();
        Console.WriteLine(string.Join(",", list));            // a,c,z

        var ages = new Dictionary<string, int> { ["ada"] = 36, ["alan"] = 41 };
        ages["grace"] = 45;                                   // add or overwrite
        Console.WriteLine(ages.TryGetValue("ada", out int age) ? age : -1);         // 36
        Console.WriteLine(ages.GetValueOrDefault("nobody", -1));                    // -1
        Console.WriteLine(ages.ContainsKey("alan"));                                // True

        var seen = new HashSet<int> { 1, 2, 3 };
        Console.WriteLine(seen.Add(3));                       // False -- already present
        seen.UnionWith([3, 4]);
        seen.IntersectWith([2, 3, 4]);
        Console.WriteLine(string.Join(",", seen.Order()));     // 2,3,4

        var queue = new Queue<string>();
        queue.Enqueue("first");
        queue.Enqueue("second");
        Console.WriteLine(queue.Dequeue());                    // first
        Console.WriteLine(queue.Peek());                       // second

        var stack = new Stack<int>([1, 2, 3]);
        Console.WriteLine(stack.Pop());                        // 3

        var jobs = new PriorityQueue<string, int>();
        jobs.Enqueue("low", 10);
        jobs.Enqueue("urgent", 1);
        jobs.Enqueue("medium", 5);
        Console.WriteLine(jobs.Dequeue());                     // urgent -- lowest priority value wins
    }
}

Comparers in Collections

Hash-based and sorted collections take a comparer, which is how you control keying and ordering without touching the element type:

public sealed record Employee(string Name, int Level);

public static class ComparersInCollections
{
    public static void Run()
    {
        // Case-insensitive keys: the comparer, not the key type, decides equality.
        var byName = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase)
        {
            ["Ada"] = 1,
        };
        Console.WriteLine(byName.ContainsKey("ADA"));     // True

        // A sorted set ordered by a projection.
        var byLevel = new SortedSet<Employee>(
            Comparer<Employee>.Create((a, b) => a.Level.CompareTo(b.Level)))
        {
            new Employee("ada", 3),
            new Employee("alan", 1),
        };
        Console.WriteLine(byLevel.Min?.Name);             // alan

        // EqualityComparer<T>.Default is what a Dictionary uses when you give it none:
        // IEquatable<T> if implemented, otherwise object.Equals.
        Console.WriteLine(EqualityComparer<Employee>.Default.Equals(
            new Employee("ada", 3), new Employee("ada", 3)));   // True -- records have value equality
    }
}

See Equality and Operator Overloading for what a correct Equals/GetHashCode pair owes a hash-based collection.

The IEnumerable<T> Contract

Everything foreach-able implements, or at least duck-types, a pair of interfaces:

public interface IEnumerableSketch<out T>
{
    IEnumeratorSketch<T> GetEnumerator();
}

public interface IEnumeratorSketch<out T> : IDisposable
{
    T Current { get; }
    bool MoveNext();
}
flowchart TD IE["IEnumerable<T>
GetEnumerator()
foreach works"] COL["ICollection<T>
Count, Add, Remove, Contains"] LST["IList<T>
indexer, Insert, RemoveAt"] SET["ISet<T>
UnionWith, IntersectWith…"] DICT["IDictionary<TKey,TValue>
this[key], TryGetValue"] ROC["IReadOnlyCollection<T>
Count"] ROL["IReadOnlyList<T>
indexer (get only)"] ROD["IReadOnlyDictionary<TKey,TValue>"] IE --> COL IE --> ROC COL --> LST COL --> SET COL --> DICT ROC --> ROL ROC --> ROD LST -.->|"implemented by"| IMPL1["List<T>, T[]"] DICT -.->|"implemented by"| IMPL2["Dictionary<TKey,TValue>"] SET -.->|"implemented by"| IMPL3["HashSet<T>, SortedSet<T>"] style IE fill:#eef6f0,stroke:#6fa383 style ROC fill:#f7f9fc,stroke:#8fa4bd style ROL fill:#f7f9fc,stroke:#8fa4bd style ROD fill:#f7f9fc,stroke:#8fa4bd

Accept the weakest interface that does the job: IEnumerable<T> for a parameter you only iterate, IReadOnlyList<T> when you need Count and indexing, a concrete type only when you must mutate it.

How foreach Desugars

foreach is syntax over that contract. The compiler does not require the interface — a type with a suitable public GetEnumerator() is enough, which is how Span<T> works with foreach despite not implementing IEnumerable<T>:

public static class ForeachDesugaring
{
    public static void Explicitly(IEnumerable<int> source)
    {
        // foreach (int n in source) { Console.WriteLine(n); }
        // is compiled to approximately:
        IEnumerator<int> enumerator = source.GetEnumerator();
        try
        {
            while (enumerator.MoveNext())
            {
                int n = enumerator.Current;
                Console.WriteLine(n);
            }
        }
        finally
        {
            enumerator?.Dispose();     // this is why iterators can run cleanup code
        }
    }
}

// A custom type that `foreach` accepts without implementing any interface.
public readonly struct Countdown
{
    private readonly int _from;

    public Countdown(int from) => _from = from;

    public Enumerator GetEnumerator() => new Enumerator(_from);

    public struct Enumerator
    {
        private int _current;

        public Enumerator(int from) => _current = from + 1;

        public int Current => _current;

        public bool MoveNext() => --_current >= 0;
    }
}

The iteration variable is read-only inside the loop, and — since C# 5 — a fresh variable each iteration, which matters when a lambda captures it (see Delegates, Lambdas and Events).

Iterators: yield return

Writing an enumerator by hand is tedious. yield return makes the compiler generate the state machine for you:

public static class Iterators
{
    // Each `yield return` hands one element back and suspends; execution resumes here
    // on the next MoveNext() call.
    public static IEnumerable<int> Fibonacci(int count)
    {
        int a = 0, b = 1;
        for (int i = 0; i < count; i++)
        {
            yield return a;
            (a, b) = (b, a + b);
        }
    }

    public static IEnumerable<string> ReadLinesLazily(IEnumerable<string> source)
    {
        foreach (string line in source)
        {
            if (line.Length == 0)
            {
                yield break;              // stop the sequence early
            }

            yield return line.Trim();
        }
    }

    public static void Run()
    {
        Console.WriteLine(string.Join(",", Fibonacci(8)));       // 0,1,1,2,3,5,8,13
        Console.WriteLine(string.Join(",", ReadLinesLazily([" a ", "b", "", "c"])));   // a,b
    }
}

Laziness, and Its Consequences

An iterator method’s body does not run when you call it — it runs when something enumerates the result. Two consequences bite regularly:

public static class LazinessTraps
{
    public static IEnumerable<int> Parsed(IEnumerable<string> input)
    {
        // This guard does NOT run at call time -- it runs at first MoveNext().
        ArgumentNullException.ThrowIfNull(input);

        foreach (string text in input)
        {
            Console.WriteLine($"parsing {text}");    // proves when work happens
            yield return int.Parse(text);
        }
    }

    // Fix: a non-iterator wrapper validates eagerly and delegates to a private iterator.
    public static IEnumerable<int> ParsedEagerlyValidated(IEnumerable<string> input)
    {
        ArgumentNullException.ThrowIfNull(input);
        return Iterate(input);

        static IEnumerable<int> Iterate(IEnumerable<string> input)
        {
            foreach (string text in input)
            {
                yield return int.Parse(text);
            }
        }
    }

    public static void Run()
    {
        IEnumerable<int> query = Parsed(["1", "2"]);
        Console.WriteLine("nothing parsed yet");
        Console.WriteLine(query.Sum());        // now it parses
        Console.WriteLine(query.Sum());        // and parses AGAIN -- re-enumeration re-runs the work
    }
}
  • Deferred side effects. Argument validation, logging and resource acquisition in an iterator happen at first enumeration, not at call time. Split the method as shown above when that matters.

  • Multiple enumeration. Every foreach over the same IEnumerable<T> re-executes it. Materialise with ToList()/ToArray() when you will walk a sequence more than once, or when the source is a network call.

An iterator’s finally blocks run when the enumerator is disposed — which foreach guarantees, including on an early break — so using inside an iterator is safe.

Read-Only, Immutable and Frozen Collections

Three distinct guarantees, often confused:

public static class ImmutabilityLevels
{
    public static void Run()
    {
        var mutable = new List<int> { 1, 2, 3 };

        // 1. A read-only VIEW: the caller cannot mutate it, but the underlying list can still change.
        IReadOnlyList<int> view = mutable;
        mutable.Add(4);
        Console.WriteLine(view.Count);                    // 4 -- the view saw the change

        // A defensive wrapper makes the view's read-only-ness explicit at run time too.
        var wrapper = mutable.AsReadOnly();
        Console.WriteLine(wrapper.Count);                 // 4

        // 2. IMMUTABLE: mutation returns a new collection; the original never changes.
        ImmutableList<int> immutable = [1, 2, 3];
        ImmutableList<int> extended = immutable.Add(4);
        Console.WriteLine($"{immutable.Count} {extended.Count}");    // 3 4

        ImmutableArray<int> array = [1, 2, 3];
        Console.WriteLine(array.Length);

        // 3. FROZEN: immutable AND optimised for reads, built once, never updated.
        FrozenDictionary<string, int> frozen = new Dictionary<string, int>
        {
            ["ada"] = 1,
            ["alan"] = 2,
        }.ToFrozenDictionary();
        Console.WriteLine(frozen["ada"]);                 // 1 -- faster lookups than Dictionary

        FrozenSet<string> frozenSet = new[] { "a", "b" }.ToFrozenSet();
        Console.WriteLine(frozenSet.Contains("a"));
    }
}

Use a read-only interface for parameters and properties, an immutable collection when sharing across threads or when value semantics matter, and a frozen collection for a lookup table built at startup and read for the process’s lifetime.

Indices and Ranges

^ counts from the end; .. builds a range. Both work on arrays, strings and spans natively, and on any type with the right indexer:

public static class IndicesAndRanges
{
    public static void Run()
    {
        int[] values = [0, 1, 2, 3, 4, 5];

        Console.WriteLine(values[^1]);                      // 5 -- last element
        Console.WriteLine(values[^2]);                      // 4

        Console.WriteLine(string.Join(",", values[1..4]));   // 1,2,3 -- end is exclusive
        Console.WriteLine(string.Join(",", values[..3]));    // 0,1,2
        Console.WriteLine(string.Join(",", values[3..]));    // 3,4,5
        Console.WriteLine(string.Join(",", values[..]));     // a copy of the whole array

        Index secondFromEnd = ^2;
        Range middle = 1..^1;
        Console.WriteLine(values[secondFromEnd]);            // 4
        Console.WriteLine(string.Join(",", values[middle])); // 1,2,3,4

        // On a List<T>, use CollectionsMarshal or a span; List<T> has no Range indexer.
        List<int> list = [0, 1, 2, 3];
        Console.WriteLine(list[^1]);                         // 3 -- Index works, Range does not
    }
}

Ranging an array allocates a new array; ranging a span does not — which is the next section’s point.

Span<T> as a Collection View

Span<T> is a window onto memory you already have — an array, a stackalloc buffer, unmanaged memory — sliced without copying:

public static class SpansAsViews
{
    public static int SumOfMiddle(int[] values)
    {
        ReadOnlySpan<int> span = values;           // no copy
        ReadOnlySpan<int> middle = span[1..^1];    // still no copy -- just offset + length

        int total = 0;
        foreach (int n in middle)
        {
            total += n;
        }
        return total;
    }

    public static void Run()
    {
        Console.WriteLine(SumOfMiddle([1, 2, 3, 4]));    // 5

        // Parsing without allocating substrings.
        ReadOnlySpan<char> line = "name=value".AsSpan();
        int separator = line.IndexOf('=');
        Console.WriteLine($"{line[..separator]} -> {line[(separator + 1)..]}");
    }
}

A Span<T> is a ref struct: it cannot be boxed, stored in a field of a class, captured by a lambda, or used across an await. See Unsafe Code, Spans and Performance.

Thread-Safe Collections

List<T> and Dictionary<TKey,TValue> are safe for concurrent readers only. For concurrent writers use the System.Collections.Concurrent types — ConcurrentDictionary<TKey,TValue>, ConcurrentQueue<T>, ConcurrentBag<T>, BlockingCollection<T> — or a Channel<T> for producer/consumer pipelines. See Threads and Synchronization.

See Also