Skip to content

Dictionaries

docs.scrimba.com

Lists let you look things up by position. But often you want to look something up by name. Not "give me item 3", but "give me the score for Alice". A dictionary stores data as key-value pairs: you look up a value by its key, not its position.

When a list's positional index is not meaningful, a dictionary is the right structure. Dicts map arbitrary keys to values, giving you named lookup in O(1) time. A leaderboard, a JSON response, a config file: all are naturally expressed as key-value mappings.

A dict is a key-value store with average O(1) lookup, insertion, and deletion (O(1) means the cost stays flat no matter how big the dict grows, because keys are found by hashing, turning the key into a number that points straight at its slot). Keys must be hashable, meaning their contents never change so that number stays stable: str, int, and tuple qualify, list does not. Values can be any object. Since Python 3.7, dicts keep keys in the order you inserted them, which matters whenever you serialise or display one. The same structure powers a lot of Python under the surface, including the attributes on your own objects, so dicts are worth knowing cold. The Lists chapter covers the position-based cousin.

Creating a dictionary

Curly braces with a colon between each key and value, and commas between pairs. Keys are almost always strings. Values can be anything: numbers, strings, other lists, even other dictionaries.

Dict literals use curly braces with key: value syntax. Keys can be any immutable (hashable) type: strings, integers, tuples. Values can be any Python object. Dicts preserve insertion order, so when you iterate, you get items in the order they were added.

Dict literals are evaluated left to right. Keys must be hashable (their contents can't change, so the number Python files them under stays put): str, int, and tuple qualify, list and dict do not. Values have no such restriction. Insertion order is part of the language guarantee as of Python 3.7, so iteration and serialisation are reproducible. One quiet trap: a duplicate key in a single literal does not raise, it keeps the last value and drops the earlier one.

python
player = {
    "name": "Alice",
    "score": 87,
    "level": 5,
    "alive": True,
}
JunoCreating a dictionary Curly braces, a colon between each key and its value, commas between pairs. Keys are usually strings, values can be anything, even another dictionary. Whatever order you add things in is the order you get them back, which I lean on more than I expected to.
JunoCreating a dictionary Curly braces with key: value pairs. Keys can be any immutable type (str, int, tuple), values can be any object at all. Iteration follows insertion order, so a dict is fine when order matters.
JunoCreating a dictionary Keys have to be hashable, so str, int, and tuple are in, list and dict are out. Insertion order is guaranteed since 3.7, so you can rely on it for output. Watch the duplicate-key literal: it takes the last value silently, no warning.

Accessing values

Use square brackets with the key to get the value. If the key does not exist, Python raises a KeyError. Use .get() when you are not sure a key is there: it returns None instead of crashing, or a default value you specify.

Square bracket access raises KeyError on a missing key. .get(key) returns None on a miss. .get(key, default) returns the default instead. Use .get() whenever the key's presence is uncertain; it is safer and more readable than wrapping access in a try/except.

Square-bracket access looks the key up by hashing it and reading the matching slot, which is O(1) on average (flat cost regardless of size). On a miss it raises KeyError. .get(key, default=None) does the same lookup but returns the default on a miss instead of raising, so it's the right call when a key's presence is uncertain. To guard before a bracket access, key in d is also O(1); pick in when you only need a yes-or-no, .get() when you want the value or a fallback in one step.

python
player = {"name": "Alice", "score": 87}

player["name"]    # "Alice"
player["score"]   # 87
player["lives"]   # KeyError (key doesn't exist)
python
player.get("score")          # 87
player.get("lives")          # None (no error, returns None by default)
player.get("lives", 3)       # 3   (use this default if key is absent)

.get() is safer whenever a key might be missing:

python
count = inventory.get("arrows", 0)   # 0 if "arrows" isn't in the dict
JunoAccessing valuesd["key"] hands you the value, but raises KeyError if that key isn't there. When you're not certain, reach for .get(): it returns None instead of crashing, or a default you pass in. I default-everything now and my programs stopped crashing on missing keys.
JunoAccessing values Bracket access raises KeyError on a miss, .get(key) returns None, and .get(key, default) returns whatever you pass. Use .get() when a key might be absent: cleaner than wrapping the access in a try/except.
JunoAccessing valuesd[key] and .get() both do one O(1) lookup, the difference is only what happens on a miss: raise versus return a default. Use key in d when you want a yes-or-no, .get() when you want the value-or-fallback in a single step.

Adding and updating

Assign to a key with square brackets. If the key already exists, the value is replaced. If it does not exist yet, a new entry is created. Use .update() to merge an entire other dictionary in at once.

Assigning to a key is O(1) average and does both jobs: it creates the entry if the key is new, replaces the value if it already exists. .update() accepts another dict or an iterable of key-value pairs and applies that same assignment for each entry, overwriting existing keys.

d[key] = value hashes the key and inserts or overwrites in one O(1) average step, so there's no separate "add" and "update": assignment does both. .update(other) is the same operation run for each entry in other, overwriting on any shared key. For merging without mutating the original, the | operator (Python 3.9+) returns a new dict and leaves both inputs untouched, while |= mutates in place like .update(). Reach for | when a function should not modify a dict its caller still holds.

python
player = {"name": "Alice", "score": 87}

player["score"] = 92        # update existing
player["level"] = 5         # add new key
python
extras = {"level": 5, "alive": True}
player.update(extras)   # adds/overwrites with keys from extras
JunoAdding and updating Assign to a key with square brackets: if it's already there the value gets replaced, if not a new entry appears. One syntax handles both, no need to check first. Use .update() to fold an entire other dictionary in at once.
JunoAdding and updatingd[key] = value creates or replaces in one move, so there's no distinct add versus update. .update(other) merges a whole dict in, overwriting any shared keys. Both are O(1) per key.
JunoAdding and updating Assignment inserts or overwrites in one O(1) step, and .update() is that repeated per entry. When you don't want to touch the original, | returns a fresh merged dict while |= mutates in place. Use | inside a function so you don't quietly change a dict the caller still holds.

Removing items

Four ways to remove entries. .pop() removes a key and gives you the value back. .pop() with a default is safe when the key might not be there. del removes a key with no return value. .clear() empties the whole dictionary.

.pop(key) raises KeyError on a miss. .pop(key, default) returns the default instead, making it the safe removal method. del d[key] removes the key with no return value and raises KeyError on a miss. .clear() removes all entries but keeps the dict object itself.

.pop(key, default) removes and returns in one O(1) average lookup, and the default makes it miss-safe; without it, a missing key raises KeyError. del d[key] removes with no return value and raises on a miss. .clear() empties the dict but keeps the same object, so any other name pointing at it sees an empty dict too. The failure mode to remember: mutating a dict while you iterate it raises RuntimeError mid-loop. The fix is to snapshot the keys you plan to remove first, with for key in list(d):, then remove from the original.

python
player = {"name": "Alice", "score": 87, "level": 5}

player.pop("level")            # removes "level" and returns 5
player.pop("lives", None)      # safe pop, returns None if key absent
del player["score"]            # removes "score", no return value
player.clear()                 # removes everything

.pop() with a default is the safest way to remove a key that might not exist.

JunoRemoving items.pop(key) removes a key and hands you its value back, and .pop(key, None) stays calm when the key might not be there. del d[key] removes without returning anything, .clear() empties the lot. The default on .pop() saved me from a lot of KeyError surprises.
JunoRemoving items.pop(key) raises on a miss, .pop(key, default) doesn't, which makes it the safe choice. del d[key] removes with no return and raises on a miss; .clear() empties the dict but keeps the object itself.
JunoRemoving items.pop(key, default) is your miss-safe remove-and-return; del and bracket-pop raise on a missing key. The one that bites: removing while iterating throws RuntimeError. Snapshot with for key in list(d): first, then delete from the original.

Iterating

Three views let you loop through different parts of a dictionary. Iterating the dict directly gives you keys. .values() gives values. .items() gives both at once and is what you will use most: unpack each pair into two names for clean, readable loops.

.keys(), .values(), and .items() return view objects, not lists. Views reflect the dict's current state dynamically: if you modify the dict, the view updates immediately. .items() is the most useful for most loops because tuple unpacking for k, v in d.items() reads clearly.

.keys(), .values(), and .items() return view objects (live windows onto the dict, not separate lists). A view copies nothing and reflects the dict's current state, so if the dict changes, the view changes with it. Because keys are unique and hashable, the keys view supports set algebra: d.keys() & other.keys() finds shared keys, - finds the difference, which is handy for diffing two configs. The same mutate-during-iteration trap applies here; if you must change the dict while looping, iterate over a snapshot with list(d.items()).

python
player = {"name": "Alice", "score": 87, "level": 5}

for key in player:               # iterate keys (most common)
    print(key)

for key in player.keys():        # same, explicit keys view
    print(key)

for value in player.values():    # the values
    print(value)

for key, value in player.items():   # both, most useful
    print(f"{key}: {value}")

.items() is what you will use most. Unpacking each pair into two names makes the loop readable.

JunoIterating Loop a dict directly and you get its keys. .values() gives the values, and .items() gives both at once, which is the one you'll reach for most. Unpacking each pair into two names like for key, value in player.items() made my loops so much easier to read.
JunoIterating.keys(), .values(), and .items() return live views, not lists, so they track the dict as it changes. .items() with for k, v in d.items() reads cleanest and is the loop you'll write most often.
JunoIterating The three views are live windows, not copies, so they reflect the dict as it changes. The keys view does set algebra, so d.keys() & other.keys() diffs two dicts in a line. Same rule as removal: don't mutate while iterating, snapshot with list(d.items()) if you must.

Checking membership

in checks whether a key exists in the dictionary. It does not check values, only keys. To check whether something is not present, use not in.

in and not in are O(1) for dicts, and they check keys only. To check values, you would use in d.values(), but that is O(n) since values are not indexed.

key in d finds the key by hashing it and reading one slot: O(1) average, flat no matter how big the dict is. value in d.values() has to walk the values one by one: O(n), cost grows with size. That asymmetry is the whole reason to store what you look up as keys rather than scanning values: if you find yourself searching values often, you probably want the dict keyed the other way around, or a second dict mapping value back to key.

python
player = {"name": "Alice", "score": 87}

"name"  in player      # True
"lives" in player      # False
"lives" not in player  # True

in only checks keys. To check values, use in player.values(), though that is rarely needed.

JunoChecking membershipin tells you whether a key is in the dict, and only a key, never a value. It stays fast no matter how big the dict gets. Flip it to not in to check that something is absent.
JunoChecking membershipin and not in check keys only, in O(1). Checking a value needs value in d.values(), which is O(n) because values aren't indexed. So design around looking things up by key.
JunoChecking membershipkey in d is O(1), value in d.values() is O(n). That gap is the reason to key a dict on whatever you search by. If you keep scanning values, you've got the dict the wrong way round; add a reverse map instead.

Nested dictionaries

Values can be dictionaries themselves. This is how you represent structured data with multiple levels: a player that has a stats section, a config file with sub-sections. Two sets of square brackets access a nested value: the first picks the outer key, the second picks the inner key.

Nested dicts are dicts where the values are themselves dicts. Access with chained subscripts. Mutating an inner dict affects the outer dict because the outer dict holds a reference to the same object. Keep nesting shallow where possible: deep nesting quickly becomes hard to read and navigate.

A nested dict holds references to the inner dicts, not copies of them (a reference is a pointer to one shared object, so two names can point at the same inner dict). This is where copying bites: d.copy() is a shallow copy, it duplicates the outer dict but the inner dicts are still shared, so changing one through the copy changes it in the original too. When you need the copy fully independent, copy.deepcopy() walks the whole tree and duplicates every level. Reach for it before you hand a config to code that might mutate it. Each bracket step is its own O(1) lookup, so depth costs nothing in speed, only in readability.

python
users = {
    "alice": {"score": 87, "level": 5},
    "bob": {"score": 74, "level": 3},
}

users["alice"]["score"]   # 87
users["bob"]["level"]     # 3

Access with chained square brackets. For deeply nested structures, this can get unwieldy, so keep nesting shallow where you can.

JunoNested dictionaries A value can be a whole dictionary of its own, which is how you hold structured data like a player with a stats section. Two sets of brackets reach in: the first picks the outer key, the second picks from inside it. Handy, but I try not to nest too deep or it gets fiddly to read.
JunoNested dictionaries Nested dicts are dicts whose values are dicts; reach in with chained brackets. The outer dict holds a reference to the inner one, so changing the inner dict shows up everywhere it's referenced. Keep nesting shallow or it gets hard to navigate.
JunoNested dictionaries The outer dict stores references to the inner dicts, so .copy() is shallow: the copy shares those inner dicts and a mutation leaks across both. Use copy.deepcopy() when you need real independence. Depth is free on speed, costly on readability, so stay shallow.

setdefault

.setdefault() reads a key if it exists, or sets it to a default value if it does not, then returns the value. It is useful when you need a key to exist but do not want to overwrite it if it is already there.

.setdefault(key, default) is a read-or-create in one call: if the key exists, return its current value without changing anything; if it does not, insert the default and return it. The common use case is building up grouped structures without a separate existence check.

.setdefault(key, default) is one O(1) lookup that reads-or-creates: present keys return their existing value untouched, absent keys get default inserted and returned. The catch worth knowing: default is an ordinary argument, so it's always built even when the key already exists. If that default is expensive to construct (a fresh object, a function call), you pay for it every call. defaultdict, covered next, sidesteps this by running a factory only on a miss. For the everyday "group items into lists" pattern, .setdefault(key, []).append(...) is the standard one-liner that replaces a key in d check.

python
inventory = {}

inventory.setdefault("arrows", 0)    # sets "arrows": 0, returns 0
inventory.setdefault("arrows", 10)   # "arrows" already exists, no change, returns 0

It is useful for building up grouped structures without checking for key existence first:

python
groups = {}

for name, team in players:
    groups.setdefault(team, []).append(name)
Junosetdefault.setdefault(key, default) reads a key if it's there, or sets it to the default and returns it if it isn't. It's the tidy way to make sure a key exists without clobbering a value that's already in. I use it most for grouping things, no "is this key here yet?" check needed.
Junosetdefault.setdefault(key, default) returns an existing value unchanged, or inserts the default and returns it on a miss. The payoff is grouping: d.setdefault(k, []).append(x) builds lists per key without a separate existence check.
Junosetdefault One O(1) read-or-create, but the default is always built even on a hit, so an expensive default costs you every call. That's exactly what defaultdict's on-miss factory fixes. For grouping, .setdefault(k, []).append(x) is the one-liner that retires the key in d check.

collections.defaultdict and Counter

The standard library has two dict subclasses that handle common patterns automatically. defaultdict creates a default value for missing keys so you never get a KeyError. Counter counts how often each item appears in a sequence and gives you the results as a dict.

defaultdict takes a callable that produces the default value for new keys, eliminating the need for .setdefault(). Counter is a specialised dict for frequency counting with a .most_common() method. Both are dict subclasses, so all standard dict operations work on them.

defaultdict(factory) runs the factory (a zero-argument callable, like list or int) the first time you read a missing key, stores the result, and returns it, so d[k] never raises KeyError. That's the key difference from .setdefault(): the factory only fires on a miss, so an expensive default costs nothing on the common path. One gotcha to flag: because a plain read of a missing key creates it, merely checking d[k] can grow the dict, so use k in d when you only want to test, not insert. Counter is a dict built for tallying: feed it a sequence and it counts occurrences, and .most_common(n) returns the top n by count. Both live in the collections module.

defaultdict and Counter live in the standard library, so they need importing first. Imports get full treatment in the Modules chapter.

python
from collections import defaultdict

groups = defaultdict(list)
for name, team in players:
    groups[team].append(name)   # no KeyError if team is new
python
from collections import Counter

words = ["cat", "dog", "cat", "bird", "cat", "dog"]
counts = Counter(words)
# Counter({'cat': 3, 'dog': 2, 'bird': 1})

counts.most_common(2)   # [('cat', 3), ('dog', 2)]

Counter saves a lot of "count things in a loop" boilerplate.

Junocollections.defaultdict and Counterdefaultdict fills in a default for any missing key, so grouping and counting stop throwing KeyError at you. Counter tallies how often each item shows up in a sequence and hands it back as a dict. The first time I swapped a hand-written counting loop for Counter, half my code vanished.
Junocollections.defaultdict and Counterdefaultdict(list) or defaultdict(int) auto-creates the default on a new key, retiring .setdefault() for grouping and counting. Counter is a dict tuned for frequencies, with .most_common(n) built in. Both are dict subclasses, so everything you know still works.
Junocollections.defaultdict and Counterdefaultdict's factory runs only on a miss, so unlike .setdefault() an expensive default costs nothing on the common path. The trap: reading a missing key creates it, so test with k in d, not d[k]. Counter tallies a sequence and .most_common(n) pulls the top n.

In practice

Building a score tracker and printing a summary with all entries:

python
scores = {"Alice": 87, "Bob": 74, "Carol": 92, "Dave": 55}

total = sum(scores.values())
average = total / len(scores)

print(f"Players:  {len(scores)}")
print(f"Average:  {average:.1f}")
print(f"Highest:  {max(scores.values())}")
print(f"Lowest:   {min(scores.values())}")
print()

for name, score in scores.items():
    print(f"  {name}: {score}")

Building a dict of per-file results in a loop, then summarising across all entries:

python
job_results = {}
files = ["report_jan.csv", "report_feb.csv", "report_mar.csv"]

for filename in files:
    size = len(filename) * 100   # placeholder for real file size
    if size < 2000:
        status = "ok"
    else:
        status = "large"
    job_results[filename] = {"size": size, "status": status}

ok_count = 0
large_count = 0

for result in job_results.values():
    if result["status"] == "ok":
        ok_count += 1
    else:
        large_count += 1

print(f"Processed {len(job_results)} file(s): {ok_count} ok, {large_count} large")

Validating a nested request dict by iterating required fields, then normalising a feature importance dict in place:

python
request = {
    "method": "POST",
    "path": "/users",
    "headers": {"Content-Type": "application/json"},
    "body": {"username": "alice", "email": "alice@example.com"},
}

body = request["body"]
errors = []

for field in ["username", "email"]:
    if not body.get(field):
        errors.append(f"Missing required field: {field}")

if "email" in body and "@" not in body["email"]:
    errors.append("Invalid email format")

print(f"Method: {request['method']} {request['path']}")
if errors:
    print(f"Errors: {errors}")
else:
    print("Validation passed")

# Normalise feature importance values to sum to 1
feature_importance = {"age": 0.34, "income": 0.28, "region": 0.15, "purchases": 0.23}
total = sum(feature_importance.values())

for key in feature_importance:
    feature_importance[key] = round(feature_importance[key] / total, 3)

print(f"Normalised: {feature_importance}")