Store by key
ages = {} makes an empty dictionary. It will map keys (here, names) to values (ages). When the first key goes in, Python makes a table of 8 slots, often called buckets, numbered 0 to 7.
To store ann, Python first runs a hash function: it turns the key into a whole number. Our toy hash adds letter positions (a = 1, n = 14): 1 + 14 + 14 = 29.
The remainder after dividing by the table size picks the slot: 29 % 8 = 5 (% means remainder). So ann and its value 31 go straight into slot 5. No other slot is touched.
Collisions
ben hashes to 2 + 5 + 14 = 21, and 21 % 8 is also 5. Two keys want the same slot: a collision. Slot 5 already holds ann, so ben cannot go there.
Python uses open addressing: every key lives in the one table, so it tries other slots until one is free. Here it simply tries the next slot. Slot 6 is free, so ben and 27 go there.
Finding ben repeats the steps: hash 21, slot 5. That slot holds ann, not ben, so Python tries the next: found, 27. It checked two slots, not every key. On average a lookup takes the same short time however big the dict is: O(1).
Growing and rules
Three more names go in the same way: eve (32 % 8 = 0), bob (19 % 8 = 3) and amy (39 % 8 = 7). Now 5 of the 8 slots are full.
A crowded table means more collisions, so Python lets 8 slots hold only 5 keys. Adding zoe makes a new table of 16 and places every key again with % 16. Keys move: ann goes to slot 13.
list(ages) gives the keys in the order they were added: ann, ben, eve, bob, amy, zoe. That is not the order of the slots. Since Python 3.7 every dict keeps this insertion order.
A key's hash must never change, or Python would look in the wrong slot. Text and numbers cannot change, so they can be keys. A list can change, so ages[["ann"]] = 1 stops with a TypeError.
Try it in the Python prompt
;; step 1/10 · line 1: ages = {}
>>> ages = {}
>>> len(ages)
0
>>> type(ages)
<class 'dict'>
The figure uses a toy hash (add up letter positions) so you can check the sums. Python's real hash() for text gives a huge number, and it changes every time Python starts, so never rely on its value. On a collision the figure tries the next slot (called linear probing). CPython also uses open addressing, but its search jumps around the table in a fixed pattern based on the hash, which spreads crowded keys out. The sizes are CPython's: the first key added gets a table of 8 slots (an empty {} holds no table yet), and Python lets at most two thirds of them be used (5 of 8, 10 of 16). Adding the 6th key builds a table of 16.