Create your own
Lesson illustration

Choosing the Right Generic Collection for Your Needs

Hello. In the previous lesson, you practiced evaluating a C# object model in terms of encapsulation, composition, interfaces, and dependency boundaries. That same design mindset applies to collections: choose a collection for the operations and guarantees your code needs, not because “a list of things” sounds natural.

This lesson is a focused interview refresh on selecting generic collections for lookup, uniqueness, ordering, queueing, and concurrent access. The important skill is being able to defend the choice, identify a hidden requirement such as equality or concurrency, and state a sensible trade-off.

Priority: Must know. Plan for about 40 minutes, including the resource review and answering the interview prompts aloud before viewing the model answers.


Start with behavior, not class names

A collection choice begins with a few operational questions:

  1. Do I need to retrieve an item by position, by key, or by value membership?
  2. Must items be unique?
  3. Does “order” mean insertion order, sorted order, FIFO processing order, or LIFO processing order?
  4. Is the collection genuinely shared by multiple threads?
  5. Is the data only in memory, or does it need durability across process restarts and multiple application instances?

The first four determine the in-process collection. The fifth prevents a common senior-level mistake: treating an in-memory concurrent collection as a reliable distributed job queue or a permanent idempotency store.

Collections and Data Structures - .NET | Microsoft Learn

Read Microsoft Learn’s “Collections and Data Structures” to anchor the selection process in required operations rather than memorized class names.

In the “Choose a collection” section, read the selection table. Map each row to the question it answers: key lookup, index access, FIFO or LIFO processing, sorting, uniqueness, and safe concurrent access. Notice that the table distinguishes a general-purpose collection from its concurrent and immutable counterparts.

A good default is to use the generic collections in System.Collections.Generic. They give compile-time type safety and avoid boxing that older non-generic collections such as Hashtable can impose for value types.


The core selection map

The table below covers the choices most likely to arise in a senior .NET interview.

RequirementPrimary choiceMain guaranteeImportant caveat
Ordered sequence, duplicates allowed, access by numeric indexList<T>Index access is ; preserves sequence orderContains is normally ; it does not enforce uniqueness
Fast retrieval of one value by a unique keyDictionary<TKey, TValue>Expected lookup by keyKeys must be unique; values need not be
Fast membership check and no duplicate valuesHashSet<T>Unique values; expected add and lookupNo meaningful sorted or positional order guarantee
First received item must be processed firstQueue<T>FIFO semanticsIn-memory only; not durable work delivery
Most recent item must be processed firstStack<T>LIFO semanticsUseful for undo/history and traversal, not ordinary job processing
Key lookup plus continuous key-sorted enumerationSortedDictionary<TKey, TValue>Unique keys in sorted-key orderAdd and lookup are typically , not expected
Unique values kept in sorted orderSortedSet<T>Uniqueness and sorted traversalEquality is determined by comparison; comparison design matters
Shared dictionary across threadsConcurrentDictionary<TKey, TValue>Thread-safe dictionary operationsDoes not make a multi-step business workflow atomic
Shared FIFO producer-consumer bufferConcurrentQueue<T>Thread-safe FIFO enqueue/dequeueStill not a durable queue or cross-process coordinator

Two points often distinguish a practical answer from a memorized one:

  • A Dictionary<TKey, TValue> enforces unique keys, not unique values. dictionary[key] = value replaces the existing value; Add rejects a duplicate key.
  • A HashSet<T> is for a set of values. Its Add method reports whether a new value was actually added, which makes deduplication clear and efficient.

For example, if an import file must reject repeated external transaction IDs:

var seenTransactionIds = new HashSet<string>(
    StringComparer.OrdinalIgnoreCase);

foreach (var row in rows)
{
    if (!seenTransactionIds.Add(row.ExternalTransactionId))
    {
        throw new InvalidOperationException(
            $"Duplicate transaction ID: {row.ExternalTransactionId}");
    }
}

The case-insensitive comparer is not merely a performance choice. It declares a business rule: IDs differing only by case are considered the same. Use it only when that is truly the domain rule.

The C# Collection Selection Cheatsheet organizes common decisions by whether data is stored as values or key/value pairs, whether duplicates or sorting matter, and whether access must be concurrent. It is useful as a quick decision aid, but the surrounding business and deployment constraints still determine the final choice.

Lookup and uniqueness depend on equality

A Dictionary<TKey, TValue> and HashSet<T> are hash-based collections. Their expected performance comes from using a hash code to locate a small candidate area, then applying equality to determine whether two keys or values are actually equivalent.

This connects directly to the equality material you have already refreshed:

  • If two objects are equal, they must return the same hash code.
  • For a custom class used as a key or set element, implement Equals and GetHashCode consistently, typically through IEquatable<T>.
  • Do not mutate fields that participate in equality or hash-code calculation while the object is inside a dictionary or set.

The last point is especially dangerous because the object may become effectively “lost” in its old hash bucket.

public sealed class PartnerKey : IEquatable<PartnerKey>
{
    public PartnerKey(string partnerCode, string region)
    {
        PartnerCode = partnerCode;
        Region = region;
    }

    public string PartnerCode { get; }
    public string Region { get; }

    public bool Equals(PartnerKey? other)
    {
        return other is not null
            && PartnerCode == other.PartnerCode
            && Region == other.Region;
    }

    public override bool Equals(object? obj)
    {
        return obj is PartnerKey other && Equals(other);
    }

    public override int GetHashCode()
    {
        return HashCode.Combine(PartnerCode, Region);
    }
}

An immutable key type is safer because the fields used for lookup cannot change after insertion. For simple domain values, a record is often a convenient choice; classes and records will be compared more fully in a later lesson.

For safe lookups where absence is a valid outcome, prefer TryGetValue:

if (partnerRules.TryGetValue(partnerId, out var rules))
{
    return rules.MaximumClaimAmount;
}

return defaultMaximumClaimAmount;

This avoids a separate ContainsKey lookup followed by an indexer access, and makes the missing-key branch explicit.

The Secret to Mastering Queue, Stack and Dictionary in C#!

Watch “The Secret to Mastering Queue, Stack and Dictionary in C#!” by Bald. Bearded. Builder. It gives a compact practical review of FIFO queue behavior and type-safe dictionary lookup.

Watch queue basics for Enqueue, Dequeue, Peek, and the FIFO processing model. Focus on why a queue is appropriate when fairness means earlier arrivals are handled first. Then watch dictionary lookup for the contrast between legacy Hashtable and generic Dictionary<TKey, TValue>, particularly TryGetValue and the importance of reliable equality and hash-code behavior for custom keys.

Interview prompt: choose collections for one workflow

Pause here and answer aloud before reading further.

An API receives a nightly reimbursement-import file. You must look up program rules by ProgramId for every row, reject duplicate external transaction IDs within the file, return validation errors in the same order as the input rows, and hand accepted rows to one in-process worker in arrival order. Which collection or collections would you choose, and why?

Model answer

This workflow has several independent requirements, so I would not force them into one collection.

  • I would use Dictionary<Guid, ProgramRules> for program-rule lookup by ProgramId, because the key is unique and repeated lookups are expected.
  • I would use HashSet<string> for external transaction IDs because the requirement is membership and uniqueness. I would explicitly choose the appropriate string comparer based on the external ID’s case-sensitivity rules.
  • I would use List<ValidationResult> to preserve the input sequence and support index-based association with the original rows. Duplicates are valid here because multiple input rows can have errors.
  • I would use Queue<AcceptedRow> if one thread produces and one in-process worker consumes in FIFO order. If several threads can produce or consume from the same buffer, I would use ConcurrentQueue<AcceptedRow>.

A senior-level caveat is that this queue only exists in one application process. If accepted rows must survive a restart, be retried reliably, or be consumed across scaled-out API instances, I would use durable messaging or persisted work records rather than treating ConcurrentQueue<T> as a production job system.


“Ordering” means several different things

Interview questions often use “ordered” imprecisely. Clarify the meaning before naming a collection.

A sequence in a known order

Use List<T> when the application naturally has a sequence, needs to preserve encounter order, allows duplicates, or needs index access.

A list is often the right choice for an API response, a batch of validation results, or line items on a request. Its indexed access is , but searching for a matching item with Contains, Find, or LINQ usually requires a scan of up to elements.

If you know the approximate item count, providing initial capacity can avoid repeated internal array resizing:

var results = new List<ValidationResult>(capacity: expectedRowCount);

Adding to a list is usually amortized , but resizing requires allocating a larger array and copying elements, making that individual addition more expensive.

Values continuously sorted by a business key

Use SortedDictionary<TKey, TValue> when you need both key-based access and enumeration in key-sorted order.

For example, if a report-building process continuously receives partner summaries and must enumerate them in ascending partner code without a later sort, a sorted dictionary is a reasonable fit. Its operations are generally , reflecting the cost of keeping the tree ordered.

Use SortedSet<T> when you need unique values that are also sorted. Be precise with custom comparisons: in a sorted set, two values that compare as equal are treated as duplicates, even if other fields differ.

A useful practical alternative is often simpler:

If data is collected first and sorted once for display or export, keep it in a List<T> and call Sort or use an ordered LINQ projection at the boundary. Do not pay the maintenance cost of a sorted structure unless continuous sorted access is genuinely required.

Processing order

Use a queue when items must be handled in arrival order:

var pendingApprovals = new Queue<Guid>();

pendingApprovals.Enqueue(requestId);

if (pendingApprovals.TryDequeue(out var nextRequestId))
{
    Process(nextRequestId);
}

TryDequeue is preferable when an empty queue is normal; it avoids using exceptions as control flow.

Use a stack when the newest item must come out first. Typical examples include undo history, expression parsing, and depth-first traversal. A stack is not usually appropriate for a “first request received, first request processed” business workflow.

Do not choose HashSet<T> or Dictionary<TKey, TValue> when correctness depends on a particular enumeration order. Even if a particular runtime implementation appears to enumerate in a convenient order, your code should use a collection whose documented semantics express the ordering requirement.


Thread safety: choose it only when ownership requires it

A normal List<T>, Dictionary<TKey, TValue>, Queue<T>, or HashSet<T> is not safe for simultaneous mutation by multiple threads.

However, an ASP.NET Core application handling many requests does not automatically mean every collection needs to be concurrent. A local collection created inside one request handler is owned by that request’s execution flow. It is not shared merely because other requests are running elsewhere.

A concurrent collection becomes relevant when the same instance is shared, for example:

  • a process-wide in-memory cache shared by requests;
  • several worker tasks consuming a common in-memory buffer;
  • a singleton service maintaining shared state;
  • parallel work that adds results to one common collection.

For these cases, select the concurrent collection that preserves the required semantics:

Shared requirementAppropriate collection
Concurrent keyed cache or lookup mapConcurrentDictionary<TKey, TValue>
Multiple producers and consumers, FIFO behaviorConcurrentQueue<T>
Multiple producers and consumers, LIFO behaviorConcurrentStack<T>
Fast concurrent additions where order does not matterConcurrentBag<T>

ConcurrentDictionary<TKey, TValue> supports useful atomic collection-level operations such as TryAdd, TryRemove, GetOrAdd, and AddOrUpdate. That does not automatically make a sequence of business operations atomic.

Consider this unsafe conceptual workflow:

  1. Check whether a reimbursement has already been processed.
  2. Charge or approve it.
  3. Mark it as processed.

Even if the “processed IDs” collection is a ConcurrentDictionary, a failure between steps can leave the real business state inconsistent. In a multi-instance deployment, each application process also has its own in-memory dictionary. For durable idempotency, the database or a durable message system must participate in the correctness design.

A concise interview explanation is:

I would use ConcurrentDictionary when several threads in one process need safe shared keyed access. For a compound decision involving external state, I would define the atomic boundary in persistent storage or another durable coordinator; the concurrent collection alone does not provide transaction semantics.

There is a related distinction worth knowing:

  • A read-only wrapper prevents consumers from mutating through that reference, but the underlying collection may still change elsewhere.
  • An immutable collection does not change after creation; a modification produces a new collection instance.

Immutability can simplify safe sharing and reasoning, but it has allocation and performance trade-offs. It is not mandatory for every read-only API.


A concise senior-level answer pattern

When asked, “Why did you choose this collection?”, give an answer in this order:

  1. State the required operation.
    “I need repeated lookup by a unique program ID,” or “I need FIFO processing.”

  2. Name the collection and its useful guarantee.
    “I would use Dictionary<Guid, ProgramRules> for expected constant-time key lookup,” or “I would use Queue<T> because it expresses FIFO directly.”

  3. Name the semantic constraint.
    “The dictionary key must be stable and have correct equality,” or “the set comparer must match the domain’s definition of duplicate.”

  4. Address scale or concurrency only if it is real.
    “If the instance is shared across worker tasks, I would move to ConcurrentQueue<T>.”

  5. State one boundary or trade-off.
    “For durable processing across restarts, an in-memory concurrent queue is insufficient.”

Final interview prompt

Answer aloud in roughly 90 seconds before reading the model response.

A teammate uses List<string> to track processed message IDs and calls Contains before processing each message. The service now processes a high-volume stream using several worker tasks. How would you critique the design and improve it? What further questions would you ask?

Model response

List<string> is a poor fit if the requirement is fast membership checking and uniqueness. Contains performs a linear scan, so the cost grows as the list grows. For a single-threaded in-memory process, I would use HashSet<string> with an explicit comparer that matches the message-ID contract.

Because several worker tasks now share the same tracking state, I would not use HashSet<T> without coordination. If the requirement is only safe in-process insertion of unique IDs, ConcurrentDictionary<string, byte> with TryAdd can represent an atomic “first time seen” decision.

Before implementing that, I would ask how long IDs must be retained, whether duplicate messages can arrive after a process restart, whether the service runs on multiple instances, and whether “processed” means received, successfully completed, or durably committed. If duplicate prevention must survive failures or work across instances, I would use persistent idempotency records with an appropriate unique constraint or another durable design rather than relying on application memory.


Key takeaways

  • Choose collections from required operations: index, key lookup, membership, sorted traversal, FIFO, LIFO, or shared concurrent access.
  • Use List<T> for sequences and index access; Dictionary<TKey, TValue> for key lookup; HashSet<T> for uniqueness and membership checks.
  • Use Queue<T> for FIFO and Stack<T> for LIFO. Use sorted collections only when you need to maintain sorted access continuously.
  • Hash-based collections depend on stable, correct equality and hash-code behavior. A mutable key is a correctness risk.
  • Use concurrent collections only when the same collection instance is actually shared among threads.
  • ConcurrentDictionary and ConcurrentQueue are thread-safe in-process structures, not substitutes for database transactions, durable queues, or distributed coordination.

Next, you will move from collections to delegates, lambdas, and events—how C# represents callbacks and decoupled behavior without tightly binding one component to another.

Can't find a good explanation? Sign up and we'll make it for you

Sign up