Dictionaries
When position is the wrong way to find something, name it instead. A dictionary looks values up by key.
Overview
Keys and values
A dict maps keys to values, written with braces:
ages = {"ana": 31, "bo": 27}
ages["ana"] # -> 31
ages["cy"] = 45 # add
ages["bo"] = 28 # overwriteKeys are unique: assigning to an existing key replaces its value rather than adding a second entry. Values have no such constraint and can repeat freely.
Lookup is O(1) — the cost does not grow with the size of the dict — because Python hashes the key to compute where the value is stored rather than searching for it. A list, by contrast, must scan.
A value under a name
Curly braces, and each entry is key: value. You reach in with the key rather than a number.
The key that is not there
Square brackets raise on a missing key. get returns a default instead, which is often what you want.
Looking things up
d["missing"] raises KeyError. d.get("missing") returns None, and d.get("missing", 0) returns your own fallback..items() when you want key and value together.Dictionaries: A Practical Guide
A dictionary stores key-value pairs and finds any of them in constant time. It is the data structure Python itself is built out of, and the one that turns most quadratic loops into linear ones.
Getting values without crashing
prices["plum"] raises KeyError if the key is absent. Three safer ways, each right in a different situation:
prices.get("plum") # None if missing
prices.get("plum", 0) # a default of your choosing
prices.setdefault("plum", 0) # returns it, and inserts it if absent
if "plum" in prices: # explicit check
...For counting and grouping, the standard library has purpose-built tools that remove the boilerplate entirely:
from collections import Counter, defaultdict
Counter(words).most_common(3) # the three most frequent words
groups = defaultdict(list)
for user in users:
groups[user.country].append(user) # no "if key not in dict" neededdefaultdict(list) creates an empty list the first time a key is touched, which turns a four-line grouping pattern into one line.
Looking things up by name
A list finds things by position. A dictionary finds them by whatever key you choose — a name, an id, a date, any immutable value.
prices = {"apple": 1.20, "pear": 0.90, "fig": 3.50}
prices["apple"] # 1.20
prices["plum"] = 2.10 # add
prices["apple"] = 1.30 # update - same syntax, because the key exists
del prices["fig"] # remove
len(prices) # 3
"pear" in prices # True - checks keys, not valuesKeys must be hashable, which in practice means immutable: strings, numbers, tuples and booleans work; lists, dicts and sets do not. Values can be anything at all, including other dictionaries.
Since Python 3.7, dictionaries keep insertion order, so iterating gives you the keys in the order they were added. That is a language guarantee now, not an implementation detail.
Iterating over a dictionary
for key in prices: # keys by default
for key, value in prices.items(): # the usual choice
for value in prices.values():Building dictionaries with comprehensions works exactly as it does for lists:
lengths = {word: len(word) for word in words}
inverted = {value: key for key, value in original.items()}
filtered = {k: v for k, v in prices.items() if v > 1.00}Merging is a | in Python 3.9+, with the right-hand side winning on conflicts:
defaults = {"colour": "red", "size": "M"}
settings = defaults | user_choices
Exploration guide
- Trigger a KeyError. Index a key that does not exist, then do the same with get. One raises, the other returns None.
- Overwrite a key. Assign to a key that is already present and check the length. It does not grow — keys are unique.
- Check insertion order. Add several keys in a deliberate order and iterate. They come back in that order, not sorted.
- Try an unhashable key. Use a list as a key and read the TypeError. Keys must be immutable, because their hash decides where the value lives.
Why dicts turn quadratic loops linear
A dictionary lookup hashes the key and jumps straight to it, taking roughly constant time regardless of size. Searching a list scans it.
That difference decides how a program scales:
# slow: for each of 10,000 orders, scan 10,000 customers -> 100,000,000 steps
for order in orders:
for customer in customers:
if customer.id == order.customer_id:
...
# fast: build the index once, then one lookup each -> 20,000 steps
by_id = {c.id: c for c in customers}
for order in orders:
customer = by_id[order.customer_id]This single transformation — replacing a nested scan with a dictionary built beforehand — is the most common and most effective optimisation in everyday Python, and it usually makes the code shorter as well as faster.
Common mistakes
- Using a list as a key.
TypeError: unhashable type: 'list'. Use a tuple. - Assuming
inchecks values."apple" in pricestests keys; use1.20 in prices.values()for values. - Modifying a dictionary while iterating it.
RuntimeError: dictionary changed size during iteration. Iterate overlist(prices.keys())if you must delete while looping. - Reaching for
try/except KeyErrorwhere.get()reads better — thoughtry/exceptis right when a missing key is genuinely exceptional. - Copying with
=.b = agives two names for one dictionary; usea.copy(), orcopy.deepcopy(a)for nested structures. - Rebuilding the same lookup inside a loop, which throws away the entire advantage. Build it once, outside.
What to remember
A dictionary maps unique, immutable keys to values with O(1) lookup, insertion and deletion, and preserves insertion order. Use get when a key may reasonably be missing and indexing when it may not. Its biggest practical use is replacing repeated list scans: swapping an in list check for an in dict check is what turns an accidental O(n²) into O(n).
Nested dictionaries and real data
JSON maps directly onto dictionaries and lists, which is why they turn up constantly when working with APIs and config files.
data = {
"user": {"name": "Ada", "roles": ["admin", "dev"]},
"active": True,
}
data["user"]["name"] # 'Ada'
data["user"]["roles"][0] # 'admin'
data["user"].get("email", "none") # safe on a missing keyDeep access is where KeyError bites, because any level can be missing. Chaining .get() handles it:
email = data.get("user", {}).get("email") # None rather than an exceptionFor genuinely deep structures, a small helper or a library such as glom beats a chain of five .get({}) calls.
A worked example: the index, built once
The transformation described above, small enough to run in your head:
customers = [{"id": 1, "name": "ana"}, {"id": 2, "name": "bo"}]
orders = [{"customer_id": 2, "total": 30}, {"customer_id": 1, "total": 12}]
by_id = {c["id"]: c for c in customers}
for order in orders:
print(by_id[order["customer_id"]]["name"], order["total"])bo 30
ana 12The dict comprehension on the middle line is the whole optimisation. It walks the customers once, producing a mapping from id to record, and every lookup afterwards is immediate. The nested-scan version would walk the customers again for every order.
Two details are worth copying. The index is built outside the loop — rebuilding it inside would do more work than the scan it replaced. And the key is chosen to match what the other collection carries: c["id"] because orders hold customer_id. Picking the wrong key gives you a working dictionary and a KeyError on the first lookup, which is at least immediate.
If a missing customer is expected rather than a bug, by_id.get(...) and a check is the right shape. If it is a bug, the KeyError is doing you a favour.
How the lookup actually works
"Constant time" is worth unpacking, because it explains the rules about keys rather than leaving them as arbitrary restrictions.
When you write d[key], Python calls hash(key) to get an integer, and uses part of that integer to pick a slot in an internal table. It goes straight to that slot rather than searching. That is why the cost does not grow with the size of the dictionary: finding one entry among ten million takes the same arithmetic as finding one among ten.
Two keys can hash to the same slot — a collision — and Python handles it by probing nearby slots and comparing keys with == until it finds the right one or an empty space. Collisions are rare enough with a good hash that the average stays constant, and this is why keys need both __hash__ and __eq__ to agree: the hash finds the neighbourhood, equality confirms the match.
Now the rules follow. A key must be hashable because the hash decides where it lives. It must be immutable because a changed key would hash to a different slot than the one holding it, and the lookup would search the wrong place for something the dictionary definitely contains. 1, 1.0 and True are one key because they hash the same and compare equal.
The table also keeps spare capacity and grows when it fills, which is why a dictionary uses more memory than a list of the same values. The speed is what you are buying with it.
When a dictionary is the wrong choice
It is the default answer often enough that the exceptions are worth naming.
When the keys are fixed and known. A record with name, email and age is better as a dataclass. A dictionary lets a typo create a new key silently; an attribute raises. The dataclass also documents the shape, which a dictionary built in five places does not.
When you need order by value. Dictionaries keep insertion order, not sorted order. If the question is always "the top ten", something else — a sorted list, a heap — may fit better than sorting the dictionary each time.
When there are no values. A dictionary whose values are all True is a set wearing a costume, and set says what it means.
When the data is a sequence. Keying a dictionary by 0, 1, 2 to store positional data is a list written the long way, and it loses slicing, ordering and every list method.
The signal is whether the key genuinely identifies something. by_id, prices_by_product, handlers_by_type are dictionaries because the key is a name for the value. A dictionary whose keys are an afterthought usually wanted to be something else.
Choosing a good key
The key is the design decision; everything else about a dictionary follows from it. Three properties make a key work.
It identifies the value. A key should be the thing you will actually have in hand when you need the value. Indexing customers by name is convenient until two customers share one, and then the dictionary has quietly lost a record. An id exists precisely because it identifies.
It is stable. Keying by something that can change — an email address, a status, a position — means the entry is filed under a value that no longer describes it. Python enforces this for the object's hash and cannot enforce it for your data model.
It has the right granularity. When one value is not enough, a tuple is the standard multi-part key: rates[(currency, date)] rather than a dictionary of dictionaries. The tuple version is flatter, easier to iterate, and does not need a missing-level check on every lookup. Nest only when you genuinely want to work with a whole inner group at once.
The failure worth watching for is a key that is derived rather than given. If building the key requires normalising, lowercasing or stripping, then every lookup must apply exactly the same transformation, and eventually one of them will not. Normalise once, when the dictionary is built and where the key arrives, and keep the transformation in a single function that both sides call.
Questions people ask
Are dictionaries ordered? Yes, by insertion, guaranteed since Python 3.7.
Can two keys be equal? No. Assigning to an existing key replaces its value.
How do I sort a dictionary? dict(sorted(prices.items(), key=lambda kv: kv[1])) sorts by value; use kv[0] for keys.
What is the difference from a set? A set is effectively a dictionary with keys and no values — same fast membership testing, no associated data.
How much memory does a dict use? More than a list of the same values, because it stores hashes and keeps spare capacity. The lookup speed is what you are buying.
When should I use a dataclass instead? When the keys are fixed and known — a record with name, email and age is better as a dataclass, which gives you attribute access, type hints and validation.
Dictionaries are how Python is built
The structure turns up throughout the language itself, and noticing that explains several features that otherwise look unrelated.
An object's attributes live in a dictionary: obj.__dict__ is a real dictionary, and obj.x = 1 is an entry in it. That is why you can add attributes to most objects at runtime, and why __slots__ — which replaces the dictionary with a fixed array — saves memory and takes that ability away.
Module-level names live in one too. globals() returns the module's namespace as a dictionary, and so does locals() for the current scope. A module is, roughly, a dictionary with a name.
Keyword arguments arrive as one: **kwargs is a dictionary, built from the names at the call site. Class bodies are executed into a dictionary that then becomes the class's namespace. Even the method lookup described in the inheritance module is a walk through a chain of dictionaries.
The practical value of knowing this is twofold. It explains why attribute access and dictionary lookup have such similar performance — they are the same operation underneath. And it explains why the rules about keys apply in places you would not expect: keyword argument names must be strings because they become dictionary keys, and attribute names follow the same constraint.
Can I use a dictionary as a switch statement? Yes, mapping values to functions, and it is the idiomatic replacement for a long elif chain that compares one value against constants.
What happens if I use a float as a key? It works, and it inherits every floating-point comparison problem — 0.1 + 0.2 will not find the entry stored under 0.3.
Recap in one screen
- A dictionary maps keys to values, with roughly constant-time lookup by key.
- Keys must be immutable; values can be anything, including nested dicts and lists.
.get(key, default)avoidsKeyError;defaultdictandCounterremove common boilerplate..items()is the normal way to iterate over both parts.- Replacing a nested search loop with a pre-built dictionary is the classic Python speed-up.