📁 Sangam Dhakal - Portfolio v2.0 - Netscape Navigator 4.0
_
□
×

📚 Interview question

How do you use Dictionary<TKey,TValue> correctly in C#? Explain TryGetValue versus ContainsKey plus the indexer, Add versus the indexer setter, TryAdd and GetValueOrDefault, custom comparers, CollectionsMarshal.GetValueRefOrAddDefault, and whether enumeration order is guaranteed.

Practical `Dictionary<TKey,TValue>` API usage: single-lookup patterns, insert semantics, comparers, ref access and ordering.

Mid-Level Coding Intermediate collections collectionsmarshal dictionary hashing performance

🎯 Key points

  • `TryGetValue` reads with one lookup; `ContainsKey` plus indexer does two
  • `Add` throws on duplicates, the indexer setter overwrites, `TryAdd` returns `false`
  • The indexer getter throws `KeyNotFoundException`; `GetValueOrDefault` returns a default
  • Pass `StringComparer.Ordinal` or `OrdinalIgnoreCase` explicitly for string keys
  • `CollectionsMarshal.GetValueRefOrAddDefault` updates a value in place with one lookup
  • Enumeration order is not guaranteed; use `OrderedDictionary<TKey,TValue>` (.NET 9+) or `SortedDictionary`
  • Keys must be immutable and follow the Equals/GetHashCode contract
  • `Dictionary` is not safe for concurrent writes; use `ConcurrentDictionary`, a lock or `FrozenDictionary`

⏱ 30-second answer

Use TryGetValue for reads: one hash lookup, no exception. ContainsKey then dict[key] hashes twice. Add throws ArgumentException on a duplicate key; the indexer setter silently overwrites; TryAdd returns false instead of throwing. The indexer getter throws KeyNotFoundException; GetValueOrDefault returns a default. Pass a comparer such as StringComparer.OrdinalIgnoreCase for case-insensitive keys. For counters and aggregations, CollectionsMarshal.GetValueRefOrAddDefault gives a ref to the value slot so you update it with one lookup. Enumeration order is not guaranteed; use OrderedDictionary<TKey,TValue> (.NET 9+) or SortedDictionary when order matters.

🗣 Full answer (senior)

How I would answer this in an interview

Dictionary<TKey,TValue> is a hash table with average O(1) lookup, insert and remove. Using it correctly is mostly about doing one hash lookup instead of two, choosing the method whose behaviour on duplicates or missing keys you actually want, giving it the right comparer, and never relying on enumeration order.

1. Reading: TryGetValue vs ContainsKey + indexer

// Two lookups: hash + bucket search twice
if (prices.ContainsKey(sku)) total += prices[sku];

// One lookup
if (prices.TryGetValue(sku, out var price)) total += price;

// One lookup with a fallback (.NET Core 2.0+ extension on IReadOnlyDictionary)
var discount = discounts.GetValueOrDefault(tier, 0m);
  • The indexer getter throws KeyNotFoundException for a missing key — use it only when absence is a bug.
  • TryGetValue is the default for "may be missing". It also avoids a race in concurrent code where the key is removed between ContainsKey and the indexer.
  • GetValueOrDefault is concise when default (or a supplied fallback) is acceptable.

2. Writing: Add vs indexer setter vs TryAdd

Method Key exists Key missing Use when
dict.Add(k, v) Throws ArgumentException Adds Duplicates indicate a bug (building from unique data)
dict[k] = v Overwrites silently Adds Upsert / last write wins
dict.TryAdd(k, v) (.NET Core 2.0) Returns false, no change Adds, returns true First write wins, no exception
dict.Remove(k, out var v) Removes and returns value Returns false Take-and-remove in one lookup

Pick by intent: silent overwrite can hide duplicate-data bugs; Add makes them loud.

3. Update in place: CollectionsMarshal.GetValueRefOrAddDefault

Counting or accumulating normally needs two lookups:

counts[word] = counts.TryGetValue(word, out var c) ? c + 1 : 1;   // 2 lookups

With CollectionsMarshal (.NET 6+) you get a reference to the value slot:

ref int count = ref CollectionsMarshal.GetValueRefOrAddDefault(counts, word, out bool existed);
count++;                                                          // 1 lookup, updated in place

GetValueRefOrNullRef gives a ref for existing keys only (check with Unsafe.IsNullRef). This also lets you mutate struct values in place without copying. The ref is only valid until the next modification of the dictionary — adding or removing can resize and invalidate it.

4. Custom comparers

The dictionary uses IEqualityComparer<TKey> (default: EqualityComparer<TKey>.Default, which uses IEquatable<T>/Equals + GetHashCode).

var headers = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
var byCode  = new Dictionary<string, Product>(StringComparer.Ordinal);
  • For strings, choose the comparer explicitly — OrdinalIgnoreCase for case-insensitive identifiers. Never use culture comparers for keys.
  • For custom key types, implement IEquatable<T> and a good GetHashCode (or use a record/record struct); keys must be immutable while in the dictionary.
  • Composite keys: (string Region, int Year) tuples or record struct keys work well and avoid string concatenation.
  • .NET 9's alternate lookup lets a Dictionary<string, T> be queried with a ReadOnlySpan<char> without allocating a string: dict.GetAlternateLookup<ReadOnlySpan<char>>().TryGetValue(span, out v).

5. Is enumeration order guaranteed?

No. The documentation says the order is undefined. In practice, a dictionary that has only ever had items added enumerates in insertion order (because entries are appended to an internal array), which is why people come to rely on it. After removals, freed slots are reused by later additions, so order changes. It can also differ between .NET versions.

If order matters: SortedDictionary/SortedList for key order, OrderedDictionary<TKey,TValue> (generic, new in .NET 9) for insertion order with index access, or keep a separate List<TKey>.

6. Other practical points

  • Pre-size with new Dictionary<K,V>(capacity) or EnsureCapacity for large loads.
  • null keys are not allowed (ArgumentNullException); null values are fine.
  • Not thread-safe for writes — use ConcurrentDictionary, a lock, or FrozenDictionary for read-only shared lookups.
  • Read-only after build? ToFrozenDictionary() (.NET 8) gives faster lookups for data that never changes.

7. A production example

A rate-limiting middleware tracked counts per client in a Dictionary<string,int> using ContainsKey + indexer get + indexer set (three lookups) and ToLower() on the client key per request. Profiling showed it as a hotspot and the ToLower allocations as a top allocator. Switching to StringComparer.OrdinalIgnoreCase and CollectionsMarshal.GetValueRefOrAddDefault cut it to one lookup and zero allocations per request. Because the middleware was shared across requests, it also needed thread safety — the final version used ConcurrentDictionary.AddOrUpdate with a static lambda, and later the built-in ASP.NET Core rate-limiting middleware replaced it altogether.

🔍 What the interviewer is checking

Checks day-to-day fluency with the most important .NET collection.

  • Green flags: uses TryGetValue by default, explains Add versus indexer versus TryAdd, passes a string comparer, knows order is not guaranteed, mentions GetValueRefOrAddDefault or FrozenDictionary and thread-safety limits.
  • Red flags: uses ContainsKey plus indexer everywhere, relies on enumeration order, mutates keys, or shares a Dictionary across writer threads.

📚 Learn in depth

1. The data structure

.NET's Dictionary<TKey,TValue> uses separate chaining implemented with arrays (not linked-list nodes):

private int[] _buckets;          // index+1 into _entries for the first entry in each bucket (0 = empty)
private Entry[] _entries;        // all entries, densely packed
private int _count, _freeList, _freeCount, _version;

private struct Entry
{
    public uint hashCode;
    public int next;             // index of next entry in the same bucket chain (-1 = end)
    public TKey key;
    public TValue value;
}

Lookup:

  1. hash = comparer.GetHashCode(key) (or a devirtualised default comparer for value types).
  2. bucket = hash % _buckets.Length — on 64-bit, computed with a fast modulo multiplier (Lemire's method) since the bucket count is a prime.
  3. Follow _entries[i].next along the chain, comparing stored hash codes first, then calling Equals only on matches.

Entries live in one contiguous array, which is cache-friendly and avoids per-entry objects.

2. Resizing

The bucket count is a prime from a precomputed table (3, 7, 11, 17, 23, 29, 37, ... ). When _count reaches the entries array length, the dictionary resizes to the next prime ≥ 2×, allocates new arrays, copies entries, and rehashes (recomputes bucket indexes from the stored hash codes — no GetHashCode calls). Resizing invalidates refs returned by CollectionsMarshal.

3. Removal and the free list

Remove unlinks the entry from its bucket chain and pushes its slot onto a free list (encoded in the next field). The next Add reuses that slot. This is exactly why enumeration order (which walks _entries from 0 to _count) looks like insertion order until removals happen — then new items fill the holes.

4. Hash flooding protection

If an attacker sends many keys with colliding hash codes, chains grow and lookups degrade to O(n) — a denial-of-service attack. Defences in .NET:

  • string.GetHashCode() is randomised per process (Marvin hash), so attackers can't predict collisions.
  • For performance, Dictionary<string, T> with the default/ordinal comparer starts with a non-randomised fast hash and switches to the randomised comparer if a chain exceeds a collision threshold (100).

Custom comparers with poor GetHashCode (e.g. hashing only the first character) reintroduce the problem.

5. How CollectionsMarshal.GetValueRefOrAddDefault works

It performs the normal lookup; if the key exists, returns ref _entries[i].value; otherwise inserts a new entry with default(TValue) and returns a ref to its value. Because it's a raw ref into the entries array:

  • No copy of struct values — you can mutate fields in place.
  • No version increment for in-place updates, so enumerators won't detect your change.
  • Any later Add/Remove can resize or reuse slots; using an old ref afterwards writes to the wrong place or a stale array.

6. Performance comparisons

Operation Lookups
ContainsKey + indexer 2
TryGetValue 1
TryGetValue + indexer set for update 2
GetValueRefOrAddDefault update 1
ConcurrentDictionary.AddOrUpdate 1+ (may retry under contention)
FrozenDictionary lookup 1, with specialised hashing (e.g. length/character-based for strings)

For tens of millions of operations, halving lookups is measurable.

7. OrderedDictionary<TKey,TValue> and FrozenDictionary

  • .NET 9 added a generic OrderedDictionary<TKey,TValue> that preserves insertion order and supports index access (GetAt(i)), at the cost of O(n) removals.
  • FrozenDictionary<TKey,TValue> (.NET 8) analyses keys at construction to pick a specialised strategy (e.g. for small string sets it may hash only some characters or bucket by length). Construction is slower, lookups are faster — ideal for configuration and lookup tables built once at start-up.

Try it yourself

  1. Benchmark ContainsKey + indexer vs TryGetValue vs GetValueRefOrAddDefault for a word count over a large text.
  2. Add 10 keys, remove 3, add 3 new ones, and print enumeration order.
  3. Create a key type whose GetHashCode returns a constant and measure lookups as the dictionary grows.
  4. Compare Dictionary vs FrozenDictionary lookup speed for 100 string keys.

💻 Code

using System.Runtime.InteropServices;

var stock = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);

// Reads
if (stock.TryGetValue("SKU-1", out int qty))      // one lookup
    Console.WriteLine(qty);
int safe = stock.GetValueOrDefault("SKU-9", 0);    // default instead of exception

// Writes
stock.Add("SKU-1", 5);                // throws if present: duplicates are a bug
stock["SKU-1"] = 7;                   // overwrite: last write wins
bool added = stock.TryAdd("sku-1", 9); // false: same key under OrdinalIgnoreCase

// Counting with one lookup per word
var counts = new Dictionary<string, int>(StringComparer.Ordinal);
foreach (var word in words)
{
    ref int c = ref CollectionsMarshal.GetValueRefOrAddDefault(counts, word, out _);
    c++;
}

// Remove and get the old value in one lookup
if (stock.Remove("SKU-1", out int old))
    Console.WriteLine($"Removed {old}");

// Order-sensitive output
var ordered = new OrderedDictionary<string, int>();   // .NET 9+
ordered.Add("first", 1);
ordered.Add("second", 2);

// Span lookup without allocating (.NET 9+)
var lookup = counts.GetAlternateLookup<ReadOnlySpan<char>>();
ReadOnlySpan<char> line = "apple,banana";
bool hasApple = lookup.ContainsKey(line[..5]);

⚠ Common mistakes

1. Double lookups with ContainsKey + indexer

What happens: if (dict.ContainsKey(k)) v = dict[k]; everywhere. Why it is wrong: hashes and searches twice; in concurrent code the key may disappear between calls. Do instead: use TryGetValue or GetValueOrDefault.

2. Using Add where duplicates are possible

What happens: building a dictionary from external data with Add or ToDictionary. Why it is wrong: a single duplicate throws ArgumentException and stops processing. Do instead: decide the policy: TryAdd (first wins), indexer (last wins), or GroupBy and handle duplicates explicitly.

3. Silent overwrites with the indexer

What happens: dict[key] = value used to build lookups from data that should be unique. Why it is wrong: duplicate data bugs are hidden — the last value silently wins. Do instead: use Add (or TryAdd with logging) when uniqueness is an invariant.

4. Relying on enumeration order

What happens: code assumes items come out in insertion order (e.g. for output or pagination). Why it is wrong: order is undefined and changes after removals or between runtime versions. Do instead: use OrderedDictionary<TKey,TValue>, sort explicitly, or keep a list of keys.

5. Mutable keys

What happens: objects used as keys are modified after insertion, changing their hash code. Why it is wrong: the entry is now in the wrong bucket and can't be found or removed. Do instead: use immutable keys (records, strings, IDs).

6. Lowercasing keys instead of using a comparer

What happens: dict[key.ToLower()] on every access. Why it is wrong: allocates on every lookup and uses culture-sensitive casing (Turkish I). Do instead: construct the dictionary with StringComparer.OrdinalIgnoreCase.

7. Writing to a Dictionary from multiple threads

What happens: a static or singleton dictionary used as a cache in a web app. Why it is wrong: concurrent writes can corrupt internal state, causing infinite loops (in older runtimes), lost items or exceptions. Do instead: use ConcurrentDictionary, locks, IMemoryCache, or a frozen dictionary for read-only data.

8. Holding CollectionsMarshal refs across modifications

What happens: a ref from GetValueRefOrAddDefault is used after adding more keys. Why it is wrong: a resize moves entries; the ref points to stale memory. Do instead: use the ref immediately and don't modify the dictionary while holding it.

9. Poor GetHashCode on custom keys

What happens: hash codes based on a single low-variety field, or constant values. Why it is wrong: many collisions turn O(1) lookups into O(n). Do instead: use HashCode.Combine over all key fields, or record keys.

10. Catching KeyNotFoundException

What happens: try { v = dict[k]; } catch (KeyNotFoundException) { ... }. Why it is wrong: exceptions are expensive and obscure intent. Do instead: use TryGetValue.

🏛 Senior insight: trade-offs

Seniors pick the lookup and insert method that states the intent ("duplicates are a bug", "last write wins", "first write wins") and pick the dictionary type from the access pattern.

Intent API
Missing key is a bug indexer get
Missing key is normal TryGetValue / GetValueOrDefault
Duplicate is a bug Add
Last write wins indexer set
First write wins TryAdd
Aggregate in place CollectionsMarshal.GetValueRefOrAddDefault
Read-mostly config ToFrozenDictionary()

They also treat the key's equality as part of the design: string keys always get an explicit comparer, and custom keys are immutable records. That avoids the most common production dictionary bugs, which are case mismatches and mutated keys, not hash table theory.

🔗 Follow-ups the interviewer may ask

  1. When: Candidate is asked how lookups are O(1) Why: Checks hash table internals
    How to answer

    A hash table turns a key into an integer hash, maps it to a bucket (hash % bucketCount), and stores the entry there. Lookups compute the same hash, go straight to the bucket, and compare keys with Equals. Average cost is O(1); worst case (everything in one bucket) is O(n).

    .NET's Dictionary<TKey,TValue> keeps an int[] buckets and an Entry[] entries array; colliding entries form a chain via a next index inside the entries array. When full, it resizes to roughly double (a prime size) and rehashes.

    Contract: if a.Equals(b) then a.GetHashCode() == b.GetHashCode(), and a key's hash must not change while it is in the dictionary. Good hashes spread keys evenly.

  2. When: Candidate mentions mutated keys or bad GetHashCode Why: Probes debugging of lost dictionary entries
    How to answer

    Likely causes, in order:

    1. Poor or constant GetHashCode (e.g. returns 0, or hashes only a low-variety field): all keys collide, chains grow, lookups become O(n), so it gets slower as it grows.
    2. Mutable keys: a field used in the hash changes after insertion, so the entry sits in the old bucket and lookups miss.
    3. Equals/GetHashCode inconsistency: equal keys produce different hashes (e.g. case-insensitive Equals with case-sensitive hash).
    4. Concurrent writes to Dictionary from multiple threads, which can corrupt the internal arrays, lose entries, or even cause infinite loops.

    Debug by measuring hash distribution, checking the key type's Equals/GetHashCode and mutability, and looking for shared dictionaries without locks. Fix with immutable keys (records), consistent comparers, and ConcurrentDictionary or proper locking.

  3. When: Candidate says Dictionary is not thread-safe Why: Checks concurrent collection knowledge
    How to answer

    System.Collections.Concurrent types are safe for many threads at once. ConcurrentDictionary uses lock-free reads and fine-grained (striped) locks for writes. Each single method is atomic, but check-then-act across two calls is not, so use GetOrAdd, AddOrUpdate, TryUpdate or TryRemove. Caveat: the value factory in GetOrAdd/AddOrUpdate can run more than once under contention (only one result is stored), so wrap expensive or side-effecting work in Lazy<T>. Count, IsEmpty and ToArray take every lock, so avoid them in hot paths. ConcurrentQueue/ConcurrentStack are lock-free FIFO/LIFO. ConcurrentBag is unordered, optimised for the same thread adding and taking. BlockingCollection adds bounding and blocking Take but blocks threads; for async producer/consumer use Channel<T>.

🛠 Real-world problems: how you handle them

Intermittent 404s from a case-sensitive route dictionary
Situation
An internal API gateway mapped incoming paths to backend services with a `Dictionary<string, Backend>` built from configuration. Some partner clients sent mixed-case paths.
Signals
A small but steady rate of 404s from one partner; the same request worked from Postman with lowercase paths; no errors in the backend logs because requests never reached them.
Investigation
Logged the raw path on 404 and saw `/Orders/123` versus the configured `/orders`. The dictionary used the default ordinal, case-sensitive comparer. Also found a `ContainsKey` plus indexer double lookup on every request.
Resolution
Built the table with `StringComparer.OrdinalIgnoreCase`, switched to `TryGetValue`, and froze it at startup with `ToFrozenDictionary(StringComparer.OrdinalIgnoreCase)` because it never changed after load.
Prevention
Added tests for mixed-case routes, enabled analyzer CA1854, and added a team guideline that every string-keyed dictionary states its comparer.

How to tell it (STAR)

**Situation:** One of our partners at [company] reported intermittent 404s through our API gateway, while our own tests passed. **Task:** I had to find why only that partner was affected and fix it without breaking other routes. **Action:** I added logging of the raw path on 404s and saw the partner sent `/Orders` while the route table had `/orders`. The routing dictionary used the default case-sensitive comparer. I rebuilt it with `StringComparer.OrdinalIgnoreCase`, replaced the `ContainsKey` plus indexer pattern with `TryGetValue`, and froze the table at startup since it is read-only. **Result:** The 404s went to zero for that partner and gateway lookup time dropped by [about X%]. I added mixed-case tests and turned on CA1854 so the double lookup pattern is flagged in reviews.

↩ Usually asked after

More in Collections

Last updated 30 Oct 2026.

🌐 Sangam's Webring

⏪ Previous 🏠 Main Next ⏩
💬 Chat with me