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:
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:
- Do I need fixed size or dynamic size?
- Do I need fast lookup by index, key, or value?
- Are duplicate values allowed?
- Does the order matter?
- Does the collection need to be sorted?
- Will multiple threads modify it?
- Should callers be allowed to modify it?
- Is this collection read-heavy, write-heavy, or balanced?
- Is memory usage important?
- Is this part of a public API or only an internal implementation detail?
Example:
// 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.
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:
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:
// 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:
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:
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:
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>.Containsrepeatedly for large membership checks; useHashSet<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:
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:
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:
// Throws KeyNotFoundException if the key does not exist.
decimal keyboardPrice = prices["Keyboard"];
Use a custom comparer for case-insensitive string keys:
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:
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:
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:
HashSet<string> allowedRoles = new(StringComparer.OrdinalIgnoreCase)
{
"Admin",
"Manager",
"Auditor"
};
bool canAccess = allowedRoles.Contains("admin"); // true
Set operations:
HashSet<string> currentPermissions = ["Read", "Write", "Delete"];
HashSet<string> requiredPermissions = ["Read", "Write"];
bool hasAllRequired = requiredPermissions.IsSubsetOf(currentPermissions);
Console.WriteLine(hasAllRequired); // true
Practical example: removing duplicates.
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:
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:
HashSet<int> selectedIds = [1, 2, 3, 4, 5];
// Usually O(1).
bool isSelected = selectedIds.Contains(5);
Real-world use:
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:
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:
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:
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:
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:
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:
Example:
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>:
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:
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:
Example:
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:
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:
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:
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:
Example:
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:
// This is not atomic as a full sequence of operations.
if (!dictionary.ContainsKey(key))
{
dictionary[key] = value;
}
Better:
ConcurrentDictionary<string, int> dictionary = new();
dictionary.TryAdd("A", 1);
Or:
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.
Good API example:
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:
public bool HasEnoughItems(IReadOnlyCollection<OrderLine> lines)
{
return lines.Count >= 5;
}
Better API when index access is needed:
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:
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:
List<int> evenNumbers = Enumerable.Range(1, 5)
.Where(x => x % 2 == 0)
.ToList();
Common mistake:
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:
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:
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:
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:
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:
// 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
Practical Example: Choosing the Right Collection in an API
Bad approach:
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:
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:
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);
}
}
Practical Example: Avoiding Repeated Linear Search
Bad approach:
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:
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:
public sealed class Order
{
public List<OrderLine> Lines { get; } = [];
}
Any caller can do this:
order.Lines.Clear();
Better approach:
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 ofHashSet<T>. - Using
List<T>as a queue and repeatedly removing from index0. - 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
ArrayListandHashtablein 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.OrdinalIgnoreCasefor case-insensitive string keys. - Using mutable objects as dictionary keys and then changing the fields used for equality.
Example of modifying during enumeration:
List<int> numbers = [1, 2, 3, 4, 5];
// Throws InvalidOperationException.
foreach (int number in numbers)
{
if (number % 2 == 0)
{
numbers.Remove(number);
}
}
Better:
numbers.RemoveAll(number => number % 2 == 0);
Or:
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>orSortedSet<T>when sorted order is part of the requirement. - Use
IReadOnlyList<T>orIReadOnlyCollection<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.OrdinalIgnoreCasefor 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.