Deep Engineering
Search
Internals·Published·3.6 · 3.13·40 MIN

Python dictionaries from the inside: the CPython hash table

Hashing, open addressing, and the 2016 rewrite that made insertion order a side effect of the implementation — and, one release later, a guarantee of the language.

Full technical treatment

TL;DR

  • A CPython dictionary is two arrays: the sparse index array dk_indices and the dense, insertion-ordered entry array dk_entries.
  • Lookup is open addressing with probing driven by perturb, not chains inside buckets. The load factor is capped at ⅔.
  • Order preservation appeared in 3.6 as an implementation detail of that layout and became a language guarantee in 3.7.
  • Key sharing (the key-sharing dict of PEP 412) is the reason instance dictionaries are cheaper than they look.

Why know this?

Because dictionaries are the substrate of the language, not just one container among others. Every attribute access, every module namespace, every set of keyword arguments is a dictionary operation. If your service has a hot loop, there is almost certainly a dictionary inside it, even if you never wrote one by hand.

The practical consequence: CPython has not one dictionary lookup implementation but several specializations, and switching between them shows up in the numbers. The clearest demonstration is the optimization from 3.11: if all of a dictionary's keys are strings, the interpreter stops storing their hashes in the table, and sys.getsizeof(dict.fromkeys("abcdefg")) drops from 352 to 272 bytes. Add a single non-string key to such a dictionary and the table switches to the general mode. Neither transition is visible in the Python source code: to explain the regression you have to know about the layout.

The mental model

Picture a coat check. The sparse dk_indices array is the board of numbered tags on the wall: almost empty, cheap to enlarge. The dense dk_entries array is the rail with the coats themselves, hung strictly in the order they arrived. The hash says which tag to read; the tag says where to go along the rail.

Growing the table redraws the board and never rehangs the coats. That is exactly why iteration order survives a resize — and exactly why the property was originally a side effect of the layout rather than a promise of the language:

The order-preserving aspect of this new implementation is considered an implementation detail and should not be relied upon.

What's New in Python 3.6 — New dict implementation

That is how the CPython authors described the side effect of the compact dictionary in 3.6 — one release before the same behavior became an official part of the language.

The design: two tables instead of one

“Sparse” and “dense” are not decorations

Two words without which nothing below adds up.

A sparse array is one that deliberately has more free cells than it needs. For an open-addressing hash table this is not a luxury but a working condition: a key is found by the formula “take the hash, cut off the low bits, look in the slot,” and if no free slots are left, the search for a free place and the resolution of collisions both degenerate into a scan of the whole table. So CPython never lets the table fill past two thirds (USABLE_FRACTION) — at least a third of the slots always stands empty, and that is not waste but working headroom.

A dense array is the opposite: it is filled consecutively from position zero, with no gaps, and grows only at the tail. Gaps appear in it only as the trace of deleted keys, and they live until the next rebuild.

The compact dictionary is made of both at once — and the whole trick is which of the two was made the sparse one.

What dk_indices is for in the first place

Before Python 3.6 there was a single array. Here is that very struct from CPython 3.5:

Objects/dict-common.h (CPython 3.5)
struct _dictkeysobject {
    Py_ssize_t dk_refcnt;
    Py_ssize_t dk_size;
    dict_lookup_func dk_lookup;
    Py_ssize_t dk_usable;
    PyDictKeyEntry dk_entries[1];
};

Here dk_entries is the hash table: slot number i is dk_entries[i], a whole entry made of hash, key and value. That is, the sparse array was an array of 24-byte entries, and the mandatory empty third cost 24 bytes per slot.

The idea of the compact dictionary (Raymond Hettinger's message to python-dev, December 2012) in one sentence: let the cheap array be the sparse one and the expensive one be dense. Sparseness is needed by the hash table itself, not by the entries, so the hash table was moved into a separate array of small integers, and all that empty headroom came to cost one byte per slot instead of twenty-four.

The arithmetic is easy to do in your head. A table of 8 slots holding 5 live keys:

Python 3.5Python 3.6+
hash table8 × 24 = 192 Bdk_indices: 8 × 1 = 8 B
entries— (they are the table)dk_entries: 5 × 24 = 120 B
total192 B, 72 B of it wasted128 B, wasted: 8 bytes of indices

On a table of 65,536 slots (43,690 entries) it is 1,572,864 bytes against 1,179,632 — the very “between 20% and 25% smaller” memory usage with which the 3.6 release notes describe the new implementation.

And now the answer to “what is this extra layer for at all”: without it, key lookup would become linear. dk_entries is ordered by insertion time, not by hash — you cannot compute from it where a key lies, you can only scan every entry in turn. dk_indices is the hash table: the address of a slot comes out of the hash in a single hash & mask operation, and the slot already holds the number of the entry you need. One access instead of a scan, O(1) instead of O(n).

Insertion order came out of this as a side effect: since entries are appended to the dense array one after another, walking that array yields the keys in the order they were added. There is no separate structure for order in the dictionary.

The two structures side by side

dk_indices is the sparse array — the one whose size is dk_size and is always a power of two. A slot holds neither a key nor a value but a small integer: the index of an entry in the second array. Or one of the service values: DKIX_EMPTY (−1) for an empty slot and DKIX_DUMMY (−2) for a slot whose key was deleted. The width of one slot is chosen to match the size of the table — from the comment on dk_indices in pycore_dict.h:

Include/internal/pycore_dict.h
/* The size in bytes of an indice depends on dk_size:
   - 1 byte if dk_size <= 0xff (char*)
   - 2 bytes if dk_size <= 0xffff (int16_t*)
   - 4 bytes if dk_size <= 0xffffffff (int32_t*)
   - 8 bytes otherwise (int64_t*) */
char dk_indices[];

This is why “the board is cheap to enlarge”: a dictionary of eight slots spends 8 bytes on dk_indices, not eight full entries. Even at a table of 65 thousand slots it is two bytes per slot.

dk_entries is the dense array of the entries themselves, in insertion order. And here is what an entry actually holds:

Include/internal/pycore_dict.h
typedef struct {
    /* Cached hash code of me_key. */
    Py_hash_t me_hash;
    PyObject *me_key;
    PyObject *me_value;
} PyDictKeyEntry;

Three fields of one machine word each — on a 64-bit build exactly 24 bytes per entry. The key and the value are pointers: the objects themselves live somewhere else on the heap, the dictionary stores only addresses and holds references to them. And me_hash is a cache: the key's hash is computed once on insertion and kept alongside, so that hash() need not be called again on every comparison in the probe sequence and on every growth of the table.

How these arrays get filled

Inserting a new key is four events in all, and their order is not the one that looks natural. Here is the body of insert_combined_dict from dictobject.c, cut down to the writes to memory:

Objects/dictobject.c — insert_combined_dict()
Py_ssize_t hashpos = find_empty_slot(mp->ma_keys, hash);
dictkeys_set_index(mp->ma_keys, hashpos, mp->ma_keys->dk_nentries);
 
if (DK_IS_UNICODE(mp->ma_keys)) {
    PyDictUnicodeEntry *ep = &DK_UNICODE_ENTRIES(mp->ma_keys)[mp->ma_keys->dk_nentries];
    STORE_KEY(ep, key);
    STORE_VALUE(ep, value);
}
else {
    PyDictKeyEntry *ep = &DK_ENTRIES(mp->ma_keys)[mp->ma_keys->dk_nentries];
    STORE_KEY(ep, key);
    STORE_VALUE(ep, value);
    STORE_HASH(ep, hash);
}
STORE_KEYS_USABLE(mp->ma_keys, mp->ma_keys->dk_usable - 1);
STORE_KEYS_NENTRIES(mp->ma_keys, mp->ma_keys->dk_nentries + 1);

It reads like this:

  1. find_empty_slot looks for a free slot in dk_indices — by the hash and, on a collision, by the probe sequence (covered below). The key and the value take no part in that search at all: the slot is chosen by the bits of the hash.
  2. dictkeys_set_index writes the current value of dk_nentries into the slot it found — the number of an entry that does not exist yet. The index lands on the board before the entry itself is created.
  3. Only now is the entry at index dk_nentries in dk_entries filled in: two pointers, plus the hash in the general mode.
  4. dk_usable goes down by one, dk_nentries goes up. The first counter is what is left before a resize (USABLE_FRACTION), the second is the boundary of the filled part of the dense array.

Nothing else is written to memory on insertion. Neither the key nor the value is copied anywhere: STORE_KEY and STORE_VALUE save the addresses of objects that already exist and raise their reference counts.

Below are the same four steps in interactive form: the play button runs the insertion phase by phase, and the switch at the top changes the entry layout from the general one to the string one.

Fig. 1 · What is written into dk_indices and dk_entries, and in what order
STEP 00 / 12
INITIAL STATE
PyDict_MINSIZE == 8
PyDictKeysObject · header
dk_kind
GENERAL
dk_log2_size
3 → 8 slots
dk_nentries
0
dk_usable
5
dk_indices · sparse array1 byte per slot (dk_size ≤ 0xff) · 8 bytes in total
0
−1
1
−1
2
−1
3
−1
4
−1
5
−1
6
−1
7
−1

A slot holds neither the key nor the value, but the index of an entry in dk_entries — a small integer. An empty slot holds DKIX_EMPTY (−1), and a slot left by a deletion holds DKIX_DUMMY (−2). That is exactly why growing this board is cheap: with eight slots it takes 8 bytes, not 8 full entries.

dk_entries · dense array, insertion orderPyDictKeyEntry = 24 bytes per entry
#
me_hash · 8 bytes
me_key · pointer, 8 bytes
me_value · pointer, 8 bytes
— empty —
entry = 24 bytes
Text equivalent · step 00

Empty dict. dk_indices is 8 one-byte slots, all of them DKIX_EMPTY (−1). dk_entries is a dense array for USABLE_FRACTION(8) = 5 entries of 24 bytes each; none of them filled yet. Everything that follows amounts to two written numbers and three pointers (two in the other mode).

The second kind of entry: when every key is a string

The entry has a second form as well. If all the table's keys are strings, CPython uses a different struct:

Include/internal/pycore_dict.h
typedef struct {
    PyObject *me_key;   /* The key must be Unicode and have hash. */
    PyObject *me_value;
} PyDictUnicodeEntry;

There is no hash field at all here — 16 bytes instead of 24. The hash is not lost in the process: a string object has it cached inside the object already, so there is no point keeping a second copy in the table. Which of the two variants is in use is recorded in the dk_kind field (DICT_KEYS_GENERAL or DICT_KEYS_UNICODE; there is a third one, DICT_KEYS_SPLIT, covered below). This is the saving mechanism the 3.11 changelog talks about: sys.getsizeof(dict.fromkeys("abcdefg")) is 272 bytes instead of the previous 352. You can switch between the modes in the visualization above.

It is the order in dk_entries that gives the dictionary what became an official language guarantee rather than an implementation detail in Python 3.7: iterating a dict goes in the order the keys were inserted.

There is a third variant too — the split table (PEP 412, “key-sharing dictionary”): if many objects of one class have the same set of string attributes, they can share one and the same dk_indices and key part, each keeping its values in its own ma_values. This is an optimization for instance __dict__s, not for general-purpose dictionaries — the main text of this article is about the ordinary, “combined” dictionary.

Step 1: from key to number

The first thing that happens on d[key] = value or d[key] is a call to hash(key). What comes next depends heavily on the type of the key:

  • For int the hash almost always equals the value: hash(n) == n for most integers (with reduction modulo the Mersenne prime 2⁶¹−1 on 64-bit builds — see sys.hash_info). The exception is -1. CPython reserves -1 as the error code for C functions returning Py_hash_t, so several of its hash functions (for bytes, float, pointers — see Python/pyhash.c) explicitly replace a computed -1 with -2. The same convention applies to int: hash(-1) == -2.
  • For str (and bytes), since PEP 456 it is SipHash13, and with a seed that is random for every run of the process (PYTHONHASHSEED). This is a deliberate security decision: before PEP 456 a predictable string hash gave the classic "hash flooding" DoS attack through specially crafted keys. The practical consequence: hash("cpython") in two different runs of python3 is, as a rule, two different numbers.

Below is that same first step, key → hash(key), in interactive form. For int the real hash() value is shown; for str it is a deliberately simplified demonstration function (not SipHash), because a real string hash is by construction not reproducible across runs.

42→ hash() →42→ & mask(7) →slot 2

Step 2: from number to slot

A hash is an arbitrary integer, while the table is an array of dk_size slots, where dk_size is always a power of two. The initial candidate slot comes from the cheapest operation available here at all:

Objects/dictobject.c
mask = dk_size - 1;
i = hash & mask;

& mask instead of % dk_size is the usual trick for powers of two: the low bits of hash are the remainder, with no division as such.

Collisions: the CPython probe sequence

Two different keys will almost inevitably produce the same i sooner or later. Here CPython uses neither linear probing nor a textbook scheme based on double hashing, but a recurrence of its own, taken straight from the comment in dictobject.c:

Objects/dictobject.c
perturb = hash;
i = hash & mask;
 
while (slot_at(i)->occupied) {
    perturb >>= PERTURB_SHIFT;      /* PERTURB_SHIFT == 5 */
    i = (i*5 + perturb + 1) & mask;
}

The idea: ordinary linear probing (i = i + 1) is too regular — with the table partly filled it produces long collision runs that correlate with mask itself. The perturb term, shifted right by 5 bits at every step, gradually "mixes" the high bits of the original hash into the probe sequence — precisely the bits that & mask on its own never uses. The result is a pseudo-random but completely deterministic walk over all dk_size slots.

Check the formula yourself — the table below recomputes CPython's real probe sequence for the hash you enter and the slots you mark as occupied:

A table of 8 slots (mask = 7). Click the slots below to mark them occupied and create collisions — the probe sequence is recomputed with CPython's real formula.

  1. i₀ = hash & mask = 37 & 7 = 5
  2. probe 1: perturb = 1, i = (i·5 + perturb + 1) & mask = 3
  3. → the key takes slot 3 after 1 probe(s).

Table growth: when, and by how much

A hash table filled to the brim degenerates into a linear search — CPython never gets that far and keeps headroom. Three numbers from dictobject.c fully determine when and how the table grows:

Objects/dictobject.c
#define PyDict_MINSIZE 8
#define USABLE_FRACTION(n) (((n) << 1)/3)   /* resize at 2/3 full */
#define GROWTH_RATE(d) ((d)->ma_used*3)

A fresh dictionary starts at dk_size = 8 and dk_usable = USABLE_FRACTION(8) = 5 — exactly what the comment in the source describes: "8 allows dicts with no more than 5 active entries". Every successful insertion lowers dk_usable by 1; when it would have to go negative, a resize runs before the insertion: the new minimum size is GROWTH_RATE(mp), that is ma_used * 3, and the actual new dk_size is the nearest power of two not smaller than that value. If there were no deletions, ma_used * 3 almost always lands in the range "the next power of two after doubling", hence the comment in the source: "dicts double in size when growing without deletions".

Interactive visualization

Step through six insertions into a table of 8 slots. Note the probing on the collision at step 3 and the growth at step 6 — the entry array is copied verbatim, and not one entry changes position.

The keys here are integers rather than strings, and that is deliberate: hash(n) == n, so every slot number below can be checked in your own interpreter, whereas a string hash is not reproducible across runs (see step 1).

Fig. 2 · Insertion and resize of a compact dictSTEP 00 / 07
dk_indices · 8 slots
0
·
1
·
2
·
3
·
4
·
5
·
6
·
7
·
dk_entries · insertion order preserved
#
Hash
Key
Value
— empty —
dk_size = 8
Text equivalent · step 00

Empty dict. dk_indices holds 8 slots, all of them DKIX_EMPTY (shown as ·). dk_entries is empty. The usable capacity of a 8-slot table is USABLE_FRACTION(8) = 5 entries.

The same dictionary in full, if you want to see for yourself:

check_order.py
import sys
 
d = {}
for k in (3, 6, 11, 1, 7):
    d[k] = "…"
print(sys.getsizeof(d))      # the table is still 8 slots
 
d[2] = "…"                   # the sixth insertion crosses the 2/3 threshold
print(sys.getsizeof(d))      # grown: dk_indices is now 16 slots
print(list(d))               # [3, 6, 11, 1, 7, 2] — insertion order survived

Update and delete: three different paths

Everything about inserting a new key has been said, but d[k] = v is also an update of a key that already exists, and del d[k] works quite differently again. The three paths behave differently, and the difference is visible to the naked eye.

Update: nothing moves

If the lookup found the key, insertdict never gets as far as creating an entry:

Objects/dictobject.c — insertdict(), the “key already present” branch
if (old_value != value) {
    uint64_t new_version = _PyDict_NotifyEvent(
            interp, PyDict_EVENT_MODIFIED, mp, key, value);
    assert(old_value != NULL);
    assert(!_PyDict_HasSplitTable(mp));
    if (DK_IS_UNICODE(mp->ma_keys)) {
        PyDictUnicodeEntry *ep = &DK_UNICODE_ENTRIES(mp->ma_keys)[ix];
        STORE_VALUE(ep, value);
    }
    else {
        PyDictKeyEntry *ep = &DK_ENTRIES(mp->ma_keys)[ix];
        STORE_VALUE(ep, value);
    }
    mp->ma_version_tag = new_version;
}
Py_XDECREF(old_value);

One field is overwritten — me_value — and the old value gets a Py_DECREF. Neither dk_indices nor me_key nor me_hash is touched, and dk_nentries and dk_usable do not change. Hence the practical consequence people often argue about: assigning to an existing key does not change iteration order. The key stays at its position in dk_entries, and therefore in iteration.

Deletion: a tombstone instead of an entry

delitem_common touches both structures. Below is its body, cut down to the writes to memory:

Objects/dictobject.c — delitem_common()
Py_ssize_t hashpos = lookdict_index(mp->ma_keys, hash, ix);
STORE_USED(mp, mp->ma_used - 1);
mp->ma_keys->dk_version = 0;
dictkeys_set_index(mp->ma_keys, hashpos, DKIX_DUMMY);
 
PyDictKeyEntry *ep = &DK_ENTRIES(mp->ma_keys)[ix];
old_key = ep->me_key;
STORE_KEY(ep, NULL);
STORE_VALUE(ep, NULL);
STORE_HASH(ep, 0);

In place of the index, the slot gets DKIX_DUMMY (−2) — a “tombstone”. The slot cannot be marked as empty, and the source says outright why:

Dummy slots cannot be made Unused again else the probe sequence in case of collision would have no way to know they were once active.

Objects/dictobject.c, the comment on slot states

If the slot became DKIX_EMPTY, a lookup for a key that once jumped over this slot further down the probe sequence because of a collision would stop right there — and a key sitting in the dictionary would stop being found.

The entry in dk_entries is zeroed in place: a hole is left at its position. And — most important for the next section — the free-capacity counter is not restored. The comment in the source explains that too: since tombstones remain in dk_indices, dk_usable cannot be raised, even though there are now fewer live keys.

Fig. 3 · Update, deletion and re-insertionFRAME 00 / 04
THREE INSERTIONS
d = {1: 1, 2: 2, 3: 3}
ma_used
3
dk_size
8
dk_usable
2
dk_nentries
3
getsizeof
224 B
dk_indices · 8 slotstombstones (−2): 0
0
−1
1
0
2
1
3
2
4
−1
5
−1
6
−1
7
−1
dk_entries · filled partholes from deleted keys: 0
#
me_hash
me_key
me_value
0
1
1
1
1
2
2
2
2
3
3
3
list(d) → [1, 2, 3]
CPython 3.13.13 · measured with ctypes
Text equivalent · frame 00

The starting state: three keys in a table of 8 slots. The keys are integers, so hash(n) = n and the slot is n & 7. Slots 1, 2, 3 hold indices 0, 1, 2, and the dk_entries rows follow one another in insertion order.

The last frame is worth remembering separately: deleting a key and inserting it back is not the same as updating the value. The slot in dk_indices is reused (find_empty_slot stops at an empty slot and at a tombstone alike), but a new entry is created at the tail — and the key moves to the end of the iteration order.

So does dk_indices grow without bound?

The previous section adds up to an alarming picture: every insertion spends capacity, every deletion leaves garbage behind and returns nothing. Insert and delete for long enough and the table ought to swell without limit.

It ought not to, and here is why. The dk_usable capacity does leak away, but when it reaches zero the next insertion triggers a rebuild, and the size of the new table is computed from the number of live keys: GROWTH_RATE(mp) is ma_used * 3, and ma_used counts only the live ones. Dead entries and tombstones do not enter that calculation and simply disappear during the copy. So the size of the table is determined by how many keys the dictionary holds now, not by how many operations were performed on it.

Fig. 4 · A long cycle of insertions and deletionsFRAME 00 / 13
EMPTY DICT
d = {}
ma_used
0
dk_size
1
dk_usable
0
dk_nentries
0
getsizeof
64 B
dk_indices · 1 slottombstones (−2): 0
0
−1
dk_entries · filled partholes from deleted keys: 0
#
me_hash
me_key
me_value
— array not allocated yet —
list(d) → []
CPython 3.13.13 · measured with ctypes
Text equivalent · frame 00

An empty dict owns no table at all: ma_keys points at the shared empty keys object, so sys.getsizeof(d) is 64 bytes and neither dk_indices nor dk_entries has been allocated yet.

Three things follow from this, each of them easy to check for yourself.

First: under a constant insert-then-delete cycle the dictionary does not grow. A million insert/delete pairs with a hundred live keys change the size exactly once, right at the start:

churn.py
import sys
 
d = {i: i for i in range(100)}
prev, changes = sys.getsizeof(d), []
for i in range(1_000_000):
    d[("k", i)] = i
    del d[("k", i)]
    if sys.getsizeof(d) != prev:
        changes.append((i, prev, sys.getsizeof(d)))
        prev = sys.getsizeof(d)
 
print(len(d), changes)   # 100 [(70, 4688, 9304)]
print(sys.getsizeof(d))  # 9304 — and constant from here on

Second: the table can shrink as well. A dictionary of a thousand keys with ten of them left shrinks almost sixtyfold at the very first rebuild:

shrink.py
import sys
 
d = {i: i for i in range(1000)}
for i in range(990):
    del d[i]
print(sys.getsizeof(d))      # 36952 — the deletions returned nothing
 
for n in range(1, 400):      # insert/delete cycle: still 10 live keys
    d[("n", n)] = n
    del d[("n", n)]
print(sys.getsizeof(d))      # 632

Third: deletions on their own do not free memory. A rebuild happens only on insertion, so a dictionary that has been emptied and is written to no more keeps holding the old table:

no_shrink.py
import sys
 
d = {i: i for i in range(1000)}
for i in range(1000):
    del d[i]
print(len(d), sys.getsizeof(d))   # 0 36952 — empty, but the memory is held
 
d.clear()
print(sys.getsizeof(d))           # 64 — now it has been released

There is exactly one practical conclusion: if almost everything has been deleted from a large dictionary that will go on living for a long time, and no insertions into it are expected — call clear() or build a new dictionary. In every other case the mechanism cleans up after itself.

The frames in Fig. 3 and Fig. 4 are not a model but a measurement: the state of a real dictionary, read straight out of the interpreter's memory. The script below reproduces it in full (ctypes here reads private CPython structures — fine for taking a look, categorically unfit for working code).

dict_state.py
import ctypes
import sys
 
class DictKeys(ctypes.Structure):
    _fields_ = [("dk_refcnt", ctypes.c_ssize_t),
                ("dk_log2_size", ctypes.c_uint8),
                ("dk_log2_index_bytes", ctypes.c_uint8),
                ("dk_kind", ctypes.c_uint8),
                ("dk_version", ctypes.c_uint32),
                ("dk_usable", ctypes.c_ssize_t),
                ("dk_nentries", ctypes.c_ssize_t)]
 
class Dict(ctypes.Structure):
    _fields_ = [("ob_refcnt", ctypes.c_ssize_t),
                ("ob_type", ctypes.c_void_p),
                ("ma_used", ctypes.c_ssize_t),
                ("ma_version_tag", ctypes.c_uint64),
                ("ma_keys", ctypes.POINTER(DictKeys)),
                ("ma_values", ctypes.c_void_p)]
 
def state(d):
    obj = Dict.from_address(id(d))
    keys = obj.ma_keys.contents
    size = 1 << keys.dk_log2_size
    base = ctypes.addressof(keys) + ctypes.sizeof(DictKeys)
    slots = list((ctypes.c_int8 * size).from_address(base))
    return (f"used={obj.ma_used} size={size} usable={keys.dk_usable} "
            f"nentries={keys.dk_nentries} bytes={sys.getsizeof(d)}\n  {slots}")
 
d = {1: 1, 2: 2, 3: 3}
print("three inserts", state(d))
d[1] = 100
print("update       ", state(d))   # the counters did not change
del d[2]
print("delete       ", state(d))   # slot 2 now holds -2, usable unchanged
d[2] = 22
print("insert back  ", state(d), list(d))   # [1, 3, 2] — the key moved to the end

Under the hood: version history

The most often misremembered fact about dictionaries is when insertion order became something you could rely on. That is not one event but two, exactly one release apart:

VersionChangeOrdering status
3.5A sparse array of entries: (hash, key, value) sit directly in the slots. Iteration order follows the hash slots.Arbitrary
3.6The compact dictionary (bpo-27350, INADA Naoki, on an idea by Raymond Hettinger): separate dk_indices and dk_entries, 20–25% less memory.Ordered — implementation detail
3.7Insertion-order preservation declared part of the language specification.Ordered — language guarantee
3.11Dictionaries with nothing but string keys stopped storing hashes: sys.getsizeof(dict.fromkeys("abcdefg")) is 272 bytes instead of 352 (bpo-46845).Same guarantee
3.13The experimental build without the GIL (PEP 703). The dictionary layout is unchanged in it.Same guarantee

What here is a language guarantee and what is an implementation detail

Two levels are worth keeping apart:

  • Language guarantee (safe to rely on in production): dictionaries preserve insertion order — since Python 3.7 this is part of the specification, not a side effect (see the 3.7 changelog). Whatever the key type — int, str, any hashable — dict[key] is amortized O(1).
  • CPython implementation detail (may change in another version, or in PyPy/GraalPy): the specific numbers 8 / ⅔ / ×3, the PERTURB_SHIFT = 5 formula, the split into dk_indices/dk_entries itself, the key-sharing dict of PEP 412, dropping stored hashes for string keys in 3.11. Code explicitly tied to those numbers (rather than to the dictionary's public behavior) is a red flag in code review.

Common misconceptions

Claim

“Dictionaries are ordered because CPython sorts the keys.”

Actually

Nothing is sorted. The order is the physical order of the dense dk_entries array, which is only ever written to at the end. Delete a key and add it again — it moves to the end; sorting would not do that.

Claim

“Insertion order has always been guaranteed by the language, at least since 3.6.”

Actually

In 3.6 this was a property of one particular implementation, CPython — the changelog asked outright that it not be relied upon (the quote above), and other interpreters were free to behave differently. Order became part of the language specification only in 3.7.

Claim

“A collision means a linked list, the way it works in a textbook hash table.”

Actually

CPython uses open addressing. On a collision it computes i = (i*5 + perturb + 1) & mask, mixing in the high bits of the full hash, so the probe sequences differ even for keys that landed in the same starting slot.

Claim

“Since hash("string") is deterministic inside a program, it can be compared across runs or saved to disk.”

Actually

The hash of str and bytes is SipHash with a seed that is random for every run of the process (PEP 456, PYTHONHASHSEED). Within one run it is stable; across runs it is, as a rule, different.

Knowledge check

Question 1 of 4

A dictionary with 5 keys has grown (resized) to 16 slots — without a single deletion along the way. What is true of the very next iteration over it?

Intermediatesoon
Hash tables: the general mechanics
Advancedsoon
Hashing and the __hash__ protocol
Expertsoon
Python sets from the inside

Sources & further reading

12 SOURCES

  1. Objects/dictobject.c — the dictionary implementationCPython source code. CPython sources at a pinned tag. Probing, USABLE_FRACTION, GROWTH_RATE and the design comments the whole article rests on. CPython tag 3.13.0.https://github.com/python/cpython/blob/v3.13.0/Objects/dictobject.c
  2. Include/internal/pycore_dict.h — the layout of the dictionary structsCPython source code. The definitions of PyDictKeyEntry and PyDictUnicodeEntry, the DKIX_* constants, the dk_kind enumeration and the rule for slot width in dk_indices. CPython tag 3.13.0.https://github.com/python/cpython/blob/v3.13.0/Include/internal/pycore_dict.h
  3. Objects/dict-common.h (CPython 3.5) — the dictionary before the compact layoutCPython source code. The _dictkeysobject struct with its dk_entries array, which was itself the sparse hash table. Needed in order to work out what the mandatory empty third cost before 3.6. CPython tag 3.5.0.https://github.com/python/cpython/blob/v3.5.0/Objects/dict-common.h
  4. Python/pyhash.c — the interpreter's hash functionsCPython source code. The convention that −1 is an error code and gets replaced by −2, and the SipHash implementation for str and bytes. CPython tag 3.13.0.https://github.com/python/cpython/blob/v3.13.0/Python/pyhash.c
  5. What's New In Python 3.6 — New dict implementationOfficial documentation. PSF release notes. This is where memory usage “between 20% and 25% smaller” is recorded, along with the direct caveat that the order is an implementation detail.https://docs.python.org/3/whatsnew/3.6.html
  6. What's New In Python 3.7 — “dict objects preserve insertion order”Official documentation. PSF release notes. The sentence that turned an implementation detail into a language guarantee.https://docs.python.org/3/whatsnew/3.7.html
  7. What's New In Python 3.11 — dictionary optimizationsOfficial documentation. Dictionaries stopped storing hashes when every key is a string: 352 → 272 bytes on a 64-bit build. Contributed by INADA Naoki, bpo-46845.https://docs.python.org/3/whatsnew/3.11.html
  8. What's New In Python 3.13 — the experimental build without the GILOfficial documentation. The free-threading mode is marked experimental; the dictionary layout is unchanged in it.https://docs.python.org/3/whatsnew/3.13.html
  9. sys.hash_info — hashing parametersOfficial documentation. The hash width, the Mersenne prime modulus and the string hashing algorithm chosen for a particular build.https://docs.python.org/3/library/sys.html#sys.hash_info
  10. PEP 412 — Key-Sharing DictionaryPEP. Mark Shannon, 2012. Split-table dictionaries, the reason instance dictionaries are cheaper than they look.https://peps.python.org/pep-0412/
  11. PEP 456 — Secure and interchangeable hash algorithmPEP. Christian Heimes, 2013. SipHash and a randomized seed as a defense against hash flooding.https://peps.python.org/pep-0456/
  12. python-dev: “More compact dictionaries with faster iteration”Mailing list. Raymond Hettinger's message, December 2012 — the layout idea that INADA Naoki implemented four years later. The 3.6 changelog links to it.https://mail.python.org/pipermail/python-dev/2012-December/123028.html