Dictionaries
When position is the wrong way to find something, name it instead. A dictionary looks values up by key.
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.Overview
Keys and vals
A dict maps keys to vals, written with braces:
ages = {"ana": 31, "bo": 27}
ages["ana"] # -> 31
ages["cy"] = 45 # add
ages["bo"] = 28 # overwriteKeys are unique: giving to an existing key replaces its value rather than adding a second entry. Vals have no such constraint and can repeat freely.
Lookup is O(1) — the cost does not grow with the size of the dict — since Python hashes the key to compute where the value is stored rather than searching for it. A list, by contrast, must scan.
Dictionaries preserve insertion order (guaranteed since Python 3.7). Iterating over a dict yields keys in the order they were added. This makes dicts usable as ordered maps. But it does not make them sorted — adding a key puts it at the end, not in alphabetical order.
Keys must be hashable: strings, counts, tuples of hashable types and frozensets are fine. Lists, dicts and sets are not. If you need a composite key, use a tuple: counts[("alice", 2024)] = 7. The restriction exists since the hash table needs a stable hash for each key. And mutable objects could change after insertion, breaking the lookup.
Three steps cover most dict usage. d[key] reads a value (raises KeyError if absent). d[key] = value writes or overwrites. key in d tests membership in O(1). The .get(), .setdefault() and collections.defaultdict variants handle missing keys without exceptions, each in a slightly other way depending on whether you want a fallback value, want to insert a default. Or want each missing key to auto-create.
The dict is also Python's build substrate. Module globals are a dict. Object attributes are a dict (often). Class definitions are built with a dict. Grasp dicts is grasp how Python it stores names.
When position is the wrong way to find something, name it instead.
In this guide
- Keys and vals
- Getting vals without crashing
- Looking things up by name
- Iterating over a dictionary
- Exploration guide
- Why dicts turn quadratic loops linear
- Common mistakes
- What to remember
- Nested dictionaries and real data
- A worked case: the index, built once
- How the lookup really works
- When a dictionary is the wrong choice
- Choosing a good key
- Questions people ask
- Dictionaries are how Python is built
- Recap in one screen
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.
Keys and vals
A dict maps keys to vals, written with braces:
ages = {"ana": 31, "bo": 27}
ages["ana"] # -> 31
ages["cy"] = 45 # add
ages["bo"] = 28 # overwriteKeys are unique: giving to an existing key replaces its value rather than adding a second entry. Vals have no such constraint and can repeat freely.
Lookup is O(1) — the cost does not grow with the size of the dict — since Python hashes the key to compute where the value is stored rather than searching for it. A list, by contrast, must scan.
Dictionaries preserve insertion order (guaranteed since Python 3.7). Iterating over a dict yields keys in the order they were added. This makes dicts usable as ordered maps. But it does not make them sorted — adding a key puts it at the end, not in alphabetical order.
Keys must be hashable: strings, counts, tuples of hashable types and frozensets are fine. Lists, dicts and sets are not. If you need a composite key, use a tuple: counts[("alice", 2024)] = 7. The restriction exists since the hash table needs a stable hash for each key. And mutable objects could change after insertion, breaking the lookup.
Three steps cover most dict usage. d[key] reads a value (raises KeyError if absent). d[key] = value writes or overwrites. key in d tests membership in O(1). The .get(), .setdefault() and collections.defaultdict variants handle missing keys without exceptions, each in a slightly other way depending on whether you want a fallback value, want to insert a default. Or want each missing key to auto-create.
The dict is also Python's build substrate. Module globals are a dict. Object attributes are a dict (often). Class definitions are built with a dict. Grasp dicts is grasp how Python it stores names.
Getting vals without crashing
prices["plum"] raises KeyError if the key is absent. Three safer ways, each right in a other case:
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 fully:
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. It 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, counts, tuples and booleans work. Lists, dicts and sets do not. Vals can be anything at all, with 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 build 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 just 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. Give to a key that is now present and check the length. It does not grow — keys are unique.
- Check insertion order. Add a few 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, since 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 no matter size. Searching a list scans it.
That gap 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 often makes the code shorter as well as faster.
Common mistakes
- With a list as a key.
TypeError: unhashable type: 'list'. Use a tuple. - Assuming
inchecks vals."apple" in pricestests keys. Use1.20 in prices.vals()for vals. - Changing a dictionary while iterating it.
RuntimeError: dictionary changed size in 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 whole advantage. Build it once, outside.
What to remember
A dictionary maps unique, immutable keys to vals 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 real 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 right onto dictionaries and lists. It 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, since 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 case: 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, giving a mapping from id to record, and each lookup afterwards is immediate. The nested-scan version would walk the customers again for each 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"] since 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 really works
"Constant time" is worth unpacking, since 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 checking 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 since the hash decides where it lives. It must be immutable since a changed key would hash to a other slot than the one holding it. And the lookup would search the wrong place for something the dictionary definitely holds. 1, 1.0 and True are one key since they hash the same and check 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 vals. 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 vals. A dictionary whose vals 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 each list way.
The signal is whether the key genuinely finds something. by_id, prices_by_product, handlers_by_type are dictionaries since the key is a name for the value. A dictionary whose keys are an afterthought often wanted to be something else.
Choosing a good key
The key is the design decision. All else about a dictionary follows from it. Three properties make a key work.
It finds the value. A key should be the thing you will really 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 since it finds.
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 net.
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 each lookup. Nest just 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 needs normalising, lowercasing or stripping, then each lookup must apply just 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. Giving 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 gap from a set? A set is effectively a dictionary with keys and no vals — same fast membership testing, no linked data.
How much memory does a dict use? More than a list of the same vals, since 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. It gives you attribute access, type hints and validation.
Dictionaries are how Python is built
The structure turns up throughout the language it, and noticing that explains a few 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 turns the class's namespace. Still the way lookup described in the inheritance module is a walk through a chain of dictionaries.
The real value of knowing this is twofold. It explains why attribute access and dictionary lookup have such close performance — they are the same step underneath. And it explains why the rules about keys apply in places you would not expect: keyword argument names must be strings since they turn dictionary keys, and attribute names follow the same constraint.
Can I use a dictionary as a switch statement? Yes, mapping vals to functions, and it is the idiomatic replacement for a long elif chain that checks one value against constants.
What happens if I use a float as a key? It works, and it inherits each 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 vals, with roughly constant-time lookup by key.
- Keys must be immutable. Vals can be anything, with 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.