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
namedtuple: named records, in depthCounter: counting without loopsdefaultdict: dictionaries with a default valuedeque: efficient queues at both endsOrderedDict: a historical note- 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 stringWhat 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 immutableWith 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 stringCompare 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 incollections:from collections import Counter, defaultdict, deque, namedtuple. - Mutating a
namedtuple:book.stock = 3raisesAttributeError. It is a tuple: use_replace()and reassign, or admit that piece of data was asking for a dict. - A
defaultdictthat "fattens up" on its own: every read of a new key creates it. To look up without creating,d.get(key)or convert todictonce you finish building. - Using
Counteras an existence validator:counter["X"]returns0instead ofKeyError, soif counter["X"]:is correct butcounter["X"]will never expose a misspelled key. - Indexing the middle of a
dequein 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,defaultdictanddequeare 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
- Bestseller ranking. With
sales = ["Hamlet", "Don Quixote", "Hamlet", "The Odyssey", "Hamlet", "Don Quixote", "Faust"], useCounterto print the podium (top 3) in the format1. Hamlet — 3 unitsusingmost_common()andenumerate(start=1)(module 2, to the rescue). Add the total number of copies sold. - Reservation grouper. With the list of tuples
reservations = [("Faust", "Luis"), ("Faust", "Marta"), ("The Iliad", "Luis"), ("Faust", "Pau")], build withdefaultdict(list)the dicttitle → [customers in order]. Then turn the "Faust" list into adequeand simulate the arrival of 2 copies: dopopleft()twice and report who gets notified and who is still waiting. - Immutable receipt. Define the
namedtupleReceiptLinewith fieldstitle,unitsandamount. 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 withsum()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: 7Notice: 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 EURTip: 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
- 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
