Lists, tuples, dictionaries, sets and strings: the basic arsenal is complete, and the Papyrus catalog finally lives in a single structure. But the standard library (03-05) holds a module designed precisely to polish the patterns we have been improvising: collections. Here we settle the promise from 04-02 with namedtuple in depth, count the best-selling books with Counter, group orders by customer with defaultdict and build an efficient reservation queue with deque — and we close the module with the definitive decision table: which structure to choose in each situation.

Contents

  1. namedtuple: named records, in depth
  2. Counter: counting without loops
  3. defaultdict: dictionaries with a default value
  4. deque: efficient queues at both ends
  5. OrderedDict: a historical note
  6. Choosing criteria: the module's summary table

namedtuple: named records, in depth

In 04-02 we tried it as an appetizer; now, the full course. A namedtuple is a factory of tuple classes: you define the record's name and its fields, and you get a new type whose instances are tuples with fields accessible by name.

from collections import namedtuple

Book = namedtuple("Book", ["title", "price", "stock"])

faust = Book("Faust", 21.00, 0)
odyssey = Book(title="The Odyssey", price=12.50, stock=4)   # keywords work too

print(faust.price)          # 21.0  → by name: no more mute indices
print(faust[1])             # 21.0  → still a tuple: indexing, slicing, len()...
title, price, stock = faust                    # and unpacking
print(faust)                # Book(title='Faust', price=21.0, stock=0) → readable repr, for free!

Three extra utilities worth knowing:

print(faust._asdict())           # {'title': 'Faust', 'price': 21.0, 'stock': 0} → to a dict
discounted = faust._replace(price=18.90)   # "modifying" an immutable: creates ANOTHER namedtuple
print(discounted.price, faust.price)       # 18.9 21.0 → the original, untouched
Book2 = namedtuple("Book2", "title price stock")   # fields also work as one string

What about the catalog? The namedtuple competes with 04-03's nested dict, and the choice is a real design decision:

# Option A (04-03): dict of dicts — flexible, fields as strings
catalog = {"Faust": {"price": 21.00, "stock": 0}}

# Option B: dict of namedtuples — genuinely named fields, protected against typos
catalog_nt = {"Faust": Book("Faust", 21.00, 0)}
print(catalog_nt["Faust"].price)             # 21.0
# catalog_nt["Faust"].stock = 3              # AttributeError: it's immutable

With option B, a typo (record["pirce"]) that in the dict would silently create a new key fails instantly here (AttributeError). In exchange, updating the stock requires _replace() plus reassignment — awkward for data that changes with every sale. For Papyrus we keep the nested dict as the canonical mutable structure and use namedtuple for read-only records, such as the lines of an already-charged receipt.

Counter: counting without loops

Counting occurrences is so common that collections ships it ready-made. Counter is a specialized dictionary: keys = elements, values = how many times they appear.

from collections import Counter

# This month's sales at Papyrus, as they were recorded
sales = ["Hamlet", "Don Quixote", "Hamlet", "The Odyssey", "Hamlet", "Don Quixote"]

counter = Counter(sales)
print(counter)                    # Counter({'Hamlet': 3, 'Don Quixote': 2, 'The Odyssey': 1})
print(counter["Hamlet"])          # 3
print(counter["Faust"])           # 0 → a missing key returns 0, NOT KeyError
print(counter.most_common(2))     # [('Hamlet', 3), ('Don Quixote', 2)] → top sellers, sorted

counter.update(["Faust", "Hamlet"])     # more sales come in
print(counter["Hamlet"])                # 4

print(sum(counter.values()))      # 8 → total copies sold
letters = Counter("papyrus")      # it also counts the characters of a string

Compare with the manual alternative — the loop with if title in counts: ... else: ... that you would have written in module 2 — and appreciate the difference: most_common() hands Ana her bestseller ranking in one call, ready for the shop window. Since it is a dict on steroids, everything from 04-03 works: items(), comprehensions, in...

defaultdict: dictionaries with a default value

A classic pattern: grouping things. Ana wants the orders organized by customer. With a regular dict you have to check whether the key exists before doing append:

orders = [("Luis", "Don Quixote"), ("Marta", "Hamlet"),
          ("Luis", "Faust"), ("Marta", "The Odyssey"), ("Luis", "Hamlet")]

# With a regular dict: the check gets in the way
by_customer = {}
for customer, title in orders:
    if customer not in by_customer:
        by_customer[customer] = []         # the initialization ceremony
    by_customer[customer].append(title)

defaultdict removes the ceremony: you build it with a factory (a zero-argument function, such as list, int or set) that gets invoked automatically the first time a missing key is accessed:

from collections import defaultdict

by_customer = defaultdict(list)            # new key → [] is created automatically
for customer, title in orders:
    by_customer[customer].append(title)    # no if: the first time, the list creates itself

print(dict(by_customer))
# {'Luis': ['Don Quixote', 'Faust', 'Hamlet'], 'Marta': ['Hamlet', 'The Odyssey']}

spending = defaultdict(float)              # float() → 0.0: accumulators with no initialization
spending["Luis"] += 15.71
spending["Luis"] += 20.75
print(spending["Luis"])                    # 36.46
Factory Initial value Typical use
list [] Grouping (append by key)
int 0 Counting (though Counter is usually better)
float 0.0 Accumulating amounts
set set() Grouping without duplicates

An important nuance: reading also creates. by_customer["Nobody"] inserts "Nobody": [] just by looking it up. If you are going to do many read-only lookups, convert it to a regular dict first (dict(by_customer)) or use get().

deque: efficient queues at both ends

In 04-01 we built the order queue with a list and gave a warning: pop(0) shifts every remaining element. deque (double-ended queue, pronounced "deck") is optimized for adding and removing at both ends without shifting anything:

from collections import deque

reservations = deque()               # the reservation queue for "Faust" (sold out, remember?)
reservations.append("Luis")          # enters on the right, like in a list
reservations.append("Marta")
reservations.appendleft("Pau")       # urgent! enters on the left

print(reservations)                  # deque(['Pau', 'Luis', 'Marta'])

next_up = reservations.popleft()     # leaves from the left: the first in the queue
print(next_up)                       # 'Pau' → copies of Faust arrive: notify Pau
last = reservations.pop()            # it can also leave from the right

recent_sales = deque(maxlen=3)       # with maxlen: when full, it evicts from the other end
for t in ["Hamlet", "The Odyssey", "Don Quixote", "Faust"]:
    recent_sales.append(t)
print(recent_sales)                  # deque(['The Odyssey', 'Don Quixote', 'Faust'], maxlen=3)

maxlen is a gem for "the last N operations" (the Papyrus menu's history, for instance): it maintains itself. The trade-off of deque: accessing the middle by index (reservations[500]) is slow compared to a list. Rule: ends → deque; interior positions → list.

graph LR
    A["appendleft()"] --> D["deque: Pau · Luis · Marta"]
    D --> B["pop()"]
    C["popleft()"] --- D
    D --- E["append()"]

OrderedDict: a historical note

In older code you will see collections.OrderedDict: a dict that guaranteed insertion order back when regular dicts did not. Since Python 3.7 all dicts preserve order, so today you almost never need it. It survives for compatibility and for two minor details (its equality does compare order, and it has move_to_end()). If you see it in a tutorial, now you know why it is there — and that you can use a regular dict.

Choosing criteria: the module's summary table

The choice of structure is the first design decision of any program. All of module 4, in one table:

Structure Ordered Mutable Duplicates Access Choose it when...
list Yes Yes Yes By index Homogeneous collection that grows/shrinks/gets sorted (order queue, titles)
tuple Yes No Yes By index Fixed, heterogeneous record; multiple return; compound dict key
dict Insertion Yes Keys no By key Associating identity → data: the catalog {title: record}
set No Yes No Membership Uniqueness, massive in, set algebra (members per month)
frozenset No No No Membership A set that must be a key or a constant
str Yes No Yes By index Text; normalizing with its methods; building with join()
namedtuple Yes No Yes By name Read-only record with readable fields (receipt line)
Counter Yes (counts) By key Counting occurrences and rankings (most_common)
defaultdict Insertion Yes Keys no By key Grouping/accumulating without initializing keys
deque Yes Yes Yes Ends Queues and "last N" (Faust reservations)

And as a quick decision guide:

graph TD
    A["What do I need to store?"] --> B{"Key→value pairs?"}
    B -- "Yes" --> C{"Keys that may be missing while grouping?"}
    C -- "Yes" --> D["defaultdict"]
    C -- "Is it counting?" --> E["Counter"]
    C -- "No" --> F["dict"]
    B -- "No" --> G{"Do order and position matter?"}
    G -- "No: only uniqueness/membership" --> H["set / frozenset"]
    G -- "Yes" --> I{"Will it change after creation?"}
    I -- "Yes" --> J{"In/out at the ends?"}
    J -- "Yes" --> K["deque"]
    J -- "No" --> L["list"]
    I -- "No" --> M{"Fields with their own meaning?"}
    M -- "Yes" --> N["namedtuple"]
    M -- "No" --> O["tuple"]

Common Mistakes and Tips

  • Forgetting the import: NameError: name 'Counter' is not defined. Everything in this lesson lives in collections: from collections import Counter, defaultdict, deque, namedtuple.
  • Mutating a namedtuple: book.stock = 3 raises AttributeError. It is a tuple: use _replace() and reassign, or admit that piece of data was asking for a dict.
  • A defaultdict that "fattens up" on its own: every read of a new key creates it. To look up without creating, d.get(key) or convert to dict once you finish building.
  • Using Counter as an existence validator: counter["X"] returns 0 instead of KeyError, so if counter["X"]: is correct but counter["X"] will never expose a misspelled key.
  • Indexing the middle of a deque in a loop: that is what it is bad at; if you need interior positions, it was a list.
  • Choosing a structure out of habit ("everything is a list"): review the table. The right structure removes code — 04-03's great refactor deleted more lines than it added.
  • Tip: Counter, defaultdict and deque are regular dicts/queues in their interface; everything you learned in 04-03 and 04-01 applies to them. They are not new structures to learn, but shortcuts over the ones you already master.

Exercises

  1. Bestseller ranking. With sales = ["Hamlet", "Don Quixote", "Hamlet", "The Odyssey", "Hamlet", "Don Quixote", "Faust"], use Counter to print the podium (top 3) in the format 1. Hamlet — 3 units using most_common() and enumerate(start=1) (module 2, to the rescue). Add the total number of copies sold.
  2. Reservation grouper. With the list of tuples reservations = [("Faust", "Luis"), ("Faust", "Marta"), ("The Iliad", "Luis"), ("Faust", "Pau")], build with defaultdict(list) the dict title → [customers in order]. Then turn the "Faust" list into a deque and simulate the arrival of 2 copies: do popleft() twice and report who gets notified and who is still waiting.
  3. Immutable receipt. Define the namedtuple ReceiptLine with fields title, units and amount. Create the lines of a purchase by Luis (2 "Hamlet" at 9.83 each → amount 19.66; 1 "Don Quixote" → 15.71), store them in a list and print the receipt with aligned f-strings (04-05) and the total with sum() and a generator expression over .amount.

Solutions

# Exercise 1
from collections import Counter

sales = ["Hamlet", "Don Quixote", "Hamlet", "The Odyssey", "Hamlet", "Don Quixote", "Faust"]
counter = Counter(sales)

for place, (title, units) in enumerate(counter.most_common(3), start=1):
    print(f"{place}. {title} — {units} units")
print(f"Total sold: {sum(counter.values())}")
# 1. Hamlet — 3 units / 2. Don Quixote — 2 units / 3. The Odyssey — 1 units / Total: 7

Notice: most_common(3) returns (title, units) tuples, and the for unpacks them inside the pair from enumerate — nested unpacking with parentheses.

# Exercise 2
from collections import defaultdict, deque

reservations = [("Faust", "Luis"), ("Faust", "Marta"), ("The Iliad", "Luis"), ("Faust", "Pau")]

by_title = defaultdict(list)
for title, customer in reservations:
    by_title[title].append(customer)

faust_queue = deque(by_title["Faust"])       # deque(['Luis', 'Marta', 'Pau'])
for _ in range(2):                            # 2 copies arrive
    print(f"Notify {faust_queue.popleft()}: their Faust has arrived")
print(f"Still waiting: {list(faust_queue)}")   # ['Pau']
# Exercise 3
from collections import namedtuple

ReceiptLine = namedtuple("ReceiptLine", ["title", "units", "amount"])

lines = [
    ReceiptLine("Hamlet", 2, 19.66),
    ReceiptLine("Don Quixote", 1, 15.71),
]
for line in lines:
    print(f"{line.units} x {line.title:<12}{line.amount:>8.2f} EUR")
total = sum(line.amount for line in lines)
print(f"{'TOTAL':<16}{total:>8.2f} EUR")     # 35.37 EUR

Tip: the lines of a charged receipt must never change — the namedtuple's immutability is not a limitation here, it is exactly the guarantee the business needs.

Conclusion

Module 4 delivers what it promised when module 3 closed: each Papyrus book is finally a single piece of information. You revisited lists in depth with their mutability and their alias traps (04-01); tuples gave you immutable records and the multiple return values that 03-02 left promised (04-02); dictionaries carried out the great refactor — the catalog is now {title: {"price": ..., "stock": ...}} with direct access by key (04-03); sets contributed uniqueness, lightning-fast membership and algebra for comparing members and deduplicating orders (04-04); strings revealed their complete toolbox and find_book()'s definitive normalization (04-05); and collections gave names to the remaining patterns — namedtuple, Counter, defaultdict, deque — along with the decision table for choosing a structure without hesitation. But look at the Papyrus code with fresh eyes: the catalog lives in a dictionary and the functions that know how to operate it — find_book(), show_catalog(), final_price() — live elsewhere, in papyrus_utils.py, trusting that nobody hands them the wrong structure. Data on one side, behavior on the other. Module 5 introduces the tool that fuses them into a single piece with a name of its own: classes. Each book will stop being a dictionary entry and become an object that knows how to compute its own member price — object-oriented programming begins where data structures end.

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