# C# Collections and Generics Interview Questions for Senior .NET Developers (2026)

Senior .NET developers are expected to go beyond knowing which collection type exists — interviewers want to understand whether you can reason about trade-offs under pressure: thread safety, allocation patterns, time complexity at scale, and how the C# type system shapes the decisions you make in production code. Collections and generics questions show up in virtually every senior interview, and the answers that separate a mid-level candidate from a senior one are never about memorizing APIs — they're about knowing *why*.

If you want to go deeper on these patterns with annotated, production-ready code that shows how collections and generics are used inside a real ASP.NET Core API, [Patreon](https://www.patreon.com/CodingDroplets) has the full implementation — designed around the kind of code enterprise teams actually ship.

* * *

## Basic: Core Collection Types

### What is the difference between `Array`, `List<T>`, and `LinkedList<T>` in C#?

An `Array` has a fixed size allocated at creation time; accessing any element is O(1) by index, and memory is contiguous, making it highly cache-friendly. `List<T>` wraps a dynamically resizing array internally — indexed access is O(1), but insertions or deletions at arbitrary positions are O(n) due to the shift. `LinkedList<T>` uses node-based heap allocation, making insertion and removal at any position O(1) once you have a reference to the node — but indexed access is O(n), and memory fragmentation is a real concern at scale.

In ASP.NET Core, `List<T>` dominates general-purpose usage. `LinkedList<T>` is rarely needed outside of specific queue or LRU cache implementations where you need O(1) arbitrary removal.

### What is `IEnumerable<T>` and why does it matter in API design?

`IEnumerable<T>` is the base interface for all enumerable sequences. It exposes a single `GetEnumerator()` method, enabling `foreach` loops and LINQ operations. Its importance in API design is about the guarantee it provides: callers can iterate the sequence, but they cannot assume it is in memory, random-accessible, or thread-safe.

When a method returns `IEnumerable<T>`, it signals deferred execution potential — the caller should not assume a materialised list. Returning `IEnumerable<T>` from repository methods can leave LINQ queries unevaluated, which is a common source of bugs and performance issues in EF Core. Returning `IReadOnlyList<T>` or `IReadOnlyCollection<T>` from application layer boundaries makes these guarantees explicit.

### What is `ICollection<T>` vs `IList<T>` vs `IReadOnlyList<T>`?

| Interface | Guarantees | Allows Mutation? |
| --- | --- | --- |
| `ICollection<T>` | Count + Add/Remove + Contains | Yes |
| `IList<T>` | Indexed access + Insert/RemoveAt | Yes |
| `IReadOnlyList<T>` | Indexed access + Count | No (read-only) |
| `IReadOnlyCollection<T>` | Count only | No (read-only) |

For outbound DTOs and query results, returning `IReadOnlyList<T>` communicates intent and prevents callers from accidentally casting to `List<T>` and modifying the underlying collection. Senior candidates should be able to articulate this boundary without prompting.

### What is the difference between `Dictionary<TKey, TValue>` and `HashSet<T>`?

`Dictionary<TKey, TValue>` maps keys to values; `HashSet<T>` stores only unique values with no associated data. Both use hash-based storage — average O(1) for Add, Contains, and Remove. Use `HashSet<T>` when you only care about membership or uniqueness, without needing an associated value. In high-frequency queries (e.g., checking whether a feature flag key exists), `HashSet<T>` is preferred over `List<T>` for O(1) Contains versus O(n).

* * *

## Intermediate: Generics and Type System

### What are C# generics and why do they matter for performance?

Generics allow you to write type-safe, reusable code without boxing. Before generics (`ArrayList` era), value types stored in collections were boxed into heap objects, adding GC pressure and allocation overhead. With `List<T>`, an `int` stays on the stack — no boxing occurs.

For senior developers, the implication is measurable: in hot paths processing large collections of structs (e.g., sensor readings, financial tick data, event payloads), using generic collections instead of object-based ones can cut allocations dramatically. This ties directly to `Span<T>` and `Memory<T>` patterns for zero-allocation processing.

### What are generic constraints in C# and when do you use them?

Generic constraints restrict what types a type parameter accepts. Common constraints include:

*   `where T : class` — reference types only
    
*   `where T : struct` — value types only, also prevents nullable
    
*   `where T : new()` — must have a parameterless constructor
    
*   `where T : IComparable<T>` — must implement an interface
    
*   `where T : BaseClass` — must inherit from a specific class
    
*   `where T : notnull` (C# 8+) — non-nullable reference or value type
    

In domain-layer generic repositories (`IRepository<T> where T : Entity`), constraints enforce that only domain entities participate in persistence operations — a design contract enforced at compile time rather than through runtime checks.

### What is the difference between covariance and contravariance in C# generics?

Covariance (`out T`) allows a type to be substituted with a more derived type. `IEnumerable<string>` is assignable to `IEnumerable<object>` because `IEnumerable<T>` declares `out T`.

Contravariance (`in T`) allows substitution of a less derived type. `IComparer<object>` can be used where `IComparer<string>` is expected.

This matters in generic pipeline designs — a senior engineer writing a generic handler or processor needs to understand when a covariant or contravariant interface enables polymorphism without losing type safety, and when it breaks (generic interfaces are invariant by default, which surprises developers who expect `List<string>` to be substitutable for `List<object>`).

### What is the difference between `IEqualityComparer<T>` and `IComparable<T>`?

`IEqualityComparer<T>` defines equality and hash codes — used by `Dictionary<TKey, TValue>` and `HashSet<T>` to resolve bucket placement and key matching. `IComparable<T>` defines ordering — used by `SortedDictionary<TKey, TValue>`, `SortedSet<T>`, and LINQ `OrderBy`.

A common interview scenario: you have a custom entity class and need to use it as a dictionary key. You either override `GetHashCode()` and `Equals()` on the class itself, or provide a custom `IEqualityComparer<T>`. Senior candidates know that failing to override `GetHashCode()` consistently with `Equals()` leads to silent data corruption in dictionaries — elements are inserted but never found, or duplicates silently accumulate.

* * *

## Intermediate-Advanced: Thread Safety and Concurrent Collections

### What thread-safe collection types does .NET provide and when do you use each?

For deeper coverage of how thread safety intersects with async patterns in high-concurrency APIs, the [C# Multithreading and Concurrency Interview Questions](https://codingdroplets.com/csharp-multithreading-concurrency-interview-questions-senior-dotnet-2026) article covers synchronisation primitives, `Task` scheduling, and concurrent access patterns in detail.

| Type | Thread-Safety Mechanism | When to Use |
| --- | --- | --- |
| `ConcurrentDictionary<TKey, TValue>` | Fine-grained locking per bucket | Shared read-write cache |
| `ConcurrentQueue<T>` | Lock-free, two-pointer algorithm | Producer-consumer pipelines |
| `ConcurrentBag<T>` | Per-thread storage + stealing | Work queues where order doesn't matter |
| `ConcurrentStack<T>` | Lock-free linked list | LIFO work distribution |
| `BlockingCollection<T>` | Wraps concurrent collections + blocking | Bounded producer-consumer with backpressure |

`ConcurrentDictionary` is the most commonly used in ASP.NET Core services — for example, in-memory caches, rate limiter state, or client connection registries. Candidates should know that `AddOrUpdate` and `GetOrAdd` are individually atomic but not transactional together — a read-modify-write on two keys is not atomic.

### What is the difference between `ConcurrentDictionary<K, V>` and using `Dictionary<K, V>` with `lock`?

`ConcurrentDictionary` uses striped locking — the dictionary is internally divided into segments, and a lock only blocks operations on that segment. This allows multiple threads to write to different segments simultaneously. A global `lock` on a `Dictionary` serialises all reads and writes.

For read-heavy workloads, `ConcurrentDictionary` provides significantly better throughput. For write-heavy workloads with complex multi-step operations (where you need to atomically update multiple keys), a single lock gives you a cleaner atomicity boundary. `ConcurrentDictionary` does not give you cross-key atomicity.

In ASP.NET Core, `ConcurrentDictionary` is the correct default for caches and registries that are accessed concurrently. Using a plain `Dictionary` with `lock` is acceptable if the dictionary is small, contains complex multi-key operations, or if you need the simplicity for testability.

### What are `ImmutableList<T>` and `FrozenDictionary<TKey, TValue>`, and when are they relevant?

`ImmutableList<T>` (from `System.Collections.Immutable`) creates a new collection on every modification. It trades mutability for thread safety — because immutable collections never change, they are inherently safe to share across threads with no locking. The trade-off is allocation cost on modification, making them unsuitable for high-mutation paths but excellent for publishing snapshots of read-mostly shared state.

`FrozenDictionary<TKey, TValue>` (.NET 8+) is optimised for read-heavy, write-never scenarios (e.g., configuration lookup tables, route maps, feature flag registries built at startup). It uses a static, highly cache-friendly internal structure that outperforms `Dictionary<K, V>` on reads. The cost is that construction is expensive and the dictionary cannot be modified after creation.

Senior engineers should reach for `FrozenDictionary` when a lookup table is built once at startup and read millions of times — this is exactly the pattern used in `IOptions<T>` configuration caching and compiled route tables.

* * *

## Advanced: Performance, Memory, and Design

### What is the impact of collection choice on GC pressure in ASP.NET Core?

Every collection allocated on the heap per request contributes to GC pressure. `List<T>` starts with a default capacity of 4 and doubles on resize — if you know the count up front, initialising with `new List<T>(expectedCount)` avoids repeated reallocation and array copying.

For collections of value types (e.g., `List<int>`, `List<Guid>`), the array is a contiguous block of value memory — efficient for CPU cache. For `List<MyClass>`, the array stores references — each element access follows a pointer to a heap object.

In hot paths (e.g., per-request result building, batch processing), replacing `List<T>` with `Span<T>` or renting arrays from `ArrayPool<T>.Shared` eliminates per-request allocation entirely. Profiling tools (`dotnet-trace`, `BenchmarkDotNet`, PerfView) will surface collection allocations as a top contributor to Gen0 GC pressure in API services under load. For a comprehensive look at the full range of performance concerns senior candidates face, see the [ASP.NET Core Performance Optimization Interview Questions](https://codingdroplets.com/aspnet-core-performance-interview-questions-senior-dotnet-2026) guide.

### How does `Dictionary<TKey, TValue>` handle hash collisions, and why does it matter?

`Dictionary<TKey, TValue>` uses open addressing with chaining internally. When two keys hash to the same bucket, they share a linked list of entries. In the average case with good hash distribution, lookup is O(1). In the worst case with all keys colliding to the same bucket, lookup degrades to O(n).

This matters in production when using custom or poorly implemented `GetHashCode()` overrides. A `GetHashCode()` that always returns the same value (a degenerate but valid implementation) turns every dictionary into a linked list under the hood. Senior engineers should know to test collection performance under their actual key distribution, especially for string-keyed dictionaries with domain-specific key patterns.

### What is the difference between `SortedDictionary<TKey, TValue>` and `SortedList<TKey, TValue>`?

Both maintain sorted key order and provide O(log n) lookup — but their internal structures differ:

*   `SortedDictionary` uses a red-black tree. Insertions and deletions are O(log n); memory is proportional to the number of elements.
    
*   `SortedList` uses two parallel sorted arrays. Lookup is O(log n) via binary search; insertions are O(n) due to array shifting. However, it uses less memory than `SortedDictionary` and is faster to enumerate.
    

`SortedList` is the better choice when your data is loaded once (or infrequently mutated) and iterated frequently — config tables, lookup tables with ordered keys. `SortedDictionary` is better for data that is frequently inserted or removed while still needing sorted enumeration.

### What is `Span<T>` and how does it relate to collections?

`Span<T>` is a stack-only reference type that provides a type-safe view over a contiguous region of memory — an array, a stack-allocated buffer, or unmanaged memory — without copying. It is not a collection in the traditional sense; it cannot be stored on the heap or captured in async methods.

In performance-sensitive ASP.NET Core paths (JSON parsing, binary protocol parsing, string slicing), `Span<T>` and `ReadOnlySpan<T>` replace `string` and `byte[]` allocations. For example, parsing a comma-separated header value by slicing a `ReadOnlySpan<char>` avoids allocating intermediate `string` objects for each segment.

`Memory<T>` is the heap-compatible counterpart, used in async contexts where `Span<T>` cannot cross an `await` boundary.

* * *

## Scenario-Based Questions

### You have a service that maintains a shared registry of active WebSocket connections. What collection would you use and why?

The registry needs concurrent read and write access (connections join and leave while other threads enumerate the active set). `ConcurrentDictionary<string, WebSocketConnection>` is the natural fit — connection ID as key, connection object as value. Add is `TryAdd`, remove is `TryRemove`.

If enumeration over all connections needs to be consistent (no torn reads), you would take a snapshot via `dictionary.Values.ToList()` before iterating — `ConcurrentDictionary` does not guarantee a stable snapshot during enumeration. If the registry is read far more often than written (e.g., broadcast scenarios), a `ReaderWriterLockSlim` with a plain `Dictionary` may offer better throughput at the cost of more complex locking code.

### How would you implement a thread-safe in-memory LRU cache using .NET collections?

An LRU cache needs O(1) lookup (eviction check) and O(1) reordering on access. The canonical approach combines a `Dictionary<TKey, LinkedListNode<(TKey, TValue)>>` for O(1) lookup with a `LinkedList<(TKey, TValue)>` for ordering — the most recently accessed item moves to the front, and the tail is evicted when capacity is reached.

Thread safety requires a lock around both the dictionary and the list operations together, since they must stay in sync. `ConcurrentDictionary` alone is insufficient here because it cannot atomically coordinate with the linked list. In .NET 9+, `HybridCache` provides a production-ready L1/L2 layered cache with stampede protection, which should be preferred over a hand-rolled LRU for most production use cases.

### A colleague stores a mutable `List<T>` in a static field used across requests. What are the risks and how would you fix it?

Storing a mutable collection in a static field that is shared across requests creates race conditions under concurrent access — simultaneous Add, Remove, or Count+index operations without synchronisation will cause data corruption or `IndexOutOfRangeException` in production.

The fix depends on the read/write pattern. For a configuration-style registry (written once, read constantly): replace with `FrozenDictionary` or `ImmutableList` built at startup. For a live registry updated at runtime: use `ConcurrentDictionary` or protect access with `ReaderWriterLockSlim`. For simple counters or flags: use `Interlocked` operations on primitive types rather than collections.

* * *

## FAQ

**What is the time complexity of** `List<T>.Contains()`**?** O(n) — it performs a linear scan. If you need frequent membership checks, convert to a `HashSet<T>` for O(1) average lookup. This is a common performance mistake in loops: checking `if (list.Contains(item))` inside a loop over thousands of items turns O(n) into O(n²).

**Can** `Dictionary<TKey, TValue>` **be safely iterated while being modified?** No — modifying a dictionary during enumeration throws `InvalidOperationException: Collection was modified; enumeration operation may not execute`. To iterate and modify, take a snapshot of the keys first (`var keys = dict.Keys.ToList()`), then iterate the snapshot while modifying the dictionary.

**What is the difference between** `Array.Sort()` **and** `List<T>.Sort()`**?** Both use an introspective sort (introsort — hybrid of quicksort, heapsort, and insertion sort) with O(n log n) average complexity. `Array.Sort()` sorts in place on the underlying array. `List<T>.Sort()` delegates to the internal array sort. For custom types, both accept an `IComparer<T>` or `Comparison<T>` delegate. `Span<T>.Sort()` is available in .NET 5+ for zero-allocation sorting of spans.

**What is** `CollectionsMarshal.GetValueRefOrAddDefault()` **and when would a senior engineer use it?** It is a performance API in `System.Runtime.InteropServices` that returns a reference to the value in a `Dictionary<TKey, TValue>` — or creates and returns a reference if the key is absent. It avoids a double lookup when you need to check existence and then modify the value. This is relevant in hot paths (e.g., frequency counters, aggregation loops) where the overhead of two dictionary lookups (`TryGetValue` + array index) is measurable at scale.

**Why should** `List<T>` **be initialised with an expected capacity?**`List<T>` starts with a capacity of 4 and doubles on overflow (4 → 8 → 16 → 32...). Each doubling allocates a new array and copies all elements. If you know the expected count at call time, `new List<T>(expectedCount)` avoids all intermediate allocations and copies. This is relevant in data pipeline stages where result sets have predictable sizes — pagination results, batch reads, LINQ materialisation.

**What is the difference between** `IEnumerable<T>` **and** `IAsyncEnumerable<T>`**?**`IEnumerable<T>` is synchronous — it blocks the thread while producing each element. `IAsyncEnumerable<T>` is asynchronous — it supports `await foreach`, allowing each element to be awaited individually. This is the correct return type for streaming database results, paginated API responses, or any producer that involves I/O. Returning `IReadOnlyList<T>` materialises all results into memory before the caller sees any; returning `IAsyncEnumerable<T>` streams them one at a time, reducing peak memory usage under load.

**When should you choose** `ConcurrentBag<T>` **over** `ConcurrentQueue<T>`**?**`ConcurrentQueue<T>` is FIFO and lock-free — use it when processing order matters (task pipelines, request queues). `ConcurrentBag<T>` has no ordering guarantee and uses per-thread storage with work-stealing — it is optimised for producer-consumer patterns where the same thread that adds items also consumes them, minimising cross-thread contention. `ConcurrentBag<T>` is rarely the right choice for most ASP.NET Core scenarios; `ConcurrentQueue<T>` or `System.Threading.Channels.Channel<T>` are preferred for general-purpose async pipelines.
