Dictionaries: Advanced
Instagram's web servers run Python. Suppose every request handler on one of those servers reads the same feature-flag dict. One handler sets flags["new_feed"] = True to try something, and every user that process serves gets the unfinished feed. Later the flags travel through JSON, and the integer experiment IDs used as keys come back as strings. Who may change a dict, and what survives the trip?
Every later reader of DEFAULTS now gets a 5-second timeout, and nothing in the error logs points back at build_request. You have 2 ways to stop it: give the function its own copy, or give it a view that refuses writes.
A view that refuses writes
The standard library's types module provides MappingProxyType. Wrapping a dict in it gives a read-only view. Every read still works: indexing and iteration, plus get, in, len, keys(), values() and items(). Any attempt to assign or delete raises TypeError at that line.
The mutating methods are missing from the view entirely. pop and update are gone, so are setdefault and clear, and calling any of them raises AttributeError. Either way the bad write fails at the line that attempted it, which is where you want to find the bug.
What happens when this program runs?
The view follows its dict
Read-only stops at the first level
- The callee can change its copy, and your dict stays as it was.
- A write in the callee goes unnoticed, so a logic bug there stays hidden.
- Each call pays for a full copy of the dict.
- Any write raises TypeError at the line that tried it.
- No copy is made, so wrapping a large dict costs almost nothing.
- The owner's later changes show through, and nested values stay writable.
What does this program print?
The dict saw 3 writes to 1 key. The first key object, the int 1, stays in place. Each later write found an equal key already stored and replaced only the value, so the last value, 'bool', wins. 1.0 and True land on the same key as 1 because they compare equal to it with == and produce the same hash.
Hash first, then equality
A dict decides whether 2 keys are the same in 2 steps. First it calls hash(key), a built-in that turns the key into an integer, and uses that integer to choose where in its internal table to look. Then it compares the key stored there with ==. Python guarantees that values which compare equal have equal hashes. Ints and floats keep that promise with each other, and so does bool:
The string "1" and the int 1 are unequal, so they stay 2 keys whatever their hashes. The animation below runs a simplified dict with 8 slots: put stores a key in the slot its hash picks, stepping right past slots that hold other keys, and get counts the slots it checks.
1slots = [None] * 823def put(key, value):4 i = hash(key) % 85 while slots[i] is not None:6 if slots[i][0] == key:7 slots[i][1] = value8 return9 i = (i + 1) % 810 slots[i] = [key, value]1112def get(key):13 i = hash(key) % 814 checks = 015 while slots[i] is not None:16 checks += 117 if slots[i][0] == key:18 return slots[i][1], checks19 i = (i + 1) % 820 return None, checks2122put(3, "red")23put(1, "one")24put(11, "blue")25put(1.0, "uno")26put(True, "yes")27print(slots)28print(get(11))29print(get(1))30print(get(19))
A simplified 8-slot table: the hash picks the slot, and == decides whether it holds the same key.
Key 11 hashed to slot 3, found 3 already there, and stepped on to slot 4, which is why get(11) needed 2 checks. Keys 1.0 and True both landed on slot 1, compared equal to the stored 1, and replaced its value while the key stayed 1. A miss such as get(19) stops at the first empty slot. A real dict also stores each key's hash and compares hashes before calling ==, so stepping past a slot that holds a different key rarely costs an == call.
Where equal keys meet in real data
The float row merged into the int key, which is what you want here. The string row became a separate entry, a silent split in the totals. Convert IDs to 1 type where data comes in, before they become keys; int() or str() will do it.
Floats add 2 more traps. float("nan") is unequal to itself, so a fresh NaN never matches a stored one, and 0.1 + 0.2 is a different float from 0.3. A dict finds a NaN key only when you look it up with the very same object, through an identity check Python runs before ==:
None before the data reaches a dict.What does this program print?
Your own classes as keys
An object of your own class equals only itself unless the class defines __eq__, and its default hash comes from its identity. To make 2 objects with the same field act as 1 key, define __eq__ and base __hash__ on the same fields it compares. A class that defines __eq__ alone gets __hash__ set to None, so its objects can no longer be keys:
- ▸If
a == b, thenhash(a) == hash(b). Break this and equal keys land in different slots and never meet. - ▸A key's hash must stay fixed while it sits in a dict, so build
__hash__only from fields you never reassign. - ▸Unequal keys may share a hash. The dict still works, at the price of extra
==checks to tell them apart.
x in some_list starts at index 0 and compares x with each item using == until 1 matches or the list runs out. A dict hashes x, jumps to the slot the hash points at, and compares only there. With the __eq__ method from the previous section, a class can count its own comparisons, which makes the difference visible:
The list pays for every item before the match, and a miss pays for all of them. The dict pays 1 == call for a hit and none for a miss, because no stored hash matched -5. Grow both to 1,000,000 items and the list counts grow with them while the dict counts stay put.
Build the lookup dict once, then reuse it
Membership checks speed up the same way. A list named seen that grows inside a loop turns every in into a longer scan, while a dict whose values you ignore keeps each check at 1 hash. dict.fromkeys(items) builds that dict in 1 call with None as every value, and since dicts keep insertion order, it also drops repeats while keeping first appearances in place:
What a dict does not speed up
Only lookups by key are fast. "Bo" in by_id.values() walks every value one by one, the same as a list. If you search by name often, build a second dict keyed by name.
- You look items up by an ID or name many times.
- You check membership inside a loop.
- You need the latest value per key, with repeats overwriting.
- You read items by position or slice them.
- Duplicates and their order are the data.
- You visit every item anyway, as in a sum or a filter.
- You check a few items once: building a dict costs a full pass first.
A dict uses more memory than a list of the same items: each entry stores the key and its value alongside the key's hash, and the table keeps spare empty slots so new keys find room.
flagged_ids is a list of 200,000 card IDs and payments holds 1,000,000 rows. Which change cuts the most work from this function?
JSON is a text format for data, and Python's json module converts between it and dicts. json.dumps(d) returns a string, and json.loads(text) parses a string back into Python values. Each JSON type has a Python counterpart:
| Python value | JSON text | After json.loads |
|---|---|---|
| dict | object {...} | dict |
| list | array [...] | list |
| str | "..." string | str |
| int / float | number | int / float |
| True / False | true / false | True / False |
| None | null | None |
| int, float, bool or None key | string key | str key |
The output is a str in JSON spelling, with lowercase true and null for None. Every string sits in double quotes. Loading it gives a dict equal to the original, because every value here has a JSON counterpart and every key was already a string.
Keys come back as strings
JSON object keys can only be strings, so dumps converts int and float keys to text. Bool and None keys get the same treatment, and loads has no record of what any of them were. A dict keyed by product ID changes shape on the round trip:
The loaded dict is unequal to the original, and a lookup by the int 101 misses. Convert the keys back where you load: {int(k): v for k, v in back.items()}. Worse, 2 different keys can turn into the same string. The equal-key collapse happens once inside the dict, then JSON merges what was left:
True merged into 1 inside the dict, leaving 2 keys. dumps then wrote both as "1", producing JSON with a repeated key, and loads kept the last value it read for it. The whole round trip ran without raising an error.
What does this program print?
Values JSON cannot hold
Dates and datetimes have no JSON type, and neither do bytes or objects of your own classes, so dumps raises TypeError at the first one it meets. Its default argument takes a function that dumps calls on each value it cannot write, and the function's return value is written in its place. Floats fail the other way: dumps writes NaN and infinity as NaN and Infinity, which strict JSON parsers in other languages reject, so pass allow_nan=False to fail at the source.
default=str keeps the program running, but loading that text gives back the string "2024-03-01", never a date. The code reading the JSON has to know which fields to convert back.
Stable, readable text
Equal dicts can produce different JSON text, because dumps writes keys in insertion order while dict equality ignores order. sort_keys=True fixes the order, which matters when you compare outputs as text or store them where a changed byte means a changed file. indent=2 lays the text out for people to read:
- ▸Keys that are not strings come back as strings. Convert them right after
loads. - ▸
1and"1"both become the same string, so they merge into 1 key on the way back. - ▸Dates and your own objects need a
defaultfunction, and come back as whatever it returned. - ▸NaN and infinity produce text strict parsers reject. Pass
allow_nan=Falseto catch them.
Changing size while looping
A dict iterator notices when the dict gains or loses keys and raises RuntimeError on its next step. The error arrives after the first deletion has already happened, so the dict is left half cleaned:
list(sessions) is a snapshot of the keys taken before any deletion, so the loop never walks the dict it changes. Building a new dict works too: {k: v for k, v in sessions.items() if v != 0}. Assigning a new value to an existing key keeps the size the same and is allowed mid-loop.
One list shared by every key
dict.fromkeys(keys, value) stores the same value object under every key. With an int or a string that is harmless. With a list or a dict, every key points at 1 shared object:
The comprehension evaluates [] once per key, so each team gets its own list.
What does this program print?
Falsy values that look missing
d.get(k) returns None for a missing key, so if not d.get(k) fires for a missing key. It also fires for a stored 0 or "", and for [] and False too. When a falsy value is real data, test for the key with in:
Updates keep a key's position
A key that changes after it is stored
A dict files each key under the hash it had when it went in. Reassign a field that __hash__ reads, and the key stays filed under its old hash. A lookup by the new value goes to the wrong slot, and a lookup by the old value finds a key that no longer compares equal:
- Loop over list(d) or build a new dict when the loop deletes keys
- Build per-key lists with {k: [] for k in keys}
- Test k in d when 0, "" or None are valid values
- Hash only fields that never change after the object becomes a key
- Delete or add keys while iterating over the dict itself
- Pass a list or dict as the value to dict.fromkeys
- Use if not d.get(k) to mean the key is absent
- Expect reassigning a key to move it to the end of the order
> A payments service loads its merchant risk limits from a JSON document at startup, keys them by merchant ID, and hands them to dozens of request handlers that check every incoming charge against them. A nightly job refreshes the limits and drops merchants that have closed.
int() as it loads, or every lookup by an int ID misses.101 and "101" from splitting 1 merchant into 2 entries with separate limits.MappingProxyType over the limits, so a handler that tries to adjust a limit fails at that line instead of changing it for every later charge.MappingProxyType(d) hands out a live, read-only view. Wrap a copy for a snapshot, and remember nested values stay writable.==, so 1.0 and True land on the same key as 1, while 1 and "1" stay 2 keys.__eq__ and __hash__ built from the same fields, and those fields must never change while the object is a key.in on a list grows with the list. Build the lookup dict once, outside the loop.sort_keys=True makes the text stable.fromkeys value, and test with in when falsy values are real data.Lock dicts against writes, predict which keys merge, look up fast, and get dicts through JSON intact.
- Category
- Python
- Difficulty
- advanced
- Duration
- 30 minutes
- Challenges
- 20 hands-on challenges
Topics covered: Read-Only Dictionaries, Keys That Compare Equal, Dictionary Performance, Converting Between Dicts and JSON, Dictionary Gotchas
Lesson Sections
- Read-Only Dictionaries
Passing a dict to a function hands over the same object you hold, so every write the function makes lands in your dict. A shared settings dict gets changed this way by code that only meant to read it: A view that refuses writes The view follows its dict A proxy reads through to the dict it wraps and stores nothing itself. The usual design keeps the real dict private to its owner, who can still change it, while every holder of the view sees the new value on its next read. For a snapshot that nobo
- Keys That Compare Equal (concepts: Collections, Frequency Counting)
Predict what this prints before you open the output: 3 keys, 3 different types. Hash first, then equality Where equal keys meet in real data The collapse appears when 2 sources describe the same ID in different types. Here one feed delivers user IDs as ints, another as floats, a third as text: Your own classes as keys
- Dictionary Performance
Build the lookup dict once, then reuse it The cost multiplies when a lookup sits inside a loop. Matching each of 50,000 orders to its customer by scanning a list of 20,000 customers repeats the scan for every order. Building a dict keyed by customer ID takes 1 pass over the customers, and after that each order costs 1 lookup. What a dict does not speed up A dict uses more memory than a list of the same items: each entry stores the key and its value alongside the key's hash, and the table keeps s
- Converting Between Dicts and JSON (concepts: Dictionaries, JSON Handling)
Keys come back as strings Values JSON cannot hold Stable, readable text
- Dictionary Gotchas (concepts: Dict Iteration)
Each trap below runs without an error on at least some inputs, which is how it survives into production code, so predict each output before you open it. Changing size while looping One list shared by every key Falsy values that look missing Updates keep a key's position Assigning to a key that already exists leaves it where it was. Deleting it and adding it back moves it to the end. Equality ignores order, so 2 dicts can be equal and still loop in different orders: A key that changes after it is