DEV_NET_CORE
GET_STARTED
.NETC# Language Foundations

Collection Choices in C#

Overview

Collection choices in C# are about selecting the right data structure for storing, reading, searching, updating, ordering, and sharing data in a .NET application. A collection can be as simple as an array, as common as a List<T> or Dictionary<TKey, TValue>, or as specialized as a ConcurrentDictionary<TKey, TValue>, ImmutableList<T>, PriorityQueue<TElement, TPriority>, or FrozenDictionary<TKey, TValue>.

Choosing the correct collection matters because the collection type directly affects performance, memory usage, thread safety, API design, readability, and correctness. A collection that works well for one scenario can be inefficient or unsafe in another. For example, a List<T> is good for ordered iteration and index access, but it is not ideal for frequent key lookups. A Dictionary<TKey, TValue> is good for fast lookup by key, but it should not be chosen when sorted order is required. A ConcurrentQueue<T> is useful for multi-threaded producer/consumer scenarios, but it adds unnecessary overhead if only one thread uses the collection.

Collection choice is important in real-world development because collections are used almost everywhere: API responses, request validation, caching, lookup tables, domain models, Entity Framework query results, background processing, queues, configuration maps, authorization rules, search filters, and UI view models.

This topic is also common in interviews because it tests whether a developer understands practical trade-offs instead of only syntax. Interviewers often ask why you would choose List<T> instead of an array, Dictionary<TKey, TValue> instead of List<T>, HashSet<T> instead of List<T>, ConcurrentDictionary<TKey, TValue> instead of locking a normal dictionary, or immutable/read-only collections instead of mutable collections.

Core Concepts

What Is a Collection?

A collection is an object that stores multiple values and provides operations for accessing, adding, removing, searching, or enumerating those values.

Common collection categories in C# include:

CategoryExamplesMain Use Case
Fixed-size sequenceT[]Known-size data, fast index access
Dynamic sequenceList<T>Ordered list that grows and shrinks
Key/value lookupDictionary<TKey, TValue>Fast lookup by key
Unique valuesHashSet<T>Prevent duplicates and test membership quickly
Sorted valuesSortedSet<T>, SortedDictionary<TKey, TValue>, SortedList<TKey, TValue>Keep data sorted by value or key
Queue/stackQueue<T>, Stack<T>FIFO or LIFO processing
Priority queuePriorityQueue<TElement, TPriority>Process items by priority
Linked structureLinkedList<T>Efficient node insertion/removal when node is known
Thread-safe collectionConcurrentDictionary<TKey, TValue>, ConcurrentQueue<T>Multi-threaded add/remove/update operations
Immutable collectionImmutableList<T>, ImmutableDictionary<TKey, TValue>Share data safely without mutation
Frozen collectionFrozenDictionary<TKey, TValue>, FrozenSet<T>Read-only lookup optimized after construction
Read-only wrapper/interfaceIReadOnlyList<T>, ReadOnlyCollection<T>Expose data without allowing callers to modify it directly

The most important interview skill is not memorizing every collection type. The important skill is knowing what question to ask before choosing one.

The Main Questions to Ask Before Choosing a Collection

When choosing a collection, ask:

  1. Do I need fixed size or dynamic size?
  2. Do I need fast lookup by index, key, or value?
  3. Are duplicate values allowed?
  4. Does the order matter?
  5. Does the collection need to be sorted?
  6. Will multiple threads modify it?
  7. Should callers be allowed to modify it?
  8. Is this collection read-heavy, write-heavy, or balanced?
  9. Is memory usage important?
  10. Is this part of a public API or only an internal implementation detail?

Example:

Code
// Need ordered data and index access?
List<string> names = ["Alice", "Bob", "Charlie"];
Console.WriteLine(names[0]);

// Need fast lookup by key?
Dictionary<int, string> usersById = new()
{
    [1] = "Alice",
    [2] = "Bob"
};
Console.WriteLine(usersById[2]);

// Need uniqueness?
HashSet<string> roles = ["Admin", "User", "Admin"];
Console.WriteLine(roles.Count); // 2

Big O Complexity Basics

A major reason collection choice matters is operation complexity.

CollectionAccess by IndexSearch by ValueAddRemoveLookup by Key/Value
T[]O(1)O(n)Fixed sizeFixed sizeO(n)
List<T>O(1)O(n)Usually O(1), sometimes O(n) when resizingO(n)O(n)
Dictionary<TKey, TValue>Not index-basedNot value-focusedUsually O(1)Usually O(1)Usually O(1) by key
HashSet<T>Not index-basedUsually O(1) membershipUsually O(1)Usually O(1)Usually O(1) by value
SortedDictionary<TKey, TValue>Not index-basedNot value-focusedO(log n)O(log n)O(log n) by key
SortedList<TKey, TValue>O(1) by indexNot value-focusedO(n)O(n)O(log n) by key
LinkedList<T>O(n)O(n)O(1) when node is knownO(1) when node is knownO(n)
Queue<T>Not index-basedO(n)O(1) enqueueO(1) dequeueO(n)
Stack<T>Not index-basedO(n)O(1) pushO(1) popO(n)
PriorityQueue<TElement, TPriority>Not index-basedO(n)O(log n) enqueueO(log n) dequeuePriority-based

O(1) does not always mean "always faster". Constant factors, memory allocation, CPU cache locality, resizing, hash quality, and data size also matter. However, Big O is a strong interview signal because it shows you understand how collections scale.

Arrays: T[]

An array is a fixed-size, zero-based sequence of elements of the same type.

Use an array when:

  • The size is known and does not change.
  • You need very fast index access.
  • You want low overhead.
  • You are working with APIs that require arrays.
  • You are working with performance-sensitive code.

Example:

Code
int[] numbers = [10, 20, 30];

for (int i = 0; i < numbers.Length; i++)
{
    Console.WriteLine(numbers[i]);
}

Trade-offs:

  • Fast index access.
  • Low memory overhead compared with many dynamic collections.
  • Size cannot be changed after creation.
  • Inserting or removing in the middle requires creating or shifting data manually.

Common mistake:

Code
// Not possible: arrays do not have Add.
int[] numbers = [1, 2, 3];
// numbers.Add(4); // Compile error

Use List<T> when the collection needs to grow or shrink frequently.

Lists: List<T>

List<T> is one of the most commonly used C# collections. It is a dynamic array that grows as needed.

Use List<T> when:

  • You need ordered items.
  • You need index access.
  • You need to add items dynamically.
  • You usually add to the end.
  • You need to return a simple sequence from a method.

Example:

Code
List<string> products = new(capacity: 100);

products.Add("Laptop");
products.Add("Mouse");
products.Add("Keyboard");

Console.WriteLine(products[1]); // Mouse

List<T> is usually efficient for appending items. However, inserting or removing from the middle can be expensive because later elements must be shifted.

Example of inefficient use:

Code
List<int> numbers = Enumerable.Range(1, 100_000).ToList();

// Expensive because each removal shifts many elements.
while (numbers.Count > 0)
{
    numbers.RemoveAt(0);
}

Better choice for FIFO processing:

Code
Queue<int> queue = new(Enumerable.Range(1, 100_000));

while (queue.Count > 0)
{
    int item = queue.Dequeue();
}

Best practices:

  • Use List<T> as the default collection for ordered, mutable sequences.
  • Set initial capacity when the approximate size is known.
  • Avoid using List<T>.Contains repeatedly for large membership checks; use HashSet<T> instead.
  • Avoid removing from the beginning repeatedly; use Queue<T> if FIFO behavior is needed.

Dictionaries: Dictionary<TKey, TValue>

Dictionary<TKey, TValue> stores key/value pairs and provides fast lookup by key.

Use Dictionary<TKey, TValue> when:

  • Each item has a unique key.
  • You need fast lookup by key.
  • You need to map one value to another.
  • You are building caches, indexes, or lookup tables.

Example:

Code
Dictionary<int, string> usersById = new()
{
    [101] = "Alice",
    [102] = "Bob"
};

if (usersById.TryGetValue(102, out string? userName))
{
    Console.WriteLine(userName);
}

Prefer TryGetValue when the key may not exist:

Code
Dictionary<string, decimal> prices = new()
{
    ["Laptop"] = 1200m,
    ["Mouse"] = 25m
};

if (!prices.TryGetValue("Keyboard", out decimal price))
{
    Console.WriteLine("Product not found.");
}

Avoid this when the key may be missing:

Code
// Throws KeyNotFoundException if the key does not exist.
decimal keyboardPrice = prices["Keyboard"];

Use a custom comparer for case-insensitive string keys:

Code
Dictionary<string, int> roleCounts = new(StringComparer.OrdinalIgnoreCase)
{
    ["Admin"] = 1
};

roleCounts["admin"]++;
Console.WriteLine(roleCounts["ADMIN"]); // 2

Trade-offs:

  • Very fast lookup by key on average.
  • Keys must be unique.
  • Requires a good equality implementation for custom key types.
  • Does not represent sorted order.
  • Uses more memory than a simple list.

Common mistake with custom keys:

Code
public sealed class ProductKey
{
    public string Sku { get; init; } = "";
}

var dictionary = new Dictionary<ProductKey, string>();
dictionary[new ProductKey { Sku = "ABC" }] = "Product A";

// This is a different object reference, so lookup fails unless equality is implemented.
bool found = dictionary.ContainsKey(new ProductKey { Sku = "ABC" });

Better with a record:

Code
public sealed record ProductKey(string Sku);

var dictionary = new Dictionary<ProductKey, string>();
dictionary[new ProductKey("ABC")] = "Product A";

bool found = dictionary.ContainsKey(new ProductKey("ABC")); // true

Sets: HashSet<T>

HashSet<T> stores unique values and provides fast membership checks.

Use HashSet<T> when:

  • You need uniqueness.
  • You frequently check whether an item exists.
  • You need set operations like union, intersection, and except.
  • Order does not matter.

Example:

Code
HashSet<string> allowedRoles = new(StringComparer.OrdinalIgnoreCase)
{
    "Admin",
    "Manager",
    "Auditor"
};

bool canAccess = allowedRoles.Contains("admin"); // true

Set operations:

Code
HashSet<string> currentPermissions = ["Read", "Write", "Delete"];
HashSet<string> requiredPermissions = ["Read", "Write"];

bool hasAllRequired = requiredPermissions.IsSubsetOf(currentPermissions);
Console.WriteLine(hasAllRequired); // true

Practical example: removing duplicates.

Code
string[] emails =
[
    "[email protected]",
    "[email protected]",
    "[email protected]"
];

HashSet<string> uniqueEmails = new(emails, StringComparer.OrdinalIgnoreCase);

Trade-offs:

  • Fast membership checks.
  • Automatically prevents duplicates.
  • Not index-based.
  • Does not represent sorted order.
  • Requires correct equality and hash code behavior.

List<T> vs HashSet<T>

A common interview question is when to choose List<T> or HashSet<T>.

Use List<T> when order and index access matter.

Use HashSet<T> when uniqueness and fast membership checks matter.

Example:

Code
List<int> selectedIds = [1, 2, 3, 4, 5];

// Fine for small lists, but O(n).
bool isSelected = selectedIds.Contains(5);

Better for frequent membership checks:

Code
HashSet<int> selectedIds = [1, 2, 3, 4, 5];

// Usually O(1).
bool isSelected = selectedIds.Contains(5);

Real-world use:

Code
HashSet<int> blockedUserIds = new(blockedUsers.Select(u => u.Id));

List<User> visibleUsers = allUsers
    .Where(user => !blockedUserIds.Contains(user.Id))
    .ToList();

This avoids repeatedly scanning a list of blocked users.

Queues: Queue<T>

Queue<T> represents first-in, first-out processing.

Use Queue<T> when:

  • The first item added should be the first item processed.
  • You are implementing buffering.
  • You are processing tasks in arrival order.
  • You need BFS-style traversal.

Example:

Code
Queue<string> jobs = new();

jobs.Enqueue("Send welcome email");
jobs.Enqueue("Generate report");

while (jobs.TryDequeue(out string? job))
{
    Console.WriteLine($"Processing: {job}");
}

Real-world examples:

  • Background job processing.
  • Breadth-first search.
  • Message buffering.
  • Request ordering.

Use ConcurrentQueue<T> instead when multiple threads need to enqueue and dequeue concurrently.

Stacks: Stack<T>

Stack<T> represents last-in, first-out processing.

Use Stack<T> when:

  • The most recently added item should be processed first.
  • You need undo/redo behavior.
  • You are parsing nested structures.
  • You need DFS-style traversal.

Example:

Code
Stack<string> navigationHistory = new();

navigationHistory.Push("Home");
navigationHistory.Push("Products");
navigationHistory.Push("Details");

string previousPage = navigationHistory.Pop();
Console.WriteLine(previousPage); // Details

Common use cases:

  • Undo operations.
  • Browser history.
  • Depth-first search.
  • Expression parsing.

Use ConcurrentStack<T> if multiple threads need to push and pop concurrently.

Priority Queues: PriorityQueue<TElement, TPriority>

PriorityQueue<TElement, TPriority> stores elements with priorities. When you dequeue, the element with the lowest priority value is returned first by default.

Use PriorityQueue<TElement, TPriority> when:

  • Items should be processed by priority rather than insertion order.
  • You need scheduling behavior.
  • You are implementing algorithms like Dijkstra's shortest path.
  • You need a min-heap-like data structure.

Example:

Code
PriorityQueue<string, int> tasks = new();

tasks.Enqueue("Low priority report", 5);
tasks.Enqueue("Critical alert", 1);
tasks.Enqueue("Normal email", 3);

while (tasks.TryDequeue(out string? task, out int priority))
{
    Console.WriteLine($"Priority {priority}: {task}");
}

Output order:

Code
Priority 1: Critical alert
Priority 3: Normal email
Priority 5: Low priority report

Trade-offs:

  • Efficient for priority-based enqueue/dequeue.
  • Not designed for searching arbitrary items.
  • Not the same as a sorted list for full ordered enumeration.

Linked Lists: LinkedList<T>

LinkedList<T> is a doubly linked list where each element is stored in a node with links to the previous and next nodes.

Use LinkedList<T> when:

  • You frequently insert or remove nodes from the middle.
  • You already have a reference to the node.
  • You need stable node references.

Example:

Code
LinkedList<string> steps = new();

LinkedListNode<string> first = steps.AddLast("Validate request");
LinkedListNode<string> third = steps.AddLast("Save data");

steps.AddBefore(third, "Map DTO to entity");

foreach (string step in steps)
{
    Console.WriteLine(step);
}

Trade-offs:

  • Efficient insertion/removal when the node is known.
  • Poor index access because finding the nth item requires traversal.
  • Higher memory overhead because each node stores links.
  • Often less cache-friendly than List<T>.

Common interview point: LinkedList<T> is not automatically better for insertions. If you do not already have the node, you still need O(n) time to find where to insert.

Sorted Collections

Sorted collections keep data sorted according to a comparer.

Common sorted collections:

CollectionDescriptionGood For
SortedDictionary<TKey, TValue>Key/value collection sorted by key, tree-basedFrequent inserts/removes with sorted key lookup
SortedList<TKey, TValue>Key/value collection sorted by key, array-basedSmaller or mostly-read collections where memory matters
SortedSet<T>Unique values sorted by valueUnique sorted values and range-style operations

Example:

Code
SortedDictionary<DateOnly, string> events = new()
{
    [new DateOnly(2026, 5, 12)] = "Deploy API",
    [new DateOnly(2026, 5, 10)] = "Code review",
    [new DateOnly(2026, 5, 11)] = "Run tests"
};

foreach (var item in events)
{
    Console.WriteLine($"{item.Key}: {item.Value}");
}

The output is sorted by date, not insertion order.

SortedDictionary<TKey, TValue> vs SortedList<TKey, TValue>:

FeatureSortedDictionary<TKey, TValue>SortedList<TKey, TValue>
Internal structureTree-basedArray-based
LookupO(log n)O(log n)
Insert/removeO(log n)O(n) because shifting may be needed
Memory usageUsually higherUsually lower
Index accessNo direct index accessSupports index-based access to keys/values
Good forFrequent changesMostly-read data or smaller data sets

Ordered Dictionaries

An ordered dictionary is useful when you need both key lookup and a stable item order.

Use an ordered dictionary when:

  • You need key/value lookup.
  • You also need to preserve or manipulate order.
  • You cannot model the data cleanly as only a list or only a dictionary.

Example:

Code
OrderedDictionary<string, int> scores = new()
{
    ["Alice"] = 95,
    ["Bob"] = 88,
    ["Charlie"] = 91
};

scores.Insert(1, "Diana", 90);

foreach (var score in scores)
{
    Console.WriteLine($"{score.Key}: {score.Value}");
}

Do not use a normal Dictionary<TKey, TValue> when the business logic depends on order. Even if a specific runtime appears to enumerate in insertion order, the safer interview answer is to choose a collection whose contract matches the ordering requirement.

Read-Only Collections and Interfaces

Read-only collections are useful for API design and encapsulation. They prevent callers from modifying a collection through the exposed reference.

Common read-only abstractions:

TypeMeaning
IEnumerable<T>Can be enumerated only
IReadOnlyCollection<T>Can be enumerated and exposes Count
IReadOnlyList<T>Read-only sequence with index access
IReadOnlyDictionary<TKey, TValue>Read-only key/value lookup
ReadOnlyCollection<T>Read-only wrapper around a list
ReadOnlyDictionary<TKey, TValue>Read-only wrapper around a dictionary

Example:

Code
public sealed class Order
{
    private readonly List<OrderLine> _lines = [];

    public IReadOnlyList<OrderLine> Lines => _lines;

    public void AddLine(string productName, int quantity)
    {
        if (quantity <= 0)
        {
            throw new ArgumentOutOfRangeException(nameof(quantity));
        }

        _lines.Add(new OrderLine(productName, quantity));
    }
}

public sealed record OrderLine(string ProductName, int Quantity);

This design allows consumers to read order lines without directly modifying the internal list.

Important distinction:

  • Read-only does not always mean immutable.
  • A read-only wrapper may still reflect changes if the underlying collection changes.
  • Immutable collections cannot be changed after creation; modification creates a new collection instance.

Immutable Collections

Immutable collections cannot be changed after they are created. Operations like Add, Remove, or SetItem return a new collection instead of modifying the original.

Common immutable collections:

  • ImmutableArray<T>
  • ImmutableList<T>
  • ImmutableDictionary<TKey, TValue>
  • ImmutableHashSet<T>
  • ImmutableQueue<T>
  • ImmutableStack<T>
  • ImmutableSortedDictionary<TKey, TValue>
  • ImmutableSortedSet<T>

Use immutable collections when:

  • You want safe sharing across threads.
  • You want predictable state.
  • You use functional programming patterns.
  • You want to avoid accidental mutation.
  • You need snapshots of data.

Example:

Code
using System.Collections.Immutable;

ImmutableList<string> original = ImmutableList.Create("Read", "Write");
ImmutableList<string> updated = original.Add("Delete");

Console.WriteLine(original.Count); // 2
Console.WriteLine(updated.Count);  // 3

The original collection is unchanged.

Trade-offs:

  • Safer sharing.
  • Easier reasoning about state.
  • Useful for concurrent read scenarios.
  • Can allocate more than mutable collections if used incorrectly.
  • For many changes, use a builder and convert to immutable at the end.

Example using a builder:

Code
using System.Collections.Immutable;

ImmutableList<string>.Builder builder = ImmutableList.CreateBuilder<string>();

builder.Add("Read");
builder.Add("Write");
builder.Add("Delete");

ImmutableList<string> permissions = builder.ToImmutable();

Frozen Collections

Frozen collections are immutable, read-only collections optimized for fast lookup and enumeration after construction.

Common frozen collections:

  • FrozenDictionary<TKey, TValue>
  • FrozenSet<T>

Use frozen collections when:

  • The collection is built once and read many times.
  • Startup or initialization cost is acceptable.
  • Lookup performance matters.
  • The data does not change after initialization.

Example:

Code
using System.Collections.Frozen;

FrozenDictionary<string, int> statusCodes = new Dictionary<string, int>
{
    ["OK"] = 200,
    ["BadRequest"] = 400,
    ["Unauthorized"] = 401,
    ["NotFound"] = 404
}.ToFrozenDictionary(StringComparer.OrdinalIgnoreCase);

int notFound = statusCodes["notfound"];

Frozen collections are not a replacement for every dictionary or set. They are useful for read-heavy lookup tables, not for frequently changing data.

Concurrent Collections

Standard generic collections such as List<T> and Dictionary<TKey, TValue> are not safe for concurrent writes. If multiple threads add, remove, or update items at the same time, use synchronization or choose a concurrent collection.

Common concurrent collections:

CollectionUse Case
ConcurrentDictionary<TKey, TValue>Thread-safe key/value updates
ConcurrentQueue<T>Thread-safe FIFO queue
ConcurrentStack<T>Thread-safe LIFO stack
ConcurrentBag<T>Thread-safe unordered collection optimized for some producer/consumer scenarios
BlockingCollection<T>Blocking and bounding over producer/consumer collections

Example:

Code
using System.Collections.Concurrent;

ConcurrentDictionary<string, int> counts = new();

Parallel.ForEach(
    ["apple", "banana", "apple", "orange", "banana", "apple"],
    word =>
    {
        counts.AddOrUpdate(
            word,
            addValue: 1,
            updateValueFactory: (_, current) => current + 1);
    });

foreach (var item in counts)
{
    Console.WriteLine($"{item.Key}: {item.Value}");
}

When not to use concurrent collections:

  • If only one thread writes to the collection.
  • If the collection is built once and then only read.
  • If a simple lock around a small critical section is clearer and sufficient.
  • If you need multi-step operations to be atomic and the collection does not provide that operation directly.

Common mistake:

Code
// This is not atomic as a full sequence of operations.
if (!dictionary.ContainsKey(key))
{
    dictionary[key] = value;
}

Better:

Code
ConcurrentDictionary<string, int> dictionary = new();

dictionary.TryAdd("A", 1);

Or:

Code
dictionary.AddOrUpdate(
    "A",
    addValue: 1,
    updateValueFactory: (_, current) => current + 1);

IEnumerable<T>, ICollection<T>, IList<T>, and Interface Choice

Collection interfaces are important for API design.

InterfaceUse When
IEnumerable<T>The caller only needs to iterate
ICollection<T>The caller needs count and mutation operations
IList<T>The caller needs index access and mutation operations
IReadOnlyCollection<T>The caller needs count but should not mutate
IReadOnlyList<T>The caller needs index access but should not mutate
IDictionary<TKey, TValue>The caller needs mutable key/value operations
IReadOnlyDictionary<TKey, TValue>The caller needs key/value lookup but should not mutate

Good API example:

Code
public decimal CalculateTotal(IEnumerable<OrderLine> lines)
{
    return lines.Sum(line => line.UnitPrice * line.Quantity);
}

This method only needs enumeration, so IEnumerable<T> is enough.

Better API when count is needed:

Code
public bool HasEnoughItems(IReadOnlyCollection<OrderLine> lines)
{
    return lines.Count >= 5;
}

Better API when index access is needed:

Code
public OrderLine GetFirstLine(IReadOnlyList<OrderLine> lines)
{
    if (lines.Count == 0)
    {
        throw new InvalidOperationException("Order has no lines.");
    }

    return lines[0];
}

Best practice:

  • Accept the least powerful interface needed.
  • Return read-only interfaces from public domain objects when callers should not mutate internal state.
  • Use concrete types internally when specific behavior or performance is needed.

Deferred Execution and Materialization

IEnumerable<T> often represents deferred execution. This means the query may not run until it is enumerated.

Example:

Code
IEnumerable<int> query = Enumerable.Range(1, 5)
    .Where(x =>
    {
        Console.WriteLine($"Filtering {x}");
        return x % 2 == 0;
    });

Console.WriteLine("Before enumeration");

foreach (int number in query)
{
    Console.WriteLine(number);
}

The filtering logic runs during enumeration, not when the query is created.

Materialization means converting the query into a concrete collection:

Code
List<int> evenNumbers = Enumerable.Range(1, 5)
    .Where(x => x % 2 == 0)
    .ToList();

Common mistake:

Code
IEnumerable<User> users = dbContext.Users.Where(u => u.IsActive);

// Multiple enumeration can execute the query multiple times depending on the source.
int count = users.Count();
List<User> result = users.ToList();

Better:

Code
List<User> users = dbContext.Users
    .Where(u => u.IsActive)
    .ToList();

int count = users.Count;

Interview point: IEnumerable<T> is not always an in-memory list. It can represent a database query, file stream, generated sequence, or lazy pipeline.

Array, Span<T>, and Memory<T>

Span<T> and Memory<T> are not normal collections, but they are important in modern C# performance work.

Span<T> represents a contiguous region of memory and can point to arrays, stack memory, or unmanaged memory. It avoids copying data in many scenarios.

Example:

Code
int[] numbers = [1, 2, 3, 4, 5];
Span<int> middle = numbers.AsSpan(1, 3);

middle[0] = 20;

Console.WriteLine(numbers[1]); // 20

Use Span<T> when:

  • You need high-performance slicing.
  • You want to avoid allocations.
  • You are parsing strings or processing buffers.
  • The data does not need to escape the current stack scope.

Use Memory<T> when:

  • You need a memory abstraction that can be stored on the heap.
  • You need async-compatible memory usage.
  • You cannot use Span<T> because it is stack-only.

Example:

Code
ReadOnlySpan<char> text = "ABC-123";
ReadOnlySpan<char> prefix = text[..3];
ReadOnlySpan<char> suffix = text[4..];

Console.WriteLine(prefix.ToString()); // ABC
Console.WriteLine(suffix.ToString()); // 123

Interview point: Span<T> is for performance-sensitive memory access, not a replacement for List<T> or Dictionary<TKey, TValue> in normal business code.

Collection Expressions

Modern C# supports collection expressions, which provide a concise way to create arrays, spans, lists, and other collection-like types.

Example:

Code
int[] numbers = [1, 2, 3];
List<string> names = ["Alice", "Bob", "Charlie"];
HashSet<int> uniqueNumbers = [1, 2, 2, 3];

Collection expressions improve readability, but they do not remove the need to choose the correct target collection type.

Example:

Code
// This creates a List<string>, so duplicates are allowed.
List<string> list = ["Admin", "Admin"];

// This creates a HashSet<string>, so duplicates collapse.
HashSet<string> set = ["Admin", "Admin"];

Choosing Collections for Common Scenarios

ScenarioGood ChoiceWhy
Store ordered API response itemsList<T>Simple, ordered, serializable
Store known-size numeric dataT[]Low overhead and fast index access
Lookup user by IDDictionary<int, User>Fast key lookup
Check whether a role existsHashSet<string>Fast membership and uniqueness
Process jobs in arrival orderQueue<T>FIFO behavior
Process undo actionsStack<T>LIFO behavior
Process tasks by priorityPriorityQueue<TElement, TPriority>Priority-based dequeue
Keep data sorted by keySortedDictionary<TKey, TValue>Maintains sorted key order
Expose domain child entities safelyIReadOnlyList<T>Prevents direct mutation by callers
Multi-threaded cache updatesConcurrentDictionary<TKey, TValue>Thread-safe key/value operations
Shared state snapshotImmutableList<T> or ImmutableDictionary<TKey, TValue>Prevents accidental mutation
Build once, read often lookup tableFrozenDictionary<TKey, TValue>Optimized for read-heavy lookup

Practical Example: Choosing the Right Collection in an API

Bad approach:

Code
public sealed class PermissionService
{
    private readonly List<string> _adminPermissions =
    [
        "User.Read",
        "User.Write",
        "Report.Read"
    ];

    public bool HasPermission(string permission)
    {
        return _adminPermissions.Contains(permission);
    }
}

This works for small data, but if permission checks happen frequently, a set is a better fit.

Better approach:

Code
public sealed class PermissionService
{
    private readonly HashSet<string> _adminPermissions = new(
        [
            "User.Read",
            "User.Write",
            "Report.Read"
        ],
        StringComparer.OrdinalIgnoreCase);

    public bool HasPermission(string permission)
    {
        return _adminPermissions.Contains(permission);
    }
}

Even better if the permissions are initialized once and never change:

Code
using System.Collections.Frozen;

public sealed class PermissionService
{
    private static readonly FrozenSet<string> AdminPermissions = new[]
    {
        "User.Read",
        "User.Write",
        "Report.Read"
    }.ToFrozenSet(StringComparer.OrdinalIgnoreCase);

    public bool HasPermission(string permission)
    {
        return AdminPermissions.Contains(permission);
    }
}

Bad approach:

Code
List<int> allowedDepartmentIds = allowedDepartments
    .Select(d => d.Id)
    .ToList();

List<Employee> result = employees
    .Where(e => allowedDepartmentIds.Contains(e.DepartmentId))
    .ToList();

If both lists are large, this can become expensive because List<T>.Contains is O(n).

Better approach:

Code
HashSet<int> allowedDepartmentIds = allowedDepartments
    .Select(d => d.Id)
    .ToHashSet();

List<Employee> result = employees
    .Where(e => allowedDepartmentIds.Contains(e.DepartmentId))
    .ToList();

Practical Example: Safe Domain Model Encapsulation

Bad approach:

Code
public sealed class Order
{
    public List<OrderLine> Lines { get; } = [];
}

Any caller can do this:

Code
order.Lines.Clear();

Better approach:

Code
public sealed class Order
{
    private readonly List<OrderLine> _lines = [];

    public IReadOnlyList<OrderLine> Lines => _lines;

    public void AddLine(string productName, int quantity)
    {
        if (string.IsNullOrWhiteSpace(productName))
        {
            throw new ArgumentException("Product name is required.", nameof(productName));
        }

        if (quantity <= 0)
        {
            throw new ArgumentOutOfRangeException(nameof(quantity));
        }

        _lines.Add(new OrderLine(productName, quantity));
    }
}

public sealed record OrderLine(string ProductName, int Quantity);

This keeps business rules inside the aggregate.

Common Mistakes

Common collection mistakes include:

  • Using List<T> for frequent membership checks instead of HashSet<T>.
  • Using List<T> as a queue and repeatedly removing from index 0.
  • Using Dictionary<TKey, TValue> when sorted order is required.
  • Exposing mutable List<T> properties from domain entities.
  • Assuming IEnumerable<T> is already materialized.
  • Enumerating an IEnumerable<T> multiple times when it represents an expensive query.
  • Modifying a collection while iterating over it.
  • Using non-generic collections like ArrayList and Hashtable in modern C# code.
  • Using concurrent collections when there is no concurrent write scenario.
  • Assuming read-only wrappers are the same as immutable collections.
  • Forgetting to pass StringComparer.OrdinalIgnoreCase for case-insensitive string keys.
  • Using mutable objects as dictionary keys and then changing the fields used for equality.

Example of modifying during enumeration:

Code
List<int> numbers = [1, 2, 3, 4, 5];

// Throws InvalidOperationException.
foreach (int number in numbers)
{
    if (number % 2 == 0)
    {
        numbers.Remove(number);
    }
}

Better:

Code
numbers.RemoveAll(number => number % 2 == 0);

Or:

Code
List<int> filtered = numbers
    .Where(number => number % 2 != 0)
    .ToList();

Best Practices

Use these rules of thumb:

  • Use List<T> for the default ordered, mutable sequence.
  • Use T[] for fixed-size data or low-level performance scenarios.
  • Use Dictionary<TKey, TValue> for fast lookup by key.
  • Use HashSet<T> for uniqueness and fast membership checks.
  • Use Queue<T> for FIFO processing.
  • Use Stack<T> for LIFO processing.
  • Use PriorityQueue<TElement, TPriority> for priority-based processing.
  • Use SortedDictionary<TKey, TValue> or SortedSet<T> when sorted order is part of the requirement.
  • Use IReadOnlyList<T> or IReadOnlyCollection<T> when exposing data that should not be modified by callers.
  • Use immutable collections when shared state should not change.
  • Use frozen collections for build-once, read-many lookup tables.
  • Use concurrent collections for multi-threaded writes.
  • Use StringComparer.OrdinalIgnoreCase for case-insensitive technical keys such as codes, names, identifiers, and headers.
  • Accept the least powerful interface needed by a method.
  • Choose collections based on access pattern, not habit.

Interview Practice

PreviousClasses, structs, recordsNext UpCommon BCL Types in C#