Hash Maps Explained: Keys, Collisions, and O(1)
See how hash maps turn keys into array positions, and learn how collisions, resizing, and mutable keys affect lookup performance in production code.

A Hash Map Is an Array That Knows Where to Look
A map with a million entries can often find a value about as quickly as one with ten. That can make it sound like the map is doing some kind of rocket-science search. In practice, it's usually simpler: it computes where the value should be, then jumps straight to that spot in an array.
That shortcut is why hash maps show up everywhere - from caches and session stores to language runtimes and databases. Still, "constant-time lookup" is an average story, not a promise that every operation costs the same.
Collisions, table growth, bad hash behavior, and mutable keys can all change what happens in real applications. This guide walks through how a hash map turns a key into an index, what it does when multiple keys land in the same place, and when (and when not) you should expect O(1) performance. It also covers practical choices for key types, capacity planning, and deciding whether a hash map is the right tool.
How does a hash map turn a key into an array index?
The fast part of a hash map is the array underneath it. With an array, a program can jump to a specific element by index. If it needs element five, it calculates where element five lives based on the array's starting address and the element size - no need to inspect elements one through four first.
The problem is that programs rarely have "index numbers" lying around. They have meaningful keys like an email address, username, or user ID. Arrays don't understand those labels as positions.
A hash map solves this by converting a key into a number, then using that number to pick an array slot.
From key to bucket
A hash function takes a key and produces an integer. The map then reduces that integer to fit within the size of its backing array. For example, if the array has eight positions, it might use the remainder after division by eight:
hash("email") = 3,742,909
3,742,909 % 8 = 5
Now the map can store or retrieve the value for email at position five. Each slot is usually called a bucket.
Implementations vary. Some use a remainder operation, others use bit masks or extra transformations. But the core idea stays the same: the key gives you a likely location, and the map avoids comparing against every entry.
A simplified sketch looks like this:
buckets = array with 8 positions
index(key) = hash(key) reduced to array size
buckets[index("email")] = "alex@example.com"
value = buckets[index("email")]
A real implementation does more than this. It has to handle collisions, manage capacity, and confirm that the key it finds at the computed position really matches what you asked for.
What makes a useful hash function?
For a map to work, the key must hash in a compatible way every time you use it. In most languages, if two keys are considered equal, they must produce the same hash. The reverse doesn't have to be true: different keys can still hash to the same number.
A good hash function also spreads typical keys across the available buckets. If lots of unrelated keys keep landing in the same few positions, those buckets get crowded and lookups end up doing extra comparisons.
It helps to remember: hashing is not encryption. A hash map uses hashing to distribute keys and keep lookups fast, not to hide data. Cryptographic hashes have their place, but they're usually overkill for a plain dictionary-style lookup, and they come with different performance trade-offs.
The model is simple: compute a candidate location, then verify the key there. That verification step is what keeps collisions from turning into "wrong value" bugs.
What happens when two keys land in the same bucket?
A hash can be very large, but a table has a limited number of buckets. Once you store more possible keys than you have slots, different keys will sometimes end up in the same bucket. That's a collision. It's not a rare edge case - every hash map has to handle it.
Say email and username both reduce to bucket five. The map can't store both values in an empty "one slot per key" world and later magically know which one belongs to which key. So entries store enough information to distinguish them. In other words: a hash map entry needs both the key and the value, not just the value.
Chaining and open addressing
Most collision-handling strategies fall into two buckets:
- Chaining: A bucket points to a collection of entries. The map checks that bucket's entries until it finds a matching key.
- Open addressing: Entries live inside the table itself. If the computed slot holds a different key, the map probes forward (or in some other pattern) until it finds the key or an empty spot.
Both methods follow the same basic workflow: compute a probable location, then do a bounded amount of extra work when collisions happen. The trade-offs show up in memory use, cache friendliness, deletion complexity, and how performance behaves under heavy collision - but neither approach eliminates collisions.
CPython's dictionary uses open addressing, as described in the implementation comments in Objects/dictobject.c. That's an implementation detail, not a requirement for all Python dictionaries. Java's HashMap uses a different approach and may change bucket structure when buckets get heavily populated.
Why compare keys after hashing?
A hash isn't a unique identity. Two unequal keys can share the same hash value - either because the hash function itself collides, or because the table reduces a large hash space into fewer buckets.
That's why the map checks equality before declaring a match.
This is also why custom key types need care. If a type says two objects are "equal" but returns different hashes, the map may look in the wrong bucket and fail to find the key you thought should match. For custom types, equality and hashing have to be designed together, matching the language's contract.
Typically, collisions add only a few comparisons. But if collisions pile up in one bucket (or along one probe sequence), those comparisons can turn into a scan. That's where the "average O(1)" claim starts to depend on assumptions.
When is hash map lookup really O(1)?
O(1) is about growth: as the collection gets larger, the operation should take roughly constant time on average. It doesn't mean zero work, and it doesn't mean every lookup takes the same number of machine instructions.
A lookup still has to compute a hash, choose a position, and check whether the key at that position matches.
The practical claim is this: under suitable conditions, the work doesn't scale with the number of stored entries. Java's documentation makes that condition explicit for HashMap: constant-time performance for operations like get and put depends on the hash function dispersing keys well across buckets.
The load factor and table growth
A hash map keeps track of how full its table is. The load factor is the number of entries divided by the table's capacity. As the table fills up, collisions and extra checks become more likely. So many implementations grow the table once the load factor crosses a threshold.
For instance, Java's HashMap uses a default load factor of 0.75. With a default initial capacity of 16, the threshold is reached as entries are added, and the table grows. When that happens, existing entries get moved into the larger table. The exact resize behavior depends on the implementation and how you construct the map, but the operational idea is consistent: resizing does a batch of extra work during a particular insertion.
Most insertions are cheap. One insertion triggers the rebuild. Over many operations, the average can still be efficient - but that one operation is not "constant time." If you're dealing with strict latency budgets or batch loads during sensitive windows, you feel this.
Planning capacity instead of reacting later
If you know you're going to store a lot of entries, sizing up-front can reduce (or avoid) resizing. In Java, you can set an initial capacity based on the expected entry count and the load factor, rather than just using the expected count and hoping for the best.
Do this when profiling or a known workload justifies it. Oversizing wastes memory, and guesses can be wrong. For small or unpredictable maps, the default growth behavior is often the sensible compromise. For a big import, cache warm-up, or latency-sensitive path with a known size, pre-sizing can save you a rebuild at the worst possible time.
So the honest takeaway is: hash map performance is best understood as expected constant-time lookup and insertion when distribution and capacity stay reasonable. If you need predictable worst-case latency, average complexity alone isn't enough - you have to look at the implementation details, your threat model, and your workload.
What can make a hash map fail in production?
The array-and-hash model is compact, but it has failure modes that show up surprisingly often because typical tests don't hit them. A map can behave like a scan when many keys collide. Resizing can make some insertions much slower than their neighbors. And keys can become "lost" if their hash-affecting state changes after insertion.
These aren't reasons to avoid hash maps. They're reasons to understand the assumptions they rely on and verify those assumptions match your use case.
Poor distribution and deliberate collisions
If a hash function funnels lots of keys into the same bucket, every lookup has to do more work to find the right entry. With chaining, that can mean walking a longer list. With open addressing, it can mean probing farther.
In the worst case, lookup time can trend toward O(n), where n is the number of stored entries.
This can happen by accident when a custom hash function is poorly designed. It can also be a security issue when an attacker can submit keys that are chosen to collide. In that scenario, a web service that runs expensive map operations on attacker-controlled input may spend a disproportionate amount of CPU time on each request. This kind of problem is often called a hash collision denial-of-service attack.
Python salts the hashes of str and bytes with a randomized value at process startup, which makes it harder to reuse crafted collisions across processes. The hash of a string may therefore differ between runs. That's a defensive choice, and it's not something you should treat as a stable identifier.
If keys come from untrusted users, stick to the language's normal hashing behavior, keep your runtimes and libraries patched, and avoid custom hash functions unless you have a clear reason and a solid design. For high-risk services, add limits on input size and request cost. Hashing is just one layer; it doesn't replace resource controls.
Mutable keys that become unreachable
A key has to behave consistently while it's stored. Imagine a key's hash depends on some field, and you change that field after you insert the key. The map put the entry in the bucket determined by the old hash. Later, a lookup computes a new hash, lands in a different bucket, and concludes the key is missing - even though the entry still sits in its old bucket.
The practical rule is to use stable keys: strings, numbers, immutable value objects, or other objects whose equality and hash-relevant state won't change while they're in the map. Some languages try to prevent obvious mistakes. For example, Python requires dictionary keys to be hashable, but custom types can still violate the intended contract if they expose mutable state that affects hashing.
Ordering is a separate requirement
Hash maps are for fast lookup, not for preserving insertion order. Don't assume iteration order means anything unless the language or the specific collection explicitly guarantees it.
Python dictionaries preserve insertion order as a language guarantee from Python 3.7 onward. Java's HashMap does not promise a stable iteration order.
If your application needs ordered iteration, choose a collection that provides that behavior explicitly. Treating incidental order as a guarantee tends to cause bugs that only show up after a runtime upgrade, a resize, or a different input pattern.
Conclusion
A hash map gets its speed by turning a key into a candidate array position instead of searching through every entry. Collisions mean it still has to verify keys, and resizing plus poor hash distribution explain why individual operations can be slower than the average suggests.
For most application work, the practical rules are straightforward: use stable keys, rely on well-tested hashing, size large maps on purpose when you know roughly how big they'll be, and choose a different collection when you need ordering or worst-case predictability. A hash map is a solid default when its assumptions match your workload - just not magic, and not always a universal "always O(1)" answer.