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

  1. Creating dictionaries
  2. Accessing values: square brackets and get()
  3. Adding, modifying and removing (del, pop)
  4. The three views: keys(), values(), items()
  5. Iterating over dictionaries
  6. Nested dictionaries
  7. Dictionary comprehensions
  8. 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 stops

Square 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)                            # None

Notice 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.9

These 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: field

The 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 up

After — 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

  • KeyError from trusting square brackets: if the key might not exist (user input), use get() or check with in first. Reserve d[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 (del inside the for) raises a RuntimeError. Loop over a copy of the keys: for t in list(catalog):.
  • Confusing in over keys with in over values: "12.50" in prices looks at keys. For values: 12.50 in prices.values().
  • Forgetting that d2 = d1 is an alias: identical to lists. And copy() 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

  1. 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.
  2. Restocking report. With the complete canonical catalog (all 4 books), build with a dictionary comprehension the dict to_restock containing the titles whose stock is below 5, with the value being how many units are missing to reach 5. Expected result: {'The Odyssey': 1, 'Faust': 5}.
  3. Safe sale. Write sell(catalog, wanted) that uses find_book() to locate the title (case-tolerant), returns None if the book does not exist or is sold out and, if there is stock, decrements it and returns the member price (Papyrus convention, with MEMBER_DISCOUNT and BOOK_VAT). Test it with "hamlet" (it should return 9.83 and leave the stock at 5) and with "faust" (it should return None without 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

Module 2: Control Structures

Module 3: Functions and Modules

Module 4: Data Structures

Module 5: Object-Oriented Programming

Module 6: File Handling

Module 7: Error and Exception Handling

Module 8: Advanced Topics

Module 9: Testing and Debugging

Module 10: Web Development with Python

Module 11: Data Science with Python

Module 12: Final Project

© Copyright 2026. All rights reserved