How a C# Dictionary Works Internally: Hashing and Buckets
A dictionary lookup doesn't search — it calculates exactly where to look, then checks one spot. That's the entire secret to O(1).
You've used Dictionary<TKey,TValue> since Foundations — prices["Widget"], instant answer, no matter how many thousands of entries the dictionary holds. It never seemed to matter whether the dictionary had 10 entries or 10 million; the lookup always just felt fast. That's not luck, and it's not because .NET secretly searches really quickly — it's because a dictionary lookup doesn't search at all, in the way a List<T> lookup does.
In this lesson, you'll see exactly how Dictionary<TKey,TValue> achieves average O(1) lookups — hash buckets, GetHashCode(), collision handling — and why a poorly-written GetHashCode/Equals pair can quietly wreck that performance. This sets up the next lesson, which covers writing that pair correctly on your own types.
What Is It?
The Simple Explanation
A Dictionary<TKey,TValue> is a hash table: instead of storing entries in a line and checking each one to find a match (like a List<T> would), it runs each key through a quick calculation that turns it into a number, uses that number to jump almost directly to the small area of memory where that key's entry belongs, and only then does the actual comparison — against a tiny handful of candidates, not the whole collection.
The Technical Definition
A hash table stores entries across an internal array of buckets. Every key is passed to GetHashCode(), producing a 32-bit integer called its hash code. That hash code is reduced (typically via a modulo operation) to an index within the bucket array's current size, determining which bucket the entry is placed in. On lookup, the same process runs on the key you're searching for, landing you directly at the correct bucket — collapsing what would otherwise be an O(n) scan into, on average, a small constant amount of work: O(1).
Why Does It Exist?
The Problem
Imagine looking up a key the way List<T>.Find would — walk every entry, one at a time, comparing keys until you find a match:
// A hypothetical "dictionary" backed by a plain list — O(n) lookup
List<(string Key, decimal Value)> prices = [ ("Widget", 9.99m), ("Anvil", 149.99m), /* ...thousands more... */ ];
decimal FindPrice(string key)
{
foreach (var (k, v) in prices)
if (k == key) return v; // has to check every entry, in the worst case
throw new KeyNotFoundException();
}With 10 entries, that's fine — nobody notices a few microseconds. With 10 million entries, every single lookup potentially walks millions of comparisons. As the collection grows, lookups get proportionally slower — that's O(n) behavior, and it doesn't scale for the kind of fast, frequent key lookups real applications constantly need (looking up a user by ID, a product by SKU, a setting by name).
The Solution
A hash table sidesteps the scan entirely. Instead of asking "which of these thousands of entries matches?", it asks a single, cheap question up front — "given this key, which bucket should I even be looking in?" — and the answer to that question is a direct calculation, not a search. Once you're pointed at the right bucket, there are only ever a small handful of candidates to check, regardless of how large the dictionary as a whole has grown. This is precisely why Dictionary<TKey,TValue> lookups stay fast even as the collection scales into the millions.
Big Picture
"Widget"
↓ GetHashCode()
1836792031 (a 32-bit integer)
↓ reduce to bucket count (say, 8 buckets)
1836792031 % 8 = 7
↓ jump directly to bucket 7
[ bucket 7: ("Widget", 9.99m) ]
↓ compare key with Equals() — confirms the match
9.99m ← returned, without ever touching buckets 0–6
How It Works
- The dictionary calls
key.GetHashCode(), producing a 32-bit integer. This step is fast — a goodGetHashCode()implementation does a small, fixed amount of work regardless of how large the object is.
- The raw hash code is reduced to fit within the current number of buckets (conceptually,
hashCode % bucketCount, though .NET's actual implementation is more refined). This determines exactly which bucket the entry belongs to.
- No other buckets are touched. Whatever entries happen to live in this one bucket are the only candidates that get compared.
- A hash code alone isn't proof of a match — two different keys can share the same hash code (more on this below). So the dictionary calls
Equals()on each candidate in the bucket to confirm which one, if any, is the real match.
- If
Equals()confirms a match, that entry's value is returned. If the bucket is empty, or nothing in it matches, the key genuinely isn't in the dictionary.
Simple Example
You can watch hash codes in action directly — string and every built-in type already override GetHashCode() correctly, so this works out of the box:
string keyA = "Widget";
string keyB = "Anvil";
Console.WriteLine(keyA.GetHashCode()); // some int, e.g. 1836792031 — varies per run for strings
Console.WriteLine(keyB.GetHashCode()); // a different int, in almost all cases
var prices = new Dictionary<string, decimal>
{
["Widget"] = 9.99m,
["Anvil"] = 149.99m
};
// Internally: prices["Widget"] computes "Widget".GetHashCode(), maps it to a bucket,
// jumps there directly, confirms with Equals("Widget", "Widget") == true, returns 9.99m.
decimal price = prices["Widget"];
Console.WriteLine(price); // 9.99Code → Meaning → Result:
- Every key gets its own hash code — different strings almost always hash to different values, spreading entries across buckets.
prices["Widget"]never has to compare against"Anvil"at all — the hash code alone routes it to the correct bucket before any string comparison happens.- This is why lookups don't slow down as more unrelated keys are added — they land in different buckets and are simply never examined.
Real-World Example
A Repository<T> caching customer records by ID relies entirely on this mechanism to stay fast, even as the cache grows to hold every customer the application has ever touched in a session.
public record Customer(int Id, string Name, string Email);
public class CustomerCache
{
private readonly Dictionary<int, Customer> _cache = new();
public void Add(Customer customer) => _cache[customer.Id] = customer;
public Customer? Find(int id) =>
_cache.TryGetValue(id, out Customer? customer) ? customer : null;
}
var cache = new CustomerCache();
for (int i = 1; i <= 500_000; i++)
cache.Add(new Customer(i, $"Customer {i}", $"customer{i}@example.com"));
// Even with half a million entries, this lookup is just as fast as it would be with 5:
Customer? found = cache.Find(482_193);
Console.WriteLine(found?.Name); // Customer 482193 — near-instant, regardless of cache sizeBecause int already has a fast, well-distributed GetHashCode() (for a reasonably small int, it's essentially the value itself), _cache[customer.Id] spreads customers evenly across buckets, and TryGetValue reaches any single customer in roughly the same, tiny amount of time — whether the cache holds 5 entries or 500,000.
Analogy
Think of a hash table as a post office with a wall of numbered sorting bins. Instead of dumping every letter into one giant pile and reading each one's address to find yours (that's the O(n) list-scan approach), the post office looks at just the ZIP code, uses it to compute a bin number, and drops the letter straight into that bin. When you come to pick up your mail, the clerk computes the same bin number from your ZIP code, walks straight to that one bin, and only then reads the names on the (hopefully very few) letters actually inside it to find yours.
The hash code is the ZIP code — a cheap, quick-to-compute number that narrows the search from "the entire building" down to "one small bin," before any real comparison ever happens.
Under the Hood
- A 32-bit hash code has about 4 billion possible values, but a dictionary might only have a few dozen buckets at any given moment — many different hash codes inevitably map to the same bucket index. This is called a collision, and it's expected, normal behavior, not a bug.
- When multiple entries land in the same bucket, .NET keeps them linked together (conceptually a small chain) inside that bucket. A lookup that lands on a colliding bucket has to check each chained entry with
Equals()until it finds the real match — a handful of comparisons, not a full scan of the dictionary.
- If every key happened to collide into the same handful of buckets, lookups would degrade toward O(n) — you'd be back to scanning a long chain. A well-distributed hash function (one that spreads different inputs across many different hash codes) keeps chains short and lookups genuinely close to constant time. This is precisely why the quality of
GetHashCode()matters so much — it's the difference between the fast dictionary you expect and a silently degraded one.
- As more entries are added, .NET periodically grows the internal bucket array and re-distributes every existing entry across the new, larger set of buckets — a process called rehashing. This keeps the average number of entries per bucket low as the dictionary grows, which is what keeps lookups close to O(1) even at large scale, rather than slowly degrading as more items are added.
Common Confusion
1. "O(1)" doesn't mean "instant" or "guaranteed" — it means "average constant time"
O(1) describes how lookup time scales as the dictionary grows — it stays roughly flat, on average, rather than climbing with the number of entries, the way O(n) would. It's not a claim that every single lookup takes the exact same number of nanoseconds, nor a guarantee against the rare worst case (heavy collisions) degrading toward O(n). "Average" is doing real, important work in that phrase.
2. A hash code collision is not the same thing as two keys being "equal"
Two different keys sharing a hash code is completely normal and expected — the pigeonhole principle guarantees it will eventually happen, since there are far more possible keys than possible 32-bit hash codes. What would be a genuine bug is two keys that Equals() says are equal producing different hash codes — that specific failure is what the next lesson on the Equals/GetHashCode contract covers in full.
3. Buckets don't preserve insertion order — that's why Dictionary<TKey,TValue> iteration order isn't guaranteed
Because an entry's position is determined by its key's hash code, not by when it was added, iterating a dictionary visits entries in whatever order the buckets happen to lay them out — which can even change after a resize. If you need guaranteed order, reach for SortedDictionary<TKey,TValue> (from the previous lesson) or maintain a separate ordered structure alongside the dictionary.
Common Mistakes
Mistake 1 — Assuming dictionary iteration preserves insertion order
Relying on foreach (var kv in dictionary) to visit entries in the order they were added — this happens to often appear true for small dictionaries in practice, but it is explicitly not a documented guarantee, and can break after a resize or with different key distributions. Use SortedDictionary<TKey,TValue> if you need sorted order, or maintain a separate List<TKey> alongside the dictionary if you specifically need insertion order.
Mistake 2 — Mutating a key's fields after it's been used as a dictionary key
If a key's hash code can change after it's already placed in a bucket (say, because it's a mutable class and one of the fields GetHashCode() depends on gets modified), the dictionary will look for it in the new bucket that key would now hash to — but the entry is still sitting in the old bucket. The key effectively becomes unfindable.
public class MutableKey { public int Value; public override int GetHashCode() => Value; }
var key = new MutableKey { Value = 1 };
dict[key] = "data";
key.Value = 2; // hash code just changed out from under the dictionary
dict.TryGetValue(key, out var found); // false — looks in the wrong bucket nowKeys should be effectively immutable — this is exactly why records and immutable value types make such natural dictionary keys.
Mistake 3 — Assuming every custom class already has a "good" hash code
Using a plain custom class as a dictionary key without overriding GetHashCode() and Equals() — by default, they're based on the object's memory reference, not its data, so two logically identical instances won't find each other in the dictionary. The next lesson covers exactly how to write this pair correctly for your own types.
When Should I Use It?
Reach for a Dictionary when
- You need fast lookup by a key, and don't care about iteration order.
- Your key type already has (or you've correctly written) a well-distributed
GetHashCode(). - You're doing lookups far more often than full-collection scans.
Reconsider when
- You genuinely need entries in a specific order — reach for a sorted collection, or track order separately.
- Your key type is mutable in ways that affect its hash code — fix that first, or the dictionary will silently misbehave.
- You have only a handful of entries and never look them up by key — a simple list may be simpler and just as fast in practice.
GetHashCode(). Built-in types already do this well — the moment you use a custom class as a key, that responsibility becomes yours, which is exactly what the next lesson walks through.
Mental Model
Bucket index = that number, mapped down to "which bin"
Collision = two keys sharing a bucket — normal, handled by a short chain within the bucket
Equals() = the final confirmation, checked only against the few candidates in that one bucket
Remember:
· A dictionary calculates where to look — it never scans the whole collection.
· "O(1) average" assumes a well-distributed hash code — a bad one degrades toward O(n).
· Dictionary iteration order is not guaranteed — never rely on it.
Key Takeaway
- A
Dictionary<TKey,TValue>is a hash table — it computes each key's hash code, maps that to a bucket, and jumps straight there instead of scanning. - This is why lookups stay close to O(1) on average as the dictionary grows, instead of the O(n) you'd get scanning a list.
- Two different keys landing in the same bucket is a collision — normal, expected, and handled by chaining a few entries together within that bucket.
Equals()only ever needs to run against the small number of candidates in the correct bucket — the hash code already did the heavy lifting.- A poorly-written
GetHashCode()— one that clusters many different keys into the same few buckets — silently degrades average O(1) performance toward O(n), which is exactly why the next lesson matters.
Check Your Understanding
You've seen exactly how a dictionary lookup avoids scanning. Let's check your understanding.
1. Why does a Dictionary<TKey,TValue> lookup stay fast even as the number of entries grows into the millions?
Show answer
Correct: B
Why B is correct: As shown in "How It Works," the hash code lets the dictionary calculate exactly where to look, so it never touches unrelated buckets — this is what keeps lookup time from growing as the dictionary grows.
Why A is incorrect: No full scan happens at all — that's precisely the O(n) behavior a hash table exists to avoid.
Why C is incorrect: That describes a sorted, tree-like structure such as SortedDictionary<TKey,TValue>, which is O(log n), not the hash-bucket approach Dictionary<TKey,TValue> actually uses.
Why D is incorrect: A dictionary retains every entry you add — it doesn't discard older ones on its own.
Reinforcement: The hash code replaces "search" with "calculate," which is exactly what makes O(1) average lookups possible.
2. Two different keys happen to produce the same hash code and land in the same bucket. What does this mean?
Show answer
Correct: B
Why B is correct: As covered in "Under the Hood," collisions are expected — there are far more possible keys than 32-bit hash codes — and .NET's dictionary handles them by chaining entries within a bucket, then using Equals() to identify the actual match.
Why A is incorrect: Collisions are a normal, anticipated part of hashing, not evidence of a broken implementation.
Why C is incorrect: A collision is handled gracefully — it doesn't cause an exception, just a slightly longer (but still small) comparison within one bucket.
Why D is incorrect: Both entries remain fully present and retrievable — sharing a bucket doesn't cause data loss.
Reinforcement: A collision is not the same as a bug — it's an expected event that a well-implemented hash table is specifically designed to handle cheaply.
3. What happens if a dictionary key's fields (which its GetHashCode() depends on) are mutated after the key has already been added to the dictionary?
Show answer
Correct: B
Why B is correct: As shown in "Common Mistakes," a dictionary has no way to know a key's hash-relevant fields changed — it only recomputes the bucket location at lookup time, using the key's current state, which no longer matches where the entry actually lives.
Why A is incorrect: Dictionaries have no mechanism to detect or react to field mutations on their keys — this is exactly why the problem occurs.
Why C is incorrect: This is a runtime behavioral issue, not something the compiler can detect — the code compiles fine and simply misbehaves at runtime.
Why D is incorrect: The entry isn't deleted — it's still stored, just unreachable through normal lookup because the dictionary is now searching in the wrong bucket.
Reinforcement: Dictionary keys should be effectively immutable — mutating hash-relevant state after insertion silently breaks lookups.
4. What does "O(1) on average" actually promise about dictionary lookup performance?
Show answer
Correct: B
Why B is correct: As explained in "Common Confusion," "average" is the key word — it describes typical behavior given a well-distributed hash function, not an absolute per-lookup guarantee, and heavy collisions can degrade performance toward O(n) in a poor implementation.
Why A is incorrect: Individual lookup times can vary (a bucket with several chained entries takes marginally longer than an empty one) — O(1) describes the overall scaling trend, not identical timing every time.
Why C is incorrect: This has nothing to do with Big-O notation or dictionary capacity — a dictionary can hold as many entries as memory allows.
Why D is incorrect: Other structures can be faster in specific scenarios (e.g. a plain array with a known index), and a badly-hashed dictionary can even be slower than expected — O(1) average is a strong guarantee, not a universal one.
Reinforcement: "Average constant time" describes how performance scales with size, not a promise that every operation takes identical, guaranteed time.
You now understand exactly how Dictionary<TKey,TValue> achieves its speed. Next: writing a correct Equals/GetHashCode pair on your own types — the contract every hash-based collection depends on.