Practical `Dictionary<TKey,TValue>` API usage: single-lookup patterns, insert semantics, comparers, ref access and ordering.
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.
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.
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);
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.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.
CollectionsMarshal.GetValueRefOrAddDefaultCounting 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.
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);
OrdinalIgnoreCase for case-insensitive identifiers. Never use culture comparers for keys.IEquatable<T> and a good GetHashCode (or use a record/record struct); keys must be immutable while in the dictionary.(string Region, int Year) tuples or record struct keys work well and avoid string concatenation.Dictionary<string, T> be queried with a ReadOnlySpan<char> without allocating a string: dict.GetAlternateLookup<ReadOnlySpan<char>>().TryGetValue(span, out v).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>.
new Dictionary<K,V>(capacity) or EnsureCapacity for large loads.null keys are not allowed (ArgumentNullException); null values are fine.ConcurrentDictionary, a lock, or FrozenDictionary for read-only shared lookups.ToFrozenDictionary() (.NET 8) gives faster lookups for data that never changes.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.
Checks day-to-day fluency with the most important .NET collection.
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.ContainsKey plus indexer everywhere, relies on enumeration order, mutates keys, or shares a Dictionary across writer threads..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:
hash = comparer.GetHashCode(key) (or a devirtualised default comparer for value types).bucket = hash % _buckets.Length — on 64-bit, computed with a fast modulo multiplier (Lemire's method) since the bucket count is a prime._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.
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.
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.
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.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.
CollectionsMarshal.GetValueRefOrAddDefault worksIt 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:
Add/Remove can resize or reuse slots; using an old ref afterwards writes to the wrong place or a stale array.| 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.
OrderedDictionary<TKey,TValue> and FrozenDictionaryOrderedDictionary<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.ContainsKey + indexer vs TryGetValue vs GetValueRefOrAddDefault for a word count over a large text.GetHashCode returns a constant and measure lookups as the dictionary grows.Dictionary vs FrozenDictionary lookup speed for 100 string keys.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]);
ContainsKey + indexerWhat 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.
Add where duplicates are possibleWhat 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.
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.
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.
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).
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.
Dictionary from multiple threadsWhat 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.
CollectionsMarshal refs across modificationsWhat 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.
GetHashCode on custom keysWhat 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.
KeyNotFoundExceptionWhat happens: try { v = dict[k]; } catch (KeyNotFoundException) { ... }.
Why it is wrong: exceptions are expensive and obscure intent.
Do instead: use TryGetValue.
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.
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.
Likely causes, in order:
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.Equals/GetHashCode inconsistency: equal keys produce different hashes (e.g. case-insensitive Equals with case-sensitive hash).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.
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>.
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.
Last updated 30 Oct 2026.