This is the moment the course has spent two modules preparing for. Papyrus's parallel lists forced us to juggle zip() and indices; the previous lesson's list of tuples brought each book's data together, but it still identified fields by position and required scanning the whole catalog to find a title. The dictionary (dict) solves both problems: it associates keys with values and lets you access any value directly by its key, with no loops and no indices. By the end of this lesson we will carry out the great refactor: the Papyrus catalog will, at last, be a single structure.
Contents
- Creating dictionaries
- Accessing values: square brackets and
get() - Adding, modifying and removing (
del,pop) - The three views:
keys(),values(),items() - Iterating over dictionaries
- Nested dictionaries
- Dictionary comprehensions
- The great refactor: the Papyrus catalog as a
dict
Creating dictionaries
A dictionary is a collection of key → value pairs. It is written between curly braces, with key: value pairs separated by commas:
prices = {
"The Odyssey": 12.50,
"Hamlet": 9.95,
"Don Quixote": 15.90,
}
empty = {} # empty dict (careful: NOT an empty set!)
also_empty = dict()
from_pairs = dict([("Faust", 21.00)]) # from a list of (key, value) tuples
with_kwargs = dict(monday=3, tuesday=1) # keys as keywords (only if they are identifiers)Rules of the game:
- Keys must be unique and immutable (hashable): strings, numbers, tuples... never lists. If you repeat a key, the last assignment wins.
- Values can be anything: numbers, strings, lists, other dictionaries.
- Since Python 3.7, dictionaries preserve insertion order — looping over them yields the keys in the order they were added.
Accessing values: square brackets and get()
print(prices["Hamlet"]) # 9.95 → direct access by key, no scanning
print(prices["Dracula"]) # KeyError: 'Dracula' → the key doesn't exist, the program stopsSquare brackets are uncompromising: missing key, KeyError. For lookups where absence is a normal case (do we carry this book?), get() returns None — or a default of your choosing — instead of failing:
print(prices.get("Dracula")) # None: not there, but no explosion
print(prices.get("Dracula", 0.0)) # 0.0: a default value tailored to you
print(prices.get("Hamlet", 0.0)) # 9.95: if it exists, get() returns the real value
if "Faust" in prices: # in checks KEYS, not values
print("On file")Remember the None sentinel from 03-02? get() is the same philosophy built into the language.
| Situation | Use |
|---|---|
| The key must exist; its absence is a bug | d[key] (fail early and loudly) |
| Absence is a normal business case | d.get(key) or d.get(key, default) |
| You only need to know whether it's there | key in d |
Adding, modifying and removing
Dictionaries are mutable, like lists — with the same alias-vs-copy consequences (d2 = d1 does not copy; d1.copy() does, shallowly).
prices = {"The Odyssey": 12.50, "Hamlet": 9.95, "Don Quixote": 15.90}
prices["Faust"] = 21.00 # ADD: assigning to a new key creates it
prices["Hamlet"] = 10.50 # MODIFY: assigning to an existing key overwrites it
prices["Hamlet"] = 9.95 # (let's put it back the way it was)
del prices["Faust"] # REMOVE: del deletes the pair; KeyError if it doesn't exist
price = prices.pop("Don Quixote") # pop: deletes AND returns the value
print(price) # 15.9
rescue = prices.pop("Dracula", None) # with a 2nd argument, no failure if absent
print(rescue) # NoneNotice the symmetry with lists: pop() also "takes out and returns", but here by key. The same assignment syntax serves both to create and to modify: there is no append() because there are no positions, there are keys.
The three views: keys(), values(), items()
prices = {"The Odyssey": 12.50, "Hamlet": 9.95, "Don Quixote": 15.90}
print(prices.keys()) # dict_keys(['The Odyssey', 'Hamlet', 'Don Quixote'])
print(prices.values()) # dict_values([12.5, 9.95, 15.9])
print(prices.items()) # dict_items([('The Odyssey', 12.5), ('Hamlet', 9.95), ...])
print(list(prices.keys())) # convertible to a list whenever you need it
print(sum(prices.values())) # 38.35 → total catalog value (at 1 unit each)
print(max(prices.values())) # 15.9These are dynamic views, not copies: if the dictionary changes, the views reflect it. And look at items(): it returns pairs as (key, value) tuples — the tuples from the previous lesson show up everywhere.
Iterating over dictionaries
# 1) Iterating directly walks the KEYS
for title in prices:
print(title, "->", prices[title])
# 2) The idiomatic form: items() + tuple unpacking
for title, price in prices.items():
print(f"{title:<12} {price:>6.2f} EUR")
# 3) Values only
total = 0.0
for price in prices.values():
total += price
# 4) Sorted by price: sorted + a lambda over the items (03-03 in action)
for title, price in sorted(prices.items(), key=lambda pair: pair[1]):
print(title, price)Form 2 is the one you will see (and write) 90% of the time: items() gives you the tuple and the for unpacks it, exactly as you did with zip() — but with no parallel lists behind it.
Nested dictionaries
Values can themselves be dictionaries. This is the piece we were missing so that a book's record has named fields:
faust_record = {"price": 21.00, "stock": 0}
print(faust_record["price"]) # 21.0 → "price" shouts what it is; book[1] said nothing
catalog = {
"The Odyssey": {"price": 12.50, "stock": 4},
"Hamlet": {"price": 9.95, "stock": 6},
}
print(catalog["Hamlet"]["stock"]) # 6 → first bracket: book; second: fieldThe chained access catalog[title][field] reads from the outside in. With get() you can chain with a safety net: catalog.get("Dracula", {}).get("stock", 0) returns 0 without blowing up.
Dictionary comprehensions
We introduced them as a teaser in module 2; now they are an official tool. Syntax: {key: value for element in iterable}.
BOOK_VAT = 0.04
MEMBER_DISCOUNT = 0.05
prices = {"The Odyssey": 12.50, "Hamlet": 9.95, "Don Quixote": 15.90, "Faust": 21.00}
# Member price list computed in one go (the Papyrus convention)
member_rates = {t: round(p * (1 - MEMBER_DISCOUNT) * (1 + BOOK_VAT), 2)
for t, p in prices.items()}
print(member_rates)
# {'The Odyssey': 12.35, 'Hamlet': 9.83, 'Don Quixote': 15.71, 'Faust': 20.75}
# With a filter: only the books under 15 EUR
affordable = {t: p for t, p in prices.items() if p < 15}
# From two parallel lists to a dict in one line (we'll use this in the refactor)
titles = ["The Odyssey", "Hamlet"]
stocks = [4, 6]
inventory = {t: s for t, s in zip(titles, stocks)} # {'The Odyssey': 4, 'Hamlet': 6}The great refactor: the Papyrus catalog as a dict
The moment has arrived. We gather all the pieces and rewrite the heart of Papyrus. Before (module 3):
catalog = ["The Odyssey", "Hamlet", "Don Quixote"]
prices = [12.50, 9.95, 15.90]
stocks = [4, 6, 8]
# Finding = scanning; displaying = zip(); add/remove = touch 3 lists without slipping upAfter — the catalog's new canonical structure:
STORE_NAME = "Papyrus"
BOOK_VAT = 0.04
MEMBER_DISCOUNT = 0.05
catalog = {
"The Odyssey": {"price": 12.50, "stock": 4},
"Hamlet": {"price": 9.95, "stock": 6},
"Don Quixote": {"price": 15.90, "stock": 8},
"Faust": {"price": 21.00, "stock": 0},
}Each book is a single piece of information: the key is the title and the value is its record with named fields. Let's see what happens to the functions in papyrus_utils.py and menu.py. find_book() keeps its module 3 contract in spirit — case-insensitive search, None if absent — but now returns the canonical key instead of an index:
def find_book(catalog, wanted):
"""Returns the title exactly as it appears in the catalog, or None if absent.
The comparison ignores case and surrounding whitespace.
"""
wanted = wanted.strip().lower()
for title in catalog: # iterating a dict walks its keys
if title.lower() == wanted:
return title # the "official" key, ready for indexing
return None
def show_catalog(catalog):
"""Prints the catalog neatly aligned. No zip() needed anymore: goodbye, parallel lists."""
print(f"— {STORE_NAME} catalog —")
for title, record in catalog.items():
status = "SOLD OUT" if record["stock"] == 0 else f"{record['stock']} units"
print(f"{title:<12} {record['price']:>6.2f} EUR {status}")And Ana's day-to-day operations become this direct:
# Exact lookup: direct access, no scanning
print(catalog["Faust"]["price"]) # 21.0
# Tolerant lookup (whatever Luis types into the menu)
key = find_book(catalog, " don quixote ")
if key is not None:
print(f"{key}: {catalog[key]['price']:.2f} EUR")
# A sale: a single piece of data to update (before: find the index and touch the right list)
if catalog["Don Quixote"]["stock"] > 0:
catalog["Don Quixote"]["stock"] -= 1
# Add and remove: one operation, impossible to desynchronize
catalog["The Iliad"] = {"price": 13.75, "stock": 2}
del catalog["The Iliad"]graph TD
subgraph "Before: 3 parallel lists"
A["catalog[2] = 'Don Quixote'"] -.index 2.- B["prices[2] = 15.90"]
A -.index 2.- C["stocks[2] = 8"]
end
subgraph "Now: 1 dictionary"
D["'Don Quixote'"] --> E["{'price': 15.90, 'stock': 8}"]
end
Only find_book() still scans, because its case tolerance requires it; the exact lookup is instantaneous (in 04-04 you will see why that lookup by key is so fast). final_price(base, member=False, vat=BOOK_VAT) does not change a single line: it operates on the base price, wherever it comes from — that is the payoff of having separated business logic and data in module 3.
Common Mistakes and Tips
KeyErrorfrom trusting square brackets: if the key might not exist (user input), useget()or check withinfirst. Reserved[key]for keys you guarantee.- Using a list as a key:
TypeError: unhashable type: 'list'. Keys must be immutable; if you need a compound key, use a tuple:("Hamlet", "hardcover"). {}creates a dictionary, not a set: we will see this in the next lesson, but etch it in now.- Modifying a dict while looping over it (
delinside thefor) raises aRuntimeError. Loop over a copy of the keys:for t in list(catalog):. - Confusing
inover keys withinover values:"12.50" in priceslooks at keys. For values:12.50 in prices.values(). - Forgetting that
d2 = d1is an alias: identical to lists. Andcopy()is shallow: with nested records,copy["Hamlet"]is still the same inner record. - Design tip: keys = identity (the title), values = attributes (price, stock). If you catch yourself constantly searching by value, you may have chosen the wrong key.
Exercises
- Full migration. Write a function
build_catalog(titles, prices, stocks)that receives Papyrus's three historic parallel lists and returns the canonical nested dictionary (key = title →{"price": ..., "stock": ...}). Use it with the classic data, then add "Faust" (21.00, stock 0) with a single assignment and print the result. - Restocking report. With the complete canonical catalog (all 4 books), build with a dictionary comprehension the dict
to_restockcontaining the titles whosestockis below 5, with the value being how many units are missing to reach 5. Expected result:{'The Odyssey': 1, 'Faust': 5}. - Safe sale. Write
sell(catalog, wanted)that usesfind_book()to locate the title (case-tolerant), returnsNoneif the book does not exist or is sold out and, if there is stock, decrements it and returns the member price (Papyrus convention, withMEMBER_DISCOUNTandBOOK_VAT). Test it with"hamlet"(it should return 9.83 and leave the stock at 5) and with"faust"(it should returnNonewithout touching anything).
Solutions
# Exercise 1
def build_catalog(titles, prices, stocks):
"""Converts the three parallel lists into the canonical catalog."""
return {t: {"price": p, "stock": s}
for t, p, s in zip(titles, prices, stocks)} # zip, on its final tour of duty
catalog = build_catalog(
["The Odyssey", "Hamlet", "Don Quixote"], [12.50, 9.95, 15.90], [4, 6, 8]
)
catalog["Faust"] = {"price": 21.00, "stock": 0}
print(catalog)Notice: the comprehension zipping three lists is the parallel lists' farewell ceremony — it runs once during the migration and they are never seen again.
# Exercise 2
to_restock = {title: 5 - record["stock"]
for title, record in catalog.items()
if record["stock"] < 5}
print(to_restock) # {'The Odyssey': 1, 'Faust': 5}# Exercise 3
BOOK_VAT = 0.04
MEMBER_DISCOUNT = 0.05
def sell(catalog, wanted):
"""Sells 1 unit at the member price; None if absent or sold out."""
key = find_book(catalog, wanted)
if key is None:
return None
record = catalog[key]
if record["stock"] == 0:
return None
record["stock"] -= 1 # mutates the shared record: the catalog reflects it
return round(record["price"] * (1 - MEMBER_DISCOUNT) * (1 + BOOK_VAT), 2)
print(sell(catalog, "hamlet")) # 9.83
print(catalog["Hamlet"]["stock"]) # 5
print(sell(catalog, "faust")) # None (sold out)A common mistake: returning False instead of None for "not sold". Be consistent with the None sentinel from 03-02: it is the convention throughout the course.
Conclusion
The hook cast at the end of module 3 is now closed: the Papyrus catalog is no longer three lists to sync by hand, nor a list to scan hunting for titles, but a nested dictionary — {title: {"price": ..., "stock": ...}} — where each book is a single piece of information with named fields and direct access. You have mastered creation, access with brackets and get(), adds/removes with assignment, del and pop(), the keys()/values()/items() views, idiomatic iteration, nesting and comprehensions. find_book() and show_catalog() have been rewritten on top of the new structure without changing their contract, and final_price() never even noticed. One question remains hanging: we said that looking up catalog["Faust"] is "instantaneous" — why? The answer is shared by another structure built on the same magic, specialized in uniqueness and lightning-fast membership: the set, star of the next lesson.
Python Programming Course
Module 1: Introduction to Python
- Introduction to Python
- Setting Up the Development Environment
- Python Syntax and Basic Data Types
- Variables and Constants
- Basic Input and Output
- Virtual Environments and Package Management
Module 2: Control Structures
Module 3: Functions and Modules
- Defining Functions
- Function Arguments
- Lambda Functions
- Modules and Packages
- Standard Library Overview
Module 4: Data Structures
Module 5: Object-Oriented Programming
Module 6: File Handling
Module 7: Error and Exception Handling
- Introduction to Exceptions
- Handling Exceptions
- Raising Exceptions
- Custom Exceptions
- Best Practices and Error Logging
Module 8: Advanced Topics
- Type Hints
- Decorators
- Generators
- Context Managers
- Concurrency: Threads and Processes
- Asyncio for Asynchronous Programming
Module 9: Testing and Debugging
- Introduction to Testing
- Unit Testing with unittest
- Testing with pytest
- Test-Driven Development
- Debugging Techniques
- Using pdb for Debugging
Module 10: Web Development with Python
- Introduction to Web Development
- Flask Framework Fundamentals
- Building REST APIs with Flask
- Introduction to Django
- Building Web Applications with Django
Module 11: Data Science with Python
- Introduction to Data Science
- NumPy for Numerical Computing
- Pandas for Data Manipulation
- Matplotlib for Data Visualization
- Introduction to Machine Learning with scikit-learn
