Debugging hash-based collection failures: constant or poor GetHashCode, mutable keys, inconsistent Equals, culture-sensitive comparisons, and thread-unsafe access to Dictionary.
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.
| Symptom | Likely cause |
|---|---|
Gets slower as it grows; CPU in FindValue / TryInsert |
Many collisions (bad hash) |
ContainsKey false right after Add succeeded |
Key mutated after insert, or Equals/hash inconsistent |
| Duplicates that should be the same key | Equals not overridden, or comparer mismatch (case, culture, whitespace) |
Random IndexOutOfRangeException, lost entries, 100% CPU in a dictionary method |
Concurrent writes to a non-thread-safe Dictionary |
A rate limiter stored counters in Dictionary<ClientKey, Counter> shared across requests. ClientKey was a class with TenantId and ApiKey, and its GetHashCode returned TenantId.GetHashCode(). One tenant had 40,000 API keys, so they all landed in the same bucket chain, and lookups slowed to milliseconds. Worse, the dictionary was used from many threads with only reads "protected", and under load a pod hit 100% CPU with threads stuck inside Dictionary.FindValue, a sign of a corrupted chain forming a loop.
Fix: changed ClientKey to readonly record struct ClientKey(Guid TenantId, string ApiKey) (value equality, combined hash), and replaced the dictionary with ConcurrentDictionary using AddOrUpdate. p99 latency of the limiter dropped from 30 ms to under 50 ยตs, and the CPU spikes stopped.
A debugging question that checks deep understanding of hashing, equality and thread safety.
Green flags
Red flags
Good hash: Constant hash (always 7):
bucket 0: A bucket 0:
bucket 1: B โ F bucket 1:
bucket 2: C ...
bucket 3: D bucket 7: A โ B โ C โ D โ E โ F โ ... (n items)
bucket 4: E lookups compare against every key: O(n)
Inserting n items becomes O(nยฒ) in total because each insert checks the chain for duplicates.
key = new OrderKey { Region = "AU", Id = 1 }; hash โ bucket 2
dict.Add(key, order);
key.Region = "NZ"; hash would now โ bucket 5
dict.ContainsKey(key); โ looks in bucket 5 โ not found
// The entry is still in bucket 2, unreachable, and still holds memory.
// Equals ignores case but GetHashCode does not
public override bool Equals(object? o) => o is Sku s && string.Equals(Code, s.Code, StringComparison.OrdinalIgnoreCase);
public override int GetHashCode() => Code.GetHashCode(); // โ "abc" and "ABC" hash differently
// Fix: StringComparer.OrdinalIgnoreCase.GetHashCode(Code)
Dictionary<TKey,TValue> supports multiple concurrent readers only if there are no writers. A write during a read or another write can corrupt buckets/entries. Symptoms are non-deterministic and can include infinite loops. Options:
| Pattern | Use |
|---|---|
ConcurrentDictionary |
Frequent concurrent reads and writes |
lock around a Dictionary |
Low contention, complex multi-step updates |
FrozenDictionary / ImmutableDictionary swapped atomically |
Read-mostly, rebuilt occasionally |
Dictionary.FindValue/TryInsert with growing counts point to collisions.GetHashCode() and count distinct hashes vs keys.Equals and GetHashCode based on the same fields and the same comparison rules? Does it implement IEquatable<T>?a.Equals(b) implies equal hashes.GetHashCode() => 1 and time it.Dictionary in a loop (in a test project) and observe exceptions or hangs; then switch to ConcurrentDictionary.// Diagnose hash quality for a set of real keys
static void ReportHashQuality<T>(IEnumerable<T> keys, IEqualityComparer<T>? comparer = null)
{
comparer ??= EqualityComparer<T>.Default;
var list = keys.ToList();
var groups = list.GroupBy(k => comparer.GetHashCode(k!)).ToList();
Console.WriteLine($"keys: {list.Count:N0}, distinct hashes: {groups.Count:N0}, " +
$"largest collision group: {groups.Max(g => g.Count()):N0}");
}
// Before: mutable class, weak hash, shared Dictionary without locks
public class ClientKey
{
public Guid TenantId { get; set; }
public string ApiKey { get; set; } = "";
public override bool Equals(object? o) => o is ClientKey k && k.TenantId == TenantId && k.ApiKey == ApiKey;
public override int GetHashCode() => TenantId.GetHashCode(); // all keys of a tenant collide
}
private static readonly Dictionary<ClientKey, Counter> Counters = new(); // written from many threads
// After: immutable value key + thread-safe map + immutable value
public readonly record struct ClientKey(Guid TenantId, string ApiKey); // IEquatable<ClientKey>, combined hash
public sealed record Counter(long Count);
private static readonly ConcurrentDictionary<ClientKey, Counter> SafeCounters = new();
public Counter Hit(Guid tenant, string apiKey) =>
SafeCounters.AddOrUpdate(new ClientKey(tenant, apiKey),
static _ => new Counter(1),
// May run more than once under contention, so it must not mutate shared state:
// it returns a new value and ConcurrentDictionary keeps the one that wins.
static (_, existing) => existing with { Count = existing.Count + 1 });
// Property-style test: equal keys must have equal hashes
[Theory]
[InlineData("abc", "ABC")]
[InlineData("Sku-1", "sku-1")]
public void Equal_keys_have_equal_hashes(string a, string b)
{
var x = new Sku(a);
var y = new Sku(b);
Assert.True(x.Equals(y));
Assert.Equal(x.GetHashCode(), y.GetHashCode());
}
Mistake: Hashing only part of the key, or returning a constant.
Why it is wrong: Keys collide, chains grow and lookups degrade to O(n), which looks like a slow memory leak or gradual CPU creep.
Instead: Include every field used by Equals with HashCode.Combine, or use records.
Mistake: Mutating key fields after inserting.
Why it is wrong: The entry stays in the bucket for its old hash, so it becomes unreachable but stays in memory.
Instead: Make keys immutable (records, readonly struct, init properties) or never change fields used for equality.
Mistake: Using different comparison rules in Equals and GetHashCode.
Why it is wrong: Case-insensitive equality with a case-sensitive hash puts "abc" and "ABC" in different buckets, so lookups miss and duplicates appear.
Instead: Use the same comparer for both, for example StringComparer.OrdinalIgnoreCase.GetHashCode(Code).
Mistake: Sharing a Dictionary across threads with writes.
Why it is wrong: A concurrent insert or resize corrupts the internal arrays, causing lost entries, exceptions or infinite loops under load.
Instead: Use ConcurrentDictionary, a lock, or immutable or frozen snapshots.
Mistake: Putting side effects in GetOrAdd or AddOrUpdate delegates.
Why it is wrong: Under contention the delegate can run more than once, so a mutating Increment() can double count.
Instead: Return new immutable values, use Interlocked on a field inside a stored object, or wrap the value in Lazy<T> for expensive creation.
Mistake: Fixing by restarting the service or clearing the cache on a timer. Why it is wrong: The problem returns as the dictionary refills, and the root cause stays hidden. Instead: Measure hash distribution and fix the key contract or the concurrency issue.
When a hash-based collection misbehaves, I look at three contracts: the hash contract (well distributed, consistent with Equals), the immutability contract (keys do not change while stored) and the concurrency contract (no unsynchronised writers). Almost every incident breaks one of these, and each is cheap to check.
| Symptom | Contract broken | Quick check | Fix |
|---|---|---|---|
| Gets slower as it grows | Hash distribution | Distinct hashes versus keys | Hash every equality field |
ContainsKey false after Add |
Immutability or consistency | Is any key field settable? Same comparer in both methods? | Records, readonly struct, one comparer |
| Duplicates of "the same" key | Equality | Is Equals overridden or IEquatable<T> implemented? |
Implement both with the same rules |
100% CPU, threads in FindValue |
Concurrency | Dump: several threads in dictionary methods | ConcurrentDictionary, lock or snapshots |
Prevention is mostly about defaults: prefer readonly record struct or record for composite keys (they implement IEquatable<T> and a combined hash), use explicit comparers for strings, wrap shared mutable state in ConcurrentDictionary or behind a lock, and add tests that equal keys hash equally. In code review, a hand-written GetHashCode that does not mirror Equals, or a static Dictionary written from request code, is an immediate flag.
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.
A map (associative array, dictionary) stores key โ value pairs with unique keys. Choose the implementation by access pattern:
Dictionary<K,V>: hash table, O(1) average, unordered. The default.SortedDictionary<K,V>: red-black tree, O(log n), sorted by key, fast inserts/removes.SortedList<K,V>: two sorted arrays, O(log n) lookup but O(n) insert/remove; compact, indexable. Good for small or build-once sorted maps.OrderedDictionary<K,V> (.NET 9+): keeps insertion order with index access.ConcurrentDictionary<K,V>: thread-safe, lock-free reads.FrozenDictionary<K,V> (.NET 8+): built once, fastest reads.ImmutableDictionary<K,V>: immutable snapshots with cheap modified copies.ILookup<K,V> / Dictionary<K, List<V>>: multi-map (one key, many values).How to tell it (STAR)
**S:** Our [gateway rate limiter] got slower through the day and some pods pinned a CPU. **T:** I was asked to find the cause. **A:** A dump showed threads stuck in dictionary lookups. The key's hash used only the tenant ID, so one large tenant's [40,000] keys collided into a single chain, and the dictionary was also written by many threads without locks. I made the key an immutable record and switched to `ConcurrentDictionary`. **R:** Limiter p99 dropped from [30 ms] to under [50 µs] and the CPU spikes disappeared.
Last updated 23 Oct 2026.