Internal Structure of a Hash Table: The Two-Step Hashing Architecture

Arpit Bhayani

Arpit Bhayani

Jul 11, 2022 • 7 min read

Play

Hash tables are among the most pervasive data structures in software engineering. Known across ecosystems by different names—such as dict in Python, HashMap in Java, or associative arrays in PHP—their fundamental contract is identical: provide average O(1)\mathcal{O}(1) time complexity for insertion, update, deletion, and lookup operations.

While most engineers use hash tables to store domain data, they also serve as foundational building blocks inside programming language runtimes. Understanding their internal mechanics reveals the clever architectural trade-offs that make them fast, memory-efficient, and agnostic of data types.


Hash Tables as Language Runtime Primitives

Hash tables do not exist solely for application-level business logic; modern interpreted and compiled languages rely heavily on them internally:

  1. Class and Method Dispatch Tables
    When calling an object method (e.g., box.put()), the runtime must resolve which executable routine corresponds to .put on box. Object member fields and virtual method tables (vtables) are frequently backed by internal hash tables.
  2. Symbol Tables for Variable Lookups
    When code declares int a = 10 and later executes print(a), the interpreter/compiler maintains a symbol table to map identifier tokens like "a" to stack offsets or memory addresses. This mapping is implemented as a hash table.

Because language runtimes perform hundreds of thousands of member and symbol resolutions per second, a non-constant lookup time would degrade execution performance across the entire runtime.

+-------------------------------------------------------------+
|                 Language Runtime Internals                  |
|                                                             |
|  +------------------------+     +------------------------+  |
|  |      Symbol Table      |     | Class / Method Lookup  |  |
|  |   var_name -> address  |     |   method -> func_ptr   |  |
|  +------------------------+     +------------------------+  |
|               \                     /                       |
|                v                   v                        |
|              +-----------------------+                      |
|              |  Hash Table Primitive |                      |
|              +-----------------------+                      |
+-------------------------------------------------------------+

The Naive Approach: Direct Array Indexing

An ideal constant-time lookup mechanism is direct array addressing: given an integer index, computing its memory offset is a single pointer addition (O(1)O(1)):

Address=Base Address+(Index×Element Size)\text{Address} = \text{Base Address} + (\text{Index} \times \text{Element Size})

If we map every application key (such as a string "apple") to an integer via a hash function, we could theoretically use that integer directly as the index of a gigantic array.

Key ("apple") ---> Hash Function H1(x) ---> Index (12762179) ---> Array[12762179]

Why Direct Indexing Fails at Scale

Assume the primary hash function generates standard 32-bit unsigned integers, providing a range of [0,2321][0, 2^{32} - 1] (roughly 4.29 billion possibilities):

Number of Keys (NN)Array Slots NeededMemory Required (Assuming 4-Byte Pointers)
101040 B
100100400 B
1,000,0001,000,000~4 MB
Full 32-bit Range2324.29×1092^{32} \approx 4.29 \times 10^9~16 GB

If we pre-allocate an array capable of directly indexing any 32-bit integer without collisions, the hash table would demand 16 GB of contiguous memory just to store a handful of entries.

This presents two fatal bottlenecks:

  1. Contiguous Allocation Pressure: Operating systems cannot easily allocate massive blocks of contiguous physical memory on demand without fragmentation issues.
  2. Severe Underutilization: If a program stores only 10 keys across a 16 GB array, 99.999999% of the allocated memory sits idle.

The Two-Step Hashing Architecture

To preserve O(1)\mathcal{O}(1) performance without allocating gigabytes of memory, hash tables employ a two-step mapping pipeline.

flowchart LR
    K["Application Key\n(String, Object, Tuple)"] -->|Step 1: Primary Hash Function\n(e.g., hash / hashCode)| HK["Hash Key\n(Wide Integer: 0 to 2^32 - 1)"]
    HK -->|Step 2: Secondary Mapping\n(Compression / Binning)| B["Holding Array (Bin)\nIndex: 0 to m - 1"]

Step 1: Application Key \to Hash Key

The hash table must accept arbitrary data types (strings, integers, tuples, custom classes). In this step, a type-specific hash function converts the application key into a large integer (typically 32-bit or 64-bit):

  • In Python, classes define __hash__().
  • In Java, objects implement hashCode().
# Conceptual representation of Step 1
class Car:
    def __init__(self, vin: str):
        self.vin = vin

    def __hash__(self) -> int:
        # Map custom object state to a wide integer
        return hash(self.vin)

Because keys must produce a stable hash during their lifetime in a table, mutable collections (such as Python list or unhashable objects) are disallowed as keys.

Step 2: Hash Key \to Array Slot Index (Compression)

Instead of creating an array sized for the entire integer domain (2322^{32}), the runtime allocates a small holding array called a bin or bucket array of size mm, where mO(k)m \approx \mathcal{O}(k) (kk being the expected number of stored keys).

A second mapping function maps the wide hash key into the restricted range [0,m1][0, m - 1].

A standard method is the modulo reduction or bitwise masking (when mm is a power of 2):

Index=HashKey(modm)\text{Index} = \text{HashKey} \pmod{m} Index=HashKey&(m1)(if m=2p)\text{Index} = \text{HashKey} \,\&\, (m - 1) \quad \text{(if } m = 2^p\text{)}

End-to-End Example

Consider storing four key-value pairs in a holding array of size m=8m = 8:

KeyStep 1: Hash Key (H1(Key)H_1(\text{Key}))Step 2: Index (H2(HashKey)=HashKey(mod8)H_2(\text{HashKey}) = \text{HashKey} \pmod 8)Final Bin Slot
"apple"1276217612762176 % 80
"banana"5196551965 % 85
"cat"275275 % 83
"dog"19627191962719 % 87

Instead of 16 GB, the holding array consumes just 8×4 bytes=32 bytes8 \times 4\text{ bytes} = 32\text{ bytes}, maintaining instantaneous lookups while minimizing memory overhead.


Why Decouple into Two Steps?

Separating Step 1 (Application \to Integer) from Step 2 (Integer \to Bin Index) provides critical systems-level architectural benefits:

1. Separation of Concerns and Type Agnosticism

The internal storage engine of the hash table does not need to understand strings, floating-point numbers, network addresses, or custom structs. It only needs to handle integers. The user or compiler handles mapping arbitrary domain types into integers; the data structure engine handles array provisioning, compression, and collision management.

2. Resizing Without Changing User-Level Hashes

As elements are added, the number of keys kk will eventually surpass the capacity of array mm. To prevent performance degradation, the hash table resizes its bin array (commonly doubling: mnew=2×moldm_{\text{new}} = 2 \times m_{\text{old}}).

When resizing occurs:

  • The runtime allocates a new contiguous block of memory.
  • The application key’s Hash Key (Step 1) remains unchanged.
  • Only the Secondary Mapping (Step 2) is re-evaluated for each entry using the new modulus mnewm_{\text{new}}.
  • Elements are re-indexed into their new slots.

Because resizing is infrequent (amortized cost over many insertions), this maintains near O(1)\mathcal{O}(1) average performance without breaking application-layer contracts.


Summary

LayerResponsibilityInput \to OutputManaged By
Step 1: HashingConverts arbitrary keys to uniform integersDomain Key [0,2321]\to [0, 2^{32}-1]Language / User Class (__hash__)
Step 2: BinningCompresses wide integer into holding array boundsHash Key [0,m1]\to [0, m-1]Hash Table Runtime Engine
Storage ArrayHolds direct pointers to keys and values in memoryIndex \to Memory SlotOS Memory Allocator / Heap
Arpit Bhayani

Principal Engineer II at Razorpay - building Agent Studio, Ex-staff engg at GCP Memorystore & Dataproc, Creator of DiceDB, ex-Amazon Fast Data, ex-Director of Engg. SRE and Data Engineering at Unacademy. I spark engineering curiosity through my no-fluff engineering videos on YouTube and my courses