Python lesson 9 of 9
Python Dictionaries
Learn how Python dictionaries map keys to values, read them safely with .get(), add, update and delete entries, loop with .items(), and why lookups are fast.
Published · Every example on this page was run before it was published.
A list is great when the thing you care about is position — the first score, the last item, everything from index 2 onward. But a lot of real data has no meaningful position at all. If you store a person's phone number, you don't want to remember that it lives at index 47; you want to look it up by their name. When the natural way to find a value is by a label rather than by a slot number, you want a dictionary.
What is a Dictionary?
A dictionary is a collection that stores pairs: each key (the label you look something up by) points
to a value (the data stored under that label). Together one key and its value are called an
entry, or an item. Instead of asking "what's at position 3?", you ask "what's stored under
"Grace"?"
The real-world analogy is the object it's named after. In a paper dictionary you never read from page one looking for the word gravity — you jump straight to the G section and find the word, and next to it sits its definition. The word is the key, the definition is the value, and the whole point of the book's design is that finding an entry doesn't get slower just because the dictionary is thick. A phone contact list works the same way: you type a name, you get a number back.
Python writes a dictionary with curly braces {}, each entry written as key: value, and the entries
separated by commas:
phone_book = {"Ada": "555-0101", "Grace": "555-0142", "Linus": "555-0199"}
print(phone_book["Grace"])555-0142Notice what did not happen there: no loop, no index, no searching. You handed Python a key and got the matching value straight back. That one move — label in, value out — is what dictionaries exist to do, and every other feature in this lesson builds on it.
Creating Dictionaries and Reading Values
An empty dictionary is an empty pair of braces. Keys don't have to be strings and values can be any type at all, including numbers, booleans, and lists:
empty = {}
ages = {"Ana": 31, "Ben": 24}
settings = {"volume": 7, "muted": False, "profile": "dark"}
print(len(empty))
print(len(ages))
print(settings)0
2
{'volume': 7, 'muted': False, 'profile': 'dark'}len() on a dictionary counts entries, not keys and values separately — ages holds two entries, so
len(ages) is 2. Printing a dictionary shows it back in the same brace notation you typed, and in
modern Python (version 3.7 and later) entries stay in the order you inserted them, which is why
settings prints with volume first.
There are two ways to read a value out, and the difference between them matters more than almost anything else in this lesson.
The first is square brackets, the same syntax lists use — except you put a key inside instead of an
index. The second is the .get() method, which does the same lookup but hands back a fallback value
instead of crashing when the key isn't there:
stock = {"apples": 12, "pears": 4}
print(stock["apples"])
print(stock.get("pears"))
print(stock.get("plums"))
print(stock.get("plums", 0))12
4
None
0Look closely at the last two lines. stock.get("plums") asked for a key that doesn't exist and calmly
returned None — Python's built-in "no value here" object. stock.get("plums", 0) did the same lookup
but supplied a default: a second argument that .get() returns when the key is missing. Since a
missing fruit sensibly means "we have zero of those," 0 is a much more useful answer than None.
Square brackets behave very differently when the key isn't there. They raise a KeyError, which is
Python's way of saying "you asked for a key I don't have," and an unhandled error stops the program on
the spot:
stock = {"apples": 12, "pears": 4}
try:
print(stock["plums"])
except KeyError as error:
print("KeyError for key:", error)KeyError for key: 'plums'The try / except block above catches the error so the lesson can print it instead of crashing;
without it, the program would simply stop. So which should you use? Use square brackets when the key
must exist and its absence means something has genuinely gone wrong — you want the loud failure. Use
.get() with a sensible default whenever a missing key is a normal, expected situation, such as
counting things you haven't counted yet.
Adding, Updating, and Removing Entries
Dictionaries are mutable, meaning you can change their contents after creating them. Adding a new entry and updating an existing one use exactly the same syntax — assignment into a key. Python decides which one happens based on whether that key already exists:
scores = {"Ana": 10}
scores["Ben"] = 8
scores["Ana"] = 12
print(scores){'Ana': 12, 'Ben': 8}scores["Ben"] = 8 created a brand-new entry because "Ben" wasn't in the dictionary yet.
scores["Ana"] = 12 overwrote the existing value, replacing 10 with 12. A key can only appear once
in a dictionary, so assigning to an existing key never produces a duplicate — it always replaces. Note
also that "Ana" stayed in first position: updating a value doesn't move the entry.
To delete an entry, you have two options. The del statement removes it and gives you nothing back.
The .pop() method removes it and hands you the value it just removed, which is handy when you need to
use that value afterwards:
inventory = {"bolts": 40, "nuts": 25, "washers": 60}
del inventory["washers"]
print(inventory)
removed = inventory.pop("nuts")
print(removed)
print(inventory)
print(inventory.pop("screws", 0)){'bolts': 40, 'nuts': 25}
25
{'bolts': 40}
0.pop() accepts a default the same way .get() does, so inventory.pop("screws", 0) returned 0
rather than raising a KeyError for a part that was never in the inventory. Without that default, both
del and .pop() raise a KeyError on a missing key, just like square brackets do.
Before reaching for a key, you can simply ask whether it's there. The in operator answers that
question, and it's the most common guard you'll write around dictionary code:
permissions = {"read": True, "write": False}
print("read" in permissions)
print("delete" in permissions)
print(False in permissions)
print(False in permissions.values())True
False
False
TrueThat third line trips up beginners: in on a dictionary searches the keys only, never the values.
False in permissions is False because no key is named False, even though False is sitting right
there as the value of "write". If you genuinely want to search the values, say so explicitly with
.values(), as the last line does.
Looping Through a Dictionary
Looping over a dictionary directly gives you its keys, one at a time:
prices = {"tea": 3, "coffee": 5, "juice": 4}
for name in prices:
print(name)tea
coffee
juiceThree methods let you say precisely which part of each entry you want. .keys() gives the labels,
.values() gives the stored data, and .items() gives both together as pairs:
prices = {"tea": 3, "coffee": 5, "juice": 4}
print(list(prices.keys()))
print(list(prices.values()))
print(list(prices.items()))['tea', 'coffee', 'juice']
[3, 5, 4]
[('tea', 3), ('coffee', 5), ('juice', 4)]Each of those methods returns a special view object rather than a list. A view is a live window onto
the dictionary — it knows how to be looped over and how to answer in questions, but it isn't itself a
list, which is why the examples above wrap each one in list() just to display it. In a for loop you
never need that wrapper.
.items() is the one you'll use most, because it pairs naturally with unpacking two loop variables at
once — the first receives the key, the second receives the value:
prices = {"tea": 3, "coffee": 5, "juice": 4}
total = 0
for drink, price in prices.items():
print(f"{drink} costs {price}")
total += price
print("Total:", total)tea costs 3
coffee costs 5
juice costs 4
Total: 12Because .values() produces just the numbers, built-in functions that work on sequences work on it
directly — sum(), max(), and min() all accept it without any extra work:
prices = {"tea": 3, "coffee": 5, "juice": 4}
print(sum(prices.values()))
print(max(prices.values()))12
5What Can Legally Be a Key?
Values in a dictionary can be absolutely anything. Keys cannot — a key must be hashable.
Hashable means Python can run the value through a hash function: a calculation that turns the value into a fixed number, called its hash, which always comes out the same for the same value within a running program. Python uses that number to decide where in memory the entry gets filed, which is the trick that makes lookups fast (more on that in a moment). For that filing system to keep working, a key's hash must never change while it sits in the dictionary — and that means keys must be immutable, meaning they cannot be modified after they're created.
In practice, strings, integers, floats, booleans, and tuples of immutable things are all valid keys:
locations = {}
locations["home"] = "warm"
locations[7] = "lucky number"
locations[(40.7, -74.0)] = "New York City"
print(locations)
print(locations[(40.7, -74.0)]){'home': 'warm', 7: 'lucky number', (40.7, -74.0): 'New York City'}
New York CityThat tuple key is a genuinely useful pattern — a pair of coordinates, or a start and end city, is one
logical label made of two pieces, and a tuple lets you use the whole pair as a single key. You can check
hashability yourself with the built-in hash() function, which returns the same number for equal values:
print(hash("python") == hash("python"))
print(hash((1, 2)) == hash((1, 2)))True
TrueLists, on the other hand, are mutable, so they're unhashable and rejected outright:
try:
grouped = {["a", "b"]: "value"}
except TypeError as error:
print("TypeError:", error)TypeError: cannot use 'list' as a dict key (unhashable type: 'list')The reason isn't arbitrary. If a list could be a key, you could append to it after filing the entry, its hash would change, and Python would look in the wrong place and report that your own key had vanished. Forbidding it up front prevents that whole class of bug. The fix is to convert the list into a tuple, which holds the same values but can never change:
routes = {("Paris", "Rome"): 1106}
key = tuple(["Paris", "Rome"])
print(routes[key])1106Nesting Dictionaries
A dictionary value can be another dictionary. That's how you model something with structure — a record per person, where each record has its own named fields:
students = {
"ana": {"age": 21, "grades": [88, 92]},
"ben": {"age": 22, "grades": [75, 80]},
}
print(students["ana"]["age"])
print(students["ben"]["grades"][1])
students["ana"]["grades"].append(95)
print(students["ana"])21
80
{'age': 21, 'grades': [88, 92, 95]}Read those chained brackets strictly left to right. students["ana"] produces the inner dictionary
{'age': 21, 'grades': [88, 92]}, and the next ["age"] then looks up a key in that result. The
third line goes one step further: students["ben"]["grades"] produces a list, so [1] on the end is a
list index, not a key. Mixing dictionaries and lists like this is normal, and it's exactly the shape
data arrives in when a program reads settings files or web data.
The danger with nesting is that a single missing key anywhere in the chain raises a KeyError. Chaining
.get() with an empty dictionary as the default keeps the chain alive when a record doesn't exist:
students = {"ana": {"age": 21}}
print(students.get("cleo", {}).get("age", "unknown"))unknownstudents.get("cleo", {}) found no "cleo", so it returned an empty dictionary. The second .get()
call then runs on that empty dictionary, finds no "age" either, and returns its own fallback string.
No crash, and a readable answer.
Why Dictionary Lookups Are Fast
Here's the property that makes dictionaries central to problem solving, not just to storing records.
To check whether a value is in a list, Python has no choice but to walk the list and compare items one by one. If the list holds ten thousand numbers and the one you want sits at the end, that's ten thousand comparisons. Double the list and you double the work.
A dictionary doesn't search at all. It runs your key through the hash function, gets a number, and uses
that number to jump directly to the one place that entry could possibly be stored. This structure is
called a hash table. The critical consequence: the work of a single lookup barely changes whether
the dictionary holds ten entries or ten million. That's why in on a dictionary is described as taking
roughly constant time, while in on a list takes time proportional to the list's length.
That difference unlocks a pattern you'll use constantly: instead of re-scanning data you've already looked at, remember it in a dictionary as you go, then ask one instant question per new item. Here's the pattern finding duplicates in a single pass:
words = ["red", "blue", "red", "green", "blue", "red"]
seen = {}
duplicates = []
for word in words:
if word in seen:
duplicates.append(word)
else:
seen[word] = True
print(duplicates)
print(seen)['red', 'blue', 'red']
{'red': True, 'blue': True, 'green': True}The loop touches each word exactly once. For every word it asks the seen dictionary a single instant
question — have I met you before? — instead of rescanning the earlier part of the list. Note that
"red" appears twice in duplicates because it showed up two extra times, while seen holds each
distinct word only once, since assigning to an existing key just overwrites it.
This is precisely the idea behind the Two Number Sum practice problem linked at the end of this lesson. That problem hands you a list of integers and a target, and asks for the pair that adds up to the target. The slow approach compares every number against every other number. The fast approach scans the list once and, for each number, uses simple arithmetic to work out which partner value would complete the target — then asks a dictionary of already-seen numbers whether that partner has gone by already. Same shape as the loop above: one pass, one instant lookup per item.
A Worked Example
Let's combine .get() with a default, .items() iteration, and membership checking into one small
program that counts how often each word appears in a sentence and reports the winner.
def count_words(text):
counts = {}
for word in text.lower().split():
cleaned = word.strip(".,!?")
counts[cleaned] = counts.get(cleaned, 0) + 1
return counts
def most_common(counts):
best_word = ""
best_count = 0
for word, count in counts.items():
if count > best_count:
best_word, best_count = word, count
return best_word, best_count
line = "The cat sat on the mat, and the cat slept."
tally = count_words(line)
print(tally)
word, count = most_common(tally)
print(f"Most common: '{word}' appears {count} times")
print("Is 'dog' in the text?", "dog" in tally)
print("Times 'mat' appears:", tally.get("mat", 0)){'the': 3, 'cat': 2, 'sat': 1, 'on': 1, 'mat': 1, 'and': 1, 'slept': 1}
Most common: 'the' appears 3 times
Is 'dog' in the text? False
Times 'mat' appears: 1Walking through it: count_words starts with counts = {}, an empty dictionary that will grow one key
per distinct word. text.lower() makes the counting case-insensitive so "The" and "the" land on the
same key, and .split() breaks the sentence into a list of words at the spaces.
word.strip(".,!?") removes any of those four punctuation characters from both ends of the word. Without
it, "mat," and "mat" would be two different keys, since dictionary keys are compared exactly.
The single most important line is counts[cleaned] = counts.get(cleaned, 0) + 1. Read the right side
first: counts.get(cleaned, 0) asks for the current count and returns 0 if this word has never been
seen, so the very first sighting evaluates to 0 + 1. The result is then assigned back into
counts[cleaned], which creates the entry on a first sighting and overwrites it on every sighting
after. Writing counts[cleaned] + 1 instead would raise a KeyError on every new word — the default in
.get() is what makes this one line work for both cases.
most_common walks the finished tally with .items(), unpacking each entry into word and count. It
keeps a running champion in best_word and best_count, starting at 0 so the first real count always
beats it, and replaces both together whenever it meets a higher count. Because "the" was counted three
times and nothing beat it, that's what comes back.
The last two lines show the two read styles side by side. "dog" in tally is a membership test that
returns a boolean and never errors. tally.get("mat", 0) is a value lookup with a safe default, which
would have returned 0 just as calmly if "mat" had never appeared.
Common Mistakes
Using square brackets on a key that might not exist. This is the single most common dictionary bug.
Square brackets assume the key is there, and raise a KeyError when it isn't:
config = {"host": "localhost", "port": 8080}
try:
print(config["timeout"])
except KeyError as error:
print("Crashed with KeyError:", error)Crashed with KeyError: 'timeout'The fix is .get() with a default that makes sense for your data, so a missing setting falls back to a
reasonable value instead of stopping the program:
config = {"host": "localhost", "port": 8080}
print(config.get("timeout", 30))30Assuming .get() will raise an error when the key is missing. It won't — that's the whole point of
it. It quietly returns None, and because None looks fine until you try to do arithmetic or string
work with it, the real crash lands several lines later somewhere that looks unrelated:
inventory = {"bolts": 40}
count = inventory.get("screws")
print(count)
try:
print(count + 5)
except TypeError as error:
print("TypeError:", error)None
TypeError: unsupported operand type(s) for +: 'NoneType' and 'int'The error message names NoneType, not the missing key, so it gives no hint about where the problem
actually started. Always pass a default of the type you intend to work with — 0 for counters, "" for
text, [] for collections:
inventory = {"bolts": 40}
print(inventory.get("screws", 0) + 5)5Trying to use a list as a key. Lists can change, so Python refuses to hash them, and the message you get names the type rather than explaining the rule:
routes = {}
try:
routes[["Paris", "Rome"]] = 1106
except TypeError as error:
print("TypeError:", error)TypeError: cannot use 'list' as a dict key (unhashable type: 'list')Convert the list to a tuple, which stores the same values in the same order but cannot be modified and is therefore hashable:
routes = {}
routes[("Paris", "Rome")] = 1106
print(routes)
print(routes[("Paris", "Rome")]){('Paris', 'Rome'): 1106}
1106Looping over a dictionary and expecting values. A plain for loop over a dictionary yields keys, so
code written as if it were handing you values fails on the first iteration:
prices = {"tea": 3, "coffee": 5}
total = 0
try:
for price in prices:
total += price
except TypeError as error:
print("TypeError:", error)TypeError: unsupported operand type(s) for +=: 'int' and 'str'The loop variable was named price, but it actually held the string "tea", and adding a string to an
integer is not allowed. Ask for values explicitly with .values() — or for both parts with .items()
when you need the key too:
prices = {"tea": 3, "coffee": 5}
total = 0
for price in prices.values():
total += price
print(total)8Next Steps
Dictionaries turn "search through everything" into "ask one question," and that shift is what separates a slow solution from a fast one in most coding problems. The Two Number Sum practice problem is the natural next step: try the straightforward approach of comparing every pair first, then rewrite it using a dictionary of numbers you've already seen and notice how much work disappears.
Before that, open the Python playground and experiment with the examples here on your own data. Build a
small dictionary, deliberately look up a key that doesn't exist with square brackets to see the
KeyError for real, then do the same lookup with .get() and a default. Getting a feel for when each
one is the right tool will make the practice problem much smoother.
When you want a harder one
Interview-style problems graded against hidden tests — a big step up from the exercises. Come back to these when the ideas in this lesson feel comfortable rather than new.