Modules/Python/ Key and Value

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  # overwrite

Keys 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.

Python 3
Output

                            

The key that is not there

Square brackets raise on a missing key. get returns a default instead, which is often what you want.

Python 3
Output

                            

Looking things up

Keys are usually strings, but any immutable value works - a number or a tuple, never a list.
d["missing"] raises KeyError. d.get("missing") returns None, and d.get("missing", 0) returns your own fallback.
Assigning to a key that does not exist creates it. There is no separate "add" step.
Looping over a dictionary gives you the keys. Use .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" needed

defaultdict(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 values

Keys 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

  1. Trigger a KeyError. Index a key that does not exist, then do the same with get. One raises, the other returns None.
  2. Overwrite a key. Assign to a key that is already present and check the length. It does not grow — keys are unique.
  3. Check insertion order. Add several keys in a deliberate order and iterate. They come back in that order, not sorted.
  4. 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 in checks values. "apple" in prices tests keys; use 1.20 in prices.values() for values.
  • Modifying a dictionary while iterating it. RuntimeError: dictionary changed size during iteration. Iterate over list(prices.keys()) if you must delete while looping.
  • Reaching for try/except KeyError where .get() reads better — though try/except is right when a missing key is genuinely exceptional.
  • Copying with =. b = a gives two names for one dictionary; use a.copy(), or copy.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 key

Deep 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 exception

For 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 12

The 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) avoids KeyError; defaultdict and Counter remove 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.

Check yourself

0 of 3

Answer without scrolling back up.

  1. person = {"name": "Ada"}. What does person["age"] do?

  2. What does person.get("age", 0) return when there is no age key?

  3. Looping with `for x in person:` gives you:

Cheat sheet

Dictionaries

Keys 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.

PYTHON · vizlearn.in/python/dictionaries.html

Further reading

About the author

Ashish Jangra builds and maintains VizLearn. Every module here is written and the visualisation behind it hand-built, so the numbers in a readout come from the same code that draws the picture. Corrections are genuinely welcome and get priority over everything else — if a page states something wrong, or an animation misrepresents what the algorithm does, get in touch.