I want to start with a question, not a code example. You've used product[3] all week for the stock count. What happens when you write that in a function and someone reviews it three months later?
They'd have to find where the tuple was defined to figure out what index 3 is. Or they'd guess — and if they guessed wrong and changed something, the bug wouldn't be obvious until a report was wrong.
I watched that exact bug happen in Track 1. You refactored a return tuple, shifted a value by one position, and two other functions were indexing into it with the old positions. Diane's report was wrong for a week.
I found it at midnight on a Friday. Don't remind me.
Named tuples fix this. They keep everything that makes tuples good — immutability, hashability, lightweight structure — and add readable field names. product.stock instead of product[3]. The field name is right there in the code.
How do I create one? I'm assuming it's somewhere in the standard library.
collections.namedtuple. You call it with the type name and a list of field names — it hands back a class:
from collections import namedtuple
Product = namedtuple('Product', ['name', 'sku', 'price', 'stock', 'category'])
widget = Product(name='Widget-A', sku='SKU-1001', price=24.99, stock=150, category='Hardware')
print(widget.name) # Widget-A
print(widget.stock) # 150
print(widget[3]) # 150 — index access still workswidget.stock and widget[3] both work on the same object? It's a tuple that also has named attributes?
That's exactly what it is. A named tuple is still a real tuple — you can index it, unpack it, put it in a set, pass it to any function that iterates tuples. The names are an additional layer on top, not a replacement.
And the packing slip analogy — the label is printed and laminated. You read the fields by name, but you can't cross anything out.
Try it:
widget.stock = 200
# AttributeError: can't set attributeFully immutable. If you need to update the stock, you create a new Product. The _replace() method makes that clean:
updated = widget._replace(stock=200)
# New Product with stock=200, all other fields unchanged_replace() is non-destructive — it builds a new tuple rather than modifying the old one. That's consistent with how we've been thinking about snapshots all week.
You've been building toward this mental model since Day 3. The intake snapshot is immutable. Updates produce new records. Now — where should the Product definition live? Inside the function or outside?
Module level. If you define it inside a function, Python creates a new class on every call. Two instances from separate calls would be different types technically, even if they have the same fields. Defining it once at the top means there's exactly one Product type in the program.
That's the rule, and most developers don't arrive at it until they hit a subtle comparison bug caused by redefining inside a function. You got there from first principles.
There's one more thing I want to check. Can I still unpack a named tuple the way I'd unpack a plain one?
Complete backward compatibility. Index, unpack, attribute — all three work:
widget = Product('Widget-A', 'SKU-1001', 24.99, 150, 'Hardware')
# Index
print(widget[2]) # 24.99
# Attribute
print(widget.price) # 24.99
# Unpack
name, sku, price, stock, category = widgetSo I could swap all my plain tuples for named tuples and nothing downstream breaks. The plain-tuple functions still work because a named tuple is a tuple.
Correct. Gradual migration is the real-world pattern — you don't rewrite everything at once. You start using named tuples for new code and the old code coexists.
One more — there's a method for converting to a dict. I've seen ._asdict() somewhere. Useful for the reporting layer that expects dicts?
Exactly:
widget_dict = widget._asdict()
# {'name': 'Widget-A', 'sku': 'SKU-1001', 'price': 24.99, 'stock': 150, 'category': 'Hardware'}The field names are already in the type, so you don't have to manually build {"name": widget[0], "price": widget[2], ...}. The method just reflects the named fields into a dict.
I was doing that manually for three functions in Track 1. One method call.
That's the recurring theme of this track. The better tool existed. Now you know where it is.
Okay. The function today: make_product_catalog takes raw tuples and wraps each one in Product. I define Product at module level, then a list comprehension to construct each one. I can splat each raw tuple directly into the constructor:
def make_product_catalog(raw_data: list[tuple]) -> list:
return [Product(*item) for item in raw_data]Product(*item) — you used * to spread each raw tuple into positional arguments. That's the same unpacking operator you've been using all week, now on the call side rather than the assignment side. One *, two directions.
And _asdict(), _replace(), and the module-level definition rule — those are the three things I need to remember beyond the basic syntax.
Add one more: named tuples are a transitional tool. When your data needs methods, inheritance, or mutable fields, you'll move to a dataclass. Named tuples are for structured, immutable records that stay close to the tuple idiom. Warehouse product specs at intake — perfect fit.
Next week is functions. Default arguments, *args, **kwargs. How do named tuples connect?
They connect cleanly as return types. Functions that return structured results can return Product named tuples instead of plain tuples — the caller gets readable field access without you needing to write a class. That pattern shows up in week two immediately. You've already got the foundation.
collections.namedtuple creates a new class whose instances are tuples with named fields. The class is defined once (at module level) and instantiated like any class.
from collections import namedtuple
Product = namedtuple('Product', ['name', 'sku', 'price', 'stock', 'category'])
# Positional or keyword instantiation
widget = Product('Widget-A', 'SKU-1001', 24.99, 150, 'Hardware')
widget = Product(name='Widget-A', sku='SKU-1001', price=24.99, stock=150, category='Hardware')
# All of these work on the same object
widget.stock # 150 — attribute access
widget[3] # 150 — index access
name, *_, stock, _ = widget # tuple unpackingKey built-in methods:
._asdict() returns an OrderedDict of field names to values.._replace(**kwargs) returns a new instance with specified fields changed; all other fields are copied unchanged.._fields is a tuple of field name strings — useful for reflection and dynamic code.Pitfall 1: Defining namedtuple inside a function. Each call creates a new class — instances from different calls are technically different types. Define at module level.
Pitfall 2: Expecting mutability from _replace(). _replace() returns a new instance; it does not modify the original. If you call widget._replace(stock=200) and discard the return value, nothing changes.
Pitfall 3: Confusing namedtuple with dataclass. Named tuples are immutable and behave as tuples everywhere. Dataclasses (@dataclass) are mutable by default, support methods and inheritance, and do not behave as tuples. If you need product.stock = 200 to work, use a dataclass.
Python 3.6+ introduced typing.NamedTuple, which supports type annotations on fields and is the modern preferred syntax:
from typing import NamedTuple
class Product(NamedTuple):
name: str
sku: str
price: float
stock: int
category: strThis form is semantically identical to collections.namedtuple but integrates cleanly with type checkers and IDEs. The class-based syntax also makes it easy to add methods and computed properties while keeping immutability.
Sign up to write and run code in this lesson.
I want to start with a question, not a code example. You've used product[3] all week for the stock count. What happens when you write that in a function and someone reviews it three months later?
They'd have to find where the tuple was defined to figure out what index 3 is. Or they'd guess — and if they guessed wrong and changed something, the bug wouldn't be obvious until a report was wrong.
I watched that exact bug happen in Track 1. You refactored a return tuple, shifted a value by one position, and two other functions were indexing into it with the old positions. Diane's report was wrong for a week.
I found it at midnight on a Friday. Don't remind me.
Named tuples fix this. They keep everything that makes tuples good — immutability, hashability, lightweight structure — and add readable field names. product.stock instead of product[3]. The field name is right there in the code.
How do I create one? I'm assuming it's somewhere in the standard library.
collections.namedtuple. You call it with the type name and a list of field names — it hands back a class:
from collections import namedtuple
Product = namedtuple('Product', ['name', 'sku', 'price', 'stock', 'category'])
widget = Product(name='Widget-A', sku='SKU-1001', price=24.99, stock=150, category='Hardware')
print(widget.name) # Widget-A
print(widget.stock) # 150
print(widget[3]) # 150 — index access still workswidget.stock and widget[3] both work on the same object? It's a tuple that also has named attributes?
That's exactly what it is. A named tuple is still a real tuple — you can index it, unpack it, put it in a set, pass it to any function that iterates tuples. The names are an additional layer on top, not a replacement.
And the packing slip analogy — the label is printed and laminated. You read the fields by name, but you can't cross anything out.
Try it:
widget.stock = 200
# AttributeError: can't set attributeFully immutable. If you need to update the stock, you create a new Product. The _replace() method makes that clean:
updated = widget._replace(stock=200)
# New Product with stock=200, all other fields unchanged_replace() is non-destructive — it builds a new tuple rather than modifying the old one. That's consistent with how we've been thinking about snapshots all week.
You've been building toward this mental model since Day 3. The intake snapshot is immutable. Updates produce new records. Now — where should the Product definition live? Inside the function or outside?
Module level. If you define it inside a function, Python creates a new class on every call. Two instances from separate calls would be different types technically, even if they have the same fields. Defining it once at the top means there's exactly one Product type in the program.
That's the rule, and most developers don't arrive at it until they hit a subtle comparison bug caused by redefining inside a function. You got there from first principles.
There's one more thing I want to check. Can I still unpack a named tuple the way I'd unpack a plain one?
Complete backward compatibility. Index, unpack, attribute — all three work:
widget = Product('Widget-A', 'SKU-1001', 24.99, 150, 'Hardware')
# Index
print(widget[2]) # 24.99
# Attribute
print(widget.price) # 24.99
# Unpack
name, sku, price, stock, category = widgetSo I could swap all my plain tuples for named tuples and nothing downstream breaks. The plain-tuple functions still work because a named tuple is a tuple.
Correct. Gradual migration is the real-world pattern — you don't rewrite everything at once. You start using named tuples for new code and the old code coexists.
One more — there's a method for converting to a dict. I've seen ._asdict() somewhere. Useful for the reporting layer that expects dicts?
Exactly:
widget_dict = widget._asdict()
# {'name': 'Widget-A', 'sku': 'SKU-1001', 'price': 24.99, 'stock': 150, 'category': 'Hardware'}The field names are already in the type, so you don't have to manually build {"name": widget[0], "price": widget[2], ...}. The method just reflects the named fields into a dict.
I was doing that manually for three functions in Track 1. One method call.
That's the recurring theme of this track. The better tool existed. Now you know where it is.
Okay. The function today: make_product_catalog takes raw tuples and wraps each one in Product. I define Product at module level, then a list comprehension to construct each one. I can splat each raw tuple directly into the constructor:
def make_product_catalog(raw_data: list[tuple]) -> list:
return [Product(*item) for item in raw_data]Product(*item) — you used * to spread each raw tuple into positional arguments. That's the same unpacking operator you've been using all week, now on the call side rather than the assignment side. One *, two directions.
And _asdict(), _replace(), and the module-level definition rule — those are the three things I need to remember beyond the basic syntax.
Add one more: named tuples are a transitional tool. When your data needs methods, inheritance, or mutable fields, you'll move to a dataclass. Named tuples are for structured, immutable records that stay close to the tuple idiom. Warehouse product specs at intake — perfect fit.
Next week is functions. Default arguments, *args, **kwargs. How do named tuples connect?
They connect cleanly as return types. Functions that return structured results can return Product named tuples instead of plain tuples — the caller gets readable field access without you needing to write a class. That pattern shows up in week two immediately. You've already got the foundation.
collections.namedtuple creates a new class whose instances are tuples with named fields. The class is defined once (at module level) and instantiated like any class.
from collections import namedtuple
Product = namedtuple('Product', ['name', 'sku', 'price', 'stock', 'category'])
# Positional or keyword instantiation
widget = Product('Widget-A', 'SKU-1001', 24.99, 150, 'Hardware')
widget = Product(name='Widget-A', sku='SKU-1001', price=24.99, stock=150, category='Hardware')
# All of these work on the same object
widget.stock # 150 — attribute access
widget[3] # 150 — index access
name, *_, stock, _ = widget # tuple unpackingKey built-in methods:
._asdict() returns an OrderedDict of field names to values.._replace(**kwargs) returns a new instance with specified fields changed; all other fields are copied unchanged.._fields is a tuple of field name strings — useful for reflection and dynamic code.Pitfall 1: Defining namedtuple inside a function. Each call creates a new class — instances from different calls are technically different types. Define at module level.
Pitfall 2: Expecting mutability from _replace(). _replace() returns a new instance; it does not modify the original. If you call widget._replace(stock=200) and discard the return value, nothing changes.
Pitfall 3: Confusing namedtuple with dataclass. Named tuples are immutable and behave as tuples everywhere. Dataclasses (@dataclass) are mutable by default, support methods and inheritance, and do not behave as tuples. If you need product.stock = 200 to work, use a dataclass.
Python 3.6+ introduced typing.NamedTuple, which supports type annotations on fields and is the modern preferred syntax:
from typing import NamedTuple
class Product(NamedTuple):
name: str
sku: str
price: float
stock: int
category: strThis form is semantically identical to collections.namedtuple but integrates cleanly with type checkers and IDEs. The class-based syntax also makes it easy to add methods and computed properties while keeping immutability.