CPython's dict Implementation in C: Combined Tables, Split Keys, and the Probe Sequence
A guided tour of CPython 3.15's dict implementation: PyDictObject, PyDictKeysObject, open addressing with perturb, combined vs split tables, shared keys, resizing at 2/3 load, insertion order, and free-threaded safety.
TL;DR: CPython’s
dictis a hash table split into aPyDictObjectheader and aPyDictKeysObjectthat holds the actual table. It uses open addressing with a perturb-based probe sequence, resizes at 2/3 load, preserves insertion order, and can share keys across instances (split tables) — a huge win for object__dict__s. Here’s how it all fits together in CPython 3.15.
Python’s dict is a hash table with a twist: since 3.6 it’s compact and preserves insertion order, and since 3.3 a single keys object can be shared by many instances of the same class. In CPython this lives in Objects/dictobject.c. Let’s trace the representation and the hot paths through the 3.15 source.
The object struct
A dict is split into two pieces: the PyDictObject header and a PyDictKeysObject that holds the actual hash table:
// Include/cpython/dictobject.h
typedef struct {
PyObject_HEAD
Py_ssize_t ma_used; /* number of items */
uint64_t _ma_watcher_tag; /* watcher bits + mutation counter + unique id */
PyDictKeysObject *ma_keys; /* the hash table */
PyDictValues *ma_values; /* NULL => combined table */
} PyDictObject;ma_values is the switch between the two table kinds. When it’s NULL, the dict is combined: keys and values both live inside ma_keys. When it’s non-NULL, the dict is split: keys live in the shared ma_keys, values in a per-instance ma_values array.
PyDictKeysObject
The keys object is the hash table. Its header is followed in memory by dk_indices (the probe array) and then dk_entries:
// Include/internal/pycore_dict.h
struct _dictkeysobject {
Py_ssize_t dk_refcnt;
uint8_t dk_log2_size; /* log2(table size), power of two */
uint8_t dk_log2_index_bytes; /* 1/2/4/8 byte indices */
uint8_t dk_kind; /* GENERAL / UNICODE / SPLIT */
uint8_t ... (mutex on free-threaded builds)
uint32_t dk_version;
Py_ssize_t dk_usable; /* free slots left */
Py_ssize_t dk_nentries; /* used entries in dk_entries */
char dk_indices[]; /* the hash table */
/* dk_entries[] follows */
};dk_kind distinguishes general tables (arbitrary keys, PyDictKeyEntry with cached hash) from Unicode-keyed tables (PyDictUnicodeEntry, no cached hash needed because Unicode objects cache their own hash). The third kind, DICT_KEYS_SPLIT, is the shared-keys case.
Two entry types:
// Include/internal/pycore_dict.h
typedef struct {
Py_hash_t me_hash;
PyObject *me_key;
PyObject *me_value; /* meaningful only for combined tables */
} PyDictKeyEntry;
typedef struct {
PyObject *me_key; /* must be exact Unicode with cached hash */
PyObject *me_value; /* meaningful only for combined tables */
} PyDictUnicodeEntry;Combined vs. split tables
Combined table (ma_values == NULL): each entry stores key, hash, and value together in dk_entries. dk_refcnt == 1. This is the default for ordinary dicts.
Split table (ma_values != NULL): keys are stored once in a shared PyDictKeysObject (marked DICT_KEYS_SPLIT), values in a per-instance PyDictValues. Only Unicode keys are allowed. This is the optimization for instance __dict__: thousands of objects of the same class share one keys table, and each instance only pays for its own value array.
// Include/internal/pycore_dict.h
#define _PyDict_HasSplitTable(d) ((d)->ma_values != NULL)Memory layout
PyDictObject (GC-tracked)
+---------------------------+
| ob_refcnt / ob_type |
+---------------------------+
| ma_used |
+---------------------------+
| _ma_watcher_tag |
+---------------------------+
| *ma_keys ----------------+--> PyDictKeysObject (PyMem)
| *ma_values (or NULL) | +---------------------------+
+---------------------------+ | dk_refcnt |
| dk_log2_size |
| dk_log2_index_bytes |
| dk_kind |
| dk_version |
| dk_usable |
| dk_nentries |
+---------------------------+
| dk_indices[] | <- hash table
| int8/16/32/64 per slot |
+---------------------------+
| dk_entries[] | <- key/value rows
| (USABLE_FRACTION(size)) |
+---------------------------+
If split:
PyDictValues (PyMem)
+---------------------------+
| capacity / size / embedded / valid |
+---------------------------+
| values[capacity] |
+---------------------------+
| insertion-order byte array |
+---------------------------+The index array uses the smallest integer width that fits the table size (1 byte up to 128 slots, 2 bytes up to 2^15, etc.), saving memory for the common small dictionaries.
Hash probing
The hash table is open-addressed with the classic CPython probe sequence. The initial slot is hash & mask (mask = size-1, size is a power of two). On collision:
// Objects/dictobject.c (do_lookup)
size_t i = (size_t)hash & mask;
for (;;) {
ix = dictkeys_get_index(dk, i);
if (ix >= 0) { ... compare ... }
else if (ix == DKIX_EMPTY) return DKIX_EMPTY;
perturb >>= PERTURB_SHIFT; /* PERTURB_SHIFT == 5 */
i = mask & (i*5 + perturb + 1);
}DKIX_EMPTY is -1, DKIX_DUMMY is -2. A dummy slot is a tombstone: it can’t be turned back into “empty” without breaking probe chains, so deletions leave dummies behind. Empty slots terminate a search. The perturb variable folds the high bits of the hash into later probes, so even pathological key sets (e.g. i << 16) eventually spread out.
Deletion and tombstones
Deleting a key doesn’t remove its entry from dk_entries (which would break insertion order cheaply); instead the index slot is overwritten with DKIX_DUMMY (-2). The entry row is set to NULL key/value so its references can be dropped. Dummies can’t be reused in free-threaded builds (to keep lock-free reads safe), so deletion-heavy tables accumulate tombstones and are compacted by the next resize, which copies only live entries.
popitem() is the special case: it removes the last entry outright, decrementing dk_nentries, giving amortized O(1) removal from the end while leaving a dummy behind in the index array.
Insertion path
PyDict_SetItem ultimately calls insertdict. For an empty dict it takes the fast insert_to_emptydict path; otherwise it looks up the key, and on a miss calls insert_combined_dict, which resizes if dk_usable is exhausted and then writes the new entry at the found slot:
// Objects/dictobject.c
if (old_value == NULL) {
if (insert_combined_dict(mp, hash, key, value) < 0) goto Fail;
STORE_USED(mp, mp->ma_used + 1);
}For split tables, insert_split_key records the key in the shared keys (if room remains) and _PyDict_InsertSplitValue stores the value in the instance’s slot; once the shared table is full the dict is converted to a combined table.
Load factor and resizing
The table is resized when it reaches two-thirds full:
// Objects/dictobject.c
#define USABLE_FRACTION(n) (((n) << 1)/3) /* 2/3 */dk_usable counts free slots; when it hits zero a resize is triggered. Growth size is used * 3 (so the table roughly doubles when there are few deletions, but has more headroom otherwise):
// Objects/dictobject.c
#define GROWTH_RATE(d) ((d)->ma_used*3)dictresize allocates a fresh PyDictKeysObject and re-inserts all entries, rebuilding indices with build_indices_generic / build_indices_unicode. After a resize the table is always combined, even if it was split before (split values are copied in and the old value array freed). Deletion-heavy tables may actually shrink.
Insertion order
Combined tables preserve order trivially: dk_entries is append-only, so iterating entries yields insertion order. The exception is popitem(), which removes the last entry and decrements dk_nentries, leaving a dummy in the index array.
Split tables record insertion order in a byte array stored after the value array:
// Include/internal/pycore_dict.h
static inline uint8_t *
get_insertion_order_array(PyDictValues *values) {
return (uint8_t *)&values->values[values->capacity];
}When a key is inserted, its entry index is appended to this byte array (_PyDictValues_AddToInsertionOrder). With capacity capped at SHARED_KEYS_MAX_SIZE (30), the shared-keys table is limited to 30 entries.
Shared keys (the split dict optimization)
When a class’s instances get attributes, CPython creates the instance __dict__ as a split dict sharing one keys object:
// Objects/dictobject.c
static PyObject *
new_dict_with_shared_keys(PyDictKeysObject *keys) {
size_t size = shared_keys_usable_size(keys);
PyDictValues *values = new_values(size);
...
dictkeys_incref(keys);
...
return new_dict(keys, values, 0, 1);
}ht_cached_keys on the type holds the shared keys. When an attribute is set, insert_split_key records the key in the shared table (if not already present) and _PyDict_InsertSplitValue stores the value in the instance’s value array. This turns per-instance dict memory from “full hash table” into “array of values” — a big win for objects with many attributes.
Iteration and views
dict.keys(), .values(), .items() return view objects that hold a reference to the underlying dict and iterate over dk_entries in order. Because the entries array is append-only for live inserts, iteration is O(n) with no hashing. Mutating the dict during iteration raises RuntimeError: dictionary changed size during iteration (detected via ma_used/dk_nentries changes).
Version numbers and watchers
Every keys object carries a dk_version (reset to 0 on any key change) and the dict carries a _ma_watcher_tag. The version lets the specializing interpreter and JIT cache “this dict has these keys” and invalidate cheaply when keys change. Watchers (registered via PyDict_AddWatcher) receive ADDED/MODIFIED/DELETED/CLONED/CLEARED/DEALLOCATED events, used by the adaptive optimizer and debuggers.
Inline values (managed dicts)
For small instance dicts, CPython can store values inside the object’s memory rather than in a separate PyDictValues allocation. The type’s tp_basicsize/tp_dictoffset machinery plus _PyInlineValuesSize computes a slot count from the shared keys’ usable size; a prefix byte array records which slots are populated and in what insertion order. _PyObject_MaterializeManagedDict converts such an inline dict into a real PyDictObject on demand (e.g. when __dict__ is accessed).
Free lists
Both PyDictObject and PyDictKeysObject are recycled:
// Objects/dictobject.c
PyDictObject *mp = _Py_FREELIST_POP(PyDictObject, dicts);
...
PyDictKeysObject *dk = _Py_FREELIST_POP_MEM(dictkeys); /* only minsize unicode */The dict object freelist and the keys freelist (only for the minimum-size Unicode case) avoid repeated PyMem_Malloc/PyObject_GC_New traffic. PyDict_Clear resets a dict to Py_EMPTY_KEYS rather than freeing it.
Allocation fast path for empty dicts
PyDict_New starts from the immortal Py_EMPTY_KEYS:
// Objects/dictobject.c
PyObject *PyDict_New(void) {
return new_dict(Py_EMPTY_KEYS, NULL, 0, 0);
}The first insertion into an empty dict (insert_to_emptydict) allocates a fresh minimum-size keys object and stores the single entry directly at hash & (MINSIZE-1), avoiding a full resize.
Frozendict
frozendict (new in 3.15) subclasses PyDictObject adding a cached ma_hash. It’s created untracked by the GC until finalized, and its keys object is immutable in practice: mutations are forbidden, so the hash can be computed once and cached:
// Include/internal/pycore_dict.h
typedef struct {
PyDictObject ob_base;
Py_hash_t ma_hash;
} PyFrozenDictObject;Free-threading notes
In Py_GIL_DISABLED builds, dk_indices updates use atomic stores (STORE_INDEX), split keys are protected by dk_mutex, and reads can proceed lock-free using consume/acquire loads. Dicts are marked “shared” on first cross-thread access so that resizes delay freeing old keys/values via QSBR, keeping concurrent readers safe.
Key takeaways
dictisPyObject_HEAD+ma_used, watcher tag,ma_keys, andma_values;ma_values == NULLmeans combined.- The hash table lives in
PyDictKeysObject: an index array (dk_indices) of int8/16/32/64 slots plus an append-only entry array (dk_entries). - Lookups use open addressing with probe
i = (5*i + 1 + perturb) & mask,perturb >>= 5. - Tables resize at 2/3 load; growth is
used*3; entries are re-inserted. - Split tables share keys across instances (instance
__dict__), storing values in a compact per-instance array with an insertion-order tail. - Insertion order comes from append-only entries (combined) or the order byte vector (split).
- Dicts and minimum-size Unicode keys are freelist-recycled.
Further reading
Objects/dictobject.c— all of the above.Include/cpython/dictobject.h—PyDictObject.Include/internal/pycore_dict.h—PyDictKeysObject, entries, macros.Objects/dictnotes.txt— design notes and tuning.