At the end of the previous lesson one assembly was still pending. v0.8 knew how to carry many tasks, but spread over parallel lists that had to be synchronised by hand; v0.9 describes one task impeccably, but carries only one. The two pieces fit together almost obviously: if a task is a dictionary and a list stores many things of the same type, then the agenda of Alba Studio is a list of dictionaries. This lesson is about that —combining structures— and about the last one left to meet: the tuple, an immutable sequence you have already come across without knowing it every time a function returned two values back in 04-02. By the end you will read expressions such as agenda[0]["assignee"] without hesitating, choose sensibly between list, dictionary and tuple, and EasyTask will have reached v0.10 with its whole menu running on a real collection.
Contents
- What a tuple is
- Immutability: when a tuple and when a list
- Unpacking
- Returning several values is returning a tuple
- Tuples as keys and
namedtuple - Nested structures: the three patterns
- Reading a chained expression
- Traversing nested structures
- Shallow copy and deep copy
- Choosing the right structure
- EasyTask v0.10: the studio agenda
- Common mistakes and tips
- Exercises
- Conclusion
- What a tuple is
A tuple is an ordered sequence of values, like a list, but immutable: once created you cannot add, remove or change anything. It is written with parentheses, although what really creates the tuple is the commas.
task = ("Book fair poster", "Marta", "high", 3)
print(task[0], task[-1], len(task)) # Book fair poster 3 4
print(task[1:3], "Marta" in task) # ('Marta', 'high') True
task[3] = 5 # TypeError: 'tuple' object does not support item assignment
no_parentheses = "Book fair poster", "Marta", 3 # this is a tuple too
just_one = ("Book fair poster",) # tuple of ONE item: the comma is compulsory
not_a_tuple = ("Book fair poster") # only a string inside parentheses
print(type(just_one), type(not_a_tuple)) # <class 'tuple'> <class 'str'>Indices, slices, len(), in and the for traversal work exactly as in a list; the only thing that does not work is modifying. The last three assignments are the ones that confuse everybody: the parentheses are optional, the empty tuple is written () and the one-item tuple requires the trailing comma, because without it the parentheses are just grouping and the result is the value inside. The constants you have been using since 04-04 —PRIORITIES = ("high", "medium", "low")— are precisely tuples, and now you know why: they are fixed data the program must never change.
- Immutability: when a tuple and when a list
| Criterion | Tuple | List |
|---|---|---|
| Syntax | (1, 2, 3) |
[1, 2, 3] |
| Can it be modified? | No | Yes |
| Available methods | Only count and index |
append, pop, sort… |
| Dictionary key or element of a set? | Yes | No |
| Typical use | Record of fixed fields, constants, returned values | Collection that grows and changes |
The deciding question is: are the elements different things with a fixed role, or many things of the same kind? A coordinate (x, y), a date (2026, 8, 4) or a row of data are tuples: a fixed number of positions, each with its own meaning. The people of the studio are a list: it can grow, shrink and be reordered. Immutability has three practical advantages: it protects the data that must not change, it lets you use the tuple as a dictionary key or an element of a set, and it documents the intention to whoever reads the code, because a tuple says "this is a closed record". The trade-off is that to change something you have to build a new tuple.
- Unpacking
Unpacking means distributing the elements of a sequence into several variables at once. It is one of the things that make Python comfortable, and you have already used it without naming it in for key, value in task.items().
task = ("Book fair poster", "Marta", "high", 3)
title, assignee, priority, days = task
print(assignee, days) # Marta 3
a, b = 3, 5
a, b = b, a # swap with no helper variable: 5 3
first, *rest = ["Marta", "Luis", "Nuria", "Alba"]
print(first, rest) # Marta ['Luis', 'Nuria', 'Alba']
first, *middle, last = ["Marta", "Luis", "Nuria", "Alba"] # middle: ['Luis','Nuria']
title, _, priority, _ = task # the underscore: 'this value does not interest me'The number of names on the left must match exactly the number of elements; if not, ValueError: too many values to unpack. The one-line swap works because the right-hand side is evaluated in full before assigning: first the tuple (5, 3) is built and then it is distributed. The asterisk collects "the rest" into a list, and there can be only one per unpacking. The underscore _ is a convention, not a rule of the language: an ordinary variable whose name announces that its value will be ignored.
- Returning several values is returning a tuple
In 04-02 you learned that a function can return several values and it was left to be explained here. You now have all the pieces: return a, b builds a tuple and returns it; the multiple assignment at the call site is an unpacking.
def analyse(days):
"""Return the total, the average and the maximum of a list of days."""
return sum(days), sum(days) / len(days), max(days)
total, average, maximum = analyse([3, 5, 2, 8]) # unpacking
result = analyse([3, 5, 2, 8]) # without unpacking
print(result, type(result)) # (18, 4.5, 8) <class 'tuple'>
print(result[1]) # 4.5In other words: a function always returns a single value; it just happens that this value can be a tuple with several things inside. That register_task() of v0.7, which "returned four values", actually returned a four-element tuple that was unpacked at the call site. A design tip: if you come back from a function with more than three or four values, or if the caller has to remember the exact order, what you probably want to return is a dictionary, where every piece of data carries its name. That is exactly the change v0.9 made.
- Tuples as keys and
namedtuple
namedtupleBeing immutable, a tuple can be a dictionary key. That lets you index by a combination of data:
hours = {("Marta", "monday"): 6, ("Marta", "tuesday"): 4, ("Luis", "monday"): 8}
print(hours[("Marta", "tuesday")]) # 4
print(hours.get(("Nuria", "monday"), 0)) # 0 -> she did not work that dayWith a list as the key it would be impossible (TypeError: unhashable type). This pattern is common for two-way tables: person and day, product and month, row and column. There is also namedtuple, from the collections module, which is a tuple whose fields have names: Task = namedtuple("Task", "title assignee days") lets you write t.assignee instead of t[1]. I mention it so you recognise it if you meet it; in this course we will use dictionaries for the same purpose, and in module 7 you will see the definitive tool, classes.
- Nested structures: the three patterns
Lists, dictionaries and tuples can be put inside one another without limit, but in practice almost everything you will need is three combinations. The first is the list of dictionaries —"many records"—, the natural shape of an agenda, a catalogue or a table:
agenda = [
{"title": "Book fair poster", "assignee": "Marta", "priority": "high", "days": 3},
{"title": "Bakery menu", "assignee": "Luis", "priority": "medium", "days": 5},
{"title": "Vidal logo", "assignee": "Nuria", "priority": "low", "days": 2},
]Dictionary of lists —"groups of things", when you want to go straight to the name of the group. And dictionary of dictionaries —"cards indexed by a unique identifier":
by_assignee = {"Marta": ["Book fair poster", "Summer flyer"],
"Luis": ["Bakery menu"], "Nuria": []}
team = {"Marta": {"role": "coordinator", "hours": 38, "tags": {"design"}},
"Luis": {"role": "designer", "hours": 35, "tags": {"printing"}}}They are accessed with agenda[0]["title"], by_assignee["Marta"][0] and team["Marta"]["role"] respectively. The complete decision table is in section 10.
- Reading a chained expression
An expression such as agenda[0]["assignee"] is frightening the first time and stops being so as soon as you know it reads from left to right, one step at a time:
agenda→ the complete list of tasks.agenda[0]→ the first element of that list, which turns out to be a dictionary.agenda[0]["assignee"]→ the value of the key"assignee"of that dictionary,"Marta".
graph TD
A["agenda (list)"] --> B["[0] dictionary"]
A --> C["[1] dictionary"]
B --> D["title: Book fair poster"]
B --> E["assignee: Marta"]
C --> G["title: Bakery menu"]
C --> H["assignee: Luis"]
The trick for not getting lost: each bracket goes down one level of the tree. If in doubt, print the intermediate steps in the REPL: print(agenda[0]) shows the whole dictionary and print(type(agenda[0])) says which type you are standing on. The errors are informative: TypeError: list indices must be integers means you used a text key where there was a list, and KeyError, that the key does not exist in that dictionary. With practice they read at a glance: agenda[1]["title"] is "Bakery menu", team["Marta"]["role"] is "coordinator", by_assignee["Marta"][1] is "Summer flyer", and agenda[0]["days"] = 4 modifies the task in place with no trouble at all.
- Traversing nested structures
Traversing a list of dictionaries is a normal for in which each element happens to be a dictionary:
for task in agenda:
print(f"{task['title']:<20}{task['assignee']:>8}{task['days']:>4}d")
pending = [t for t in agenda if t["days"] > 2] # filter
titles = [t["title"] for t in agenda] # extract a column
by_days = sorted(agenda, key=lambda t: t["days"], reverse=True) # sortHere you can see why the change was worth it: sorting by days, which in 05-01 was a convoluted expression with titles.index(t), is now simply key=lambda t: t["days"]. Traversing a dictionary of lists requires two levels of loop, the second inside the first; with a dictionary of dictionaries the pattern is the same:
for assignee, titles in by_assignee.items():
print(f"{assignee} ({len(titles)} tasks):")
for title in titles:
print(f" - {title}")
for name, card in team.items():
print(f"{name:<8}{card['role']:<15}{card['hours']:>3}h")Notice the direct unpacking in the header: for assignee, titles in ...items() separates key and value with no extra lines, and it works whenever the inner structure is known.
- Shallow copy and deep copy
Here a classic mistake appears that only shows up with nested structures: .copy() copies the list, but it does not copy what is inside.
import copy
shallow = agenda.copy() # shallow copy
shallow[0]["days"] = 99 # we modify the task through the copy
print(agenda[0]["days"]) # 99 -> the original has changed too
real_copy = copy.deepcopy(agenda) # deep copy
real_copy[0]["days"] = 1
print(agenda[0]["days"]) # 99 -> now yes, the original was left aloneThe list shallow is new, but its elements are the same dictionaries as those of agenda: the box has been duplicated, not the content.
| Kind of copy | How | What it duplicates |
|---|---|---|
| Reference (aliasing) | b = a |
Nothing: two names, one object |
| Shallow | a.copy(), list(a), a[:] |
The outer container |
| Deep | copy.deepcopy(a) |
The whole tree, to any depth |
The practical rule: if the structure has a single level, a shallow copy is enough; if there are lists or dictionaries inside and you are going to modify them, you need deepcopy. And do not use deepcopy by default: it is slower, and most of the time what you want is precisely for the function to really modify the agenda.
- Choosing the right structure
| I need… | Structure | Studio example |
|---|---|---|
| Many things of the same type, in order | List | Task titles |
| The fields of one thing, each with its name | Dictionary | A task |
| A fixed record that must not change | Tuple | PRIORITIES, a coordinate |
| Unique values, only checking membership | Set | Studio tags |
| Many records with the same fields | List of dictionaries | The complete agenda |
| Grouping records by a category | Dictionary of lists | Tasks by assignee |
| Finding a card by a unique identifier | Dictionary of dictionaries | Team member card |
| Indexing by a combination of data | Dictionary with tuple keys | Hours per person and day |
A tip that saves a lot of work: choose the structure thinking about the most frequent operation. If what you do most is traverse everything and sort, list of dictionaries; if what you do most is "give me the card of X", dictionary of dictionaries. And if you need both things, keeping one list and building the grouping when needed is usually simpler than holding two synchronised structures —the mistake we already made with the parallel lists.
- EasyTask v0.10: the studio agenda
We reach the assembly: the agenda is a list of dictionaries and every menu option is rebuilt on top of it, growing to six to include the summary by assignee. The parallel lists of v0.8 disappear for good.
# easytask.py - Alba Studio / Version 0.10: the agenda is a list of dictionaries
WIDTH = 52
PRIORITIES = ("high", "medium", "low")
TEAM = ("marta", "luis", "nuria")
OPTIONS = ("1", "2", "3", "4", "5", "6")
PRIORITY_ORDER = {"high": 0, "medium": 1, "low": 2}
# --- Unchanged: ask_text, ask_option, ask_integer, confirm,
# classify_urgency, show_menu (now with 6 options) and pause ---
def register_task(agenda):
"""Ask for a new task and add it at the end of the agenda."""
agenda.append({
"title": ask_text("Title : "),
"assignee": ask_option("Assignee : ", TEAM).capitalize(),
"priority": ask_option("Priority : ", PRIORITIES),
"days": ask_integer("Days (1-365) : ", 1, 365),
"completed": False})
print(f"Task {len(agenda)} registered successfully.")
def show_list(agenda):
"""Print the agenda sorted by priority, keeping the real number of each task."""
if not agenda:
print("There are no tasks registered.")
return False
pairs = sorted(enumerate(agenda, start=1),
key=lambda p: PRIORITY_ORDER[p[1]["priority"]])
print("-" * WIDTH)
for number, task in pairs:
mark = "[X]" if task["completed"] else "[ ]"
print(f"{number:>2}. {mark} {task['title']:<24}"
f"{task['assignee']:>8}{task['priority']:>7}{task['days']:>4}d")
done = sum(1 for t in agenda if t["completed"])
print(f"Total: {len(agenda)} tasks, {done} completed, "
f"{sum(t['days'] for t in agenda)} days of work.")
return True
def choose_task(agenda):
"""Return the task the user picks, or None if the agenda is empty."""
if not show_list(agenda):
return None
number = ask_integer(f"Task number (1-{len(agenda)}): ", 1, len(agenda))
return agenda[number - 1]
# change_priority(task) and mark_completed(task) stay exactly as they were
# in v0.9: they already received a dictionary and modified it in place.
def summary_by_assignee(agenda):
"""Print how many tasks and how many days each person of the team accumulates."""
summary = {}
for task in agenda:
data = summary.setdefault(task["assignee"], {"tasks": 0, "days": 0})
data["tasks"] += 1
data["days"] += task["days"]
for assignee, data in sorted(summary.items(), key=lambda p: -p[1]["days"]):
print(f"{assignee:<12}{data['tasks']:>3} tasks{data['days']:>5} days")
without_tasks = {n.capitalize() for n in TEAM} - set(summary)
if without_tasks:
print("No tasks assigned:", ", ".join(sorted(without_tasks)))
def main():
"""Run the main loop of the application."""
agenda = []
while True:
show_menu()
option = ask_option("Choose an option (1-6): ", OPTIONS)
if option == "6":
if confirm("Are you sure you want to exit?"):
break
elif option == "1":
register_task(agenda)
elif option == "2":
show_list(agenda)
elif option == "3":
task = choose_task(agenda)
if task is not None:
change_priority(task)
elif option == "4":
task = choose_task(agenda)
if task is not None:
mark_completed(task)
elif option == "5":
summary_by_assignee(agenda)
pause()
if __name__ == "__main__":
main()The points worth understanding well:
choose_taskreturns the dictionary, not an index. And since the dictionary is mutable and passed by reference,change_priority(task)modifies the task inside the agenda without needing to know which position it was in. Compare this with the five coordinatedpopcalls of v0.8.sorted(enumerate(agenda, start=1), key=lambda p: PRIORITY_ORDER[p[1]["priority"]])sorts by the logical order of the priorities —using a dictionary as a translation table— without losing the real number of each task:enumerateproduces pairs(number, task)and thekeylooks inside the second element of the pair. That way the listing comes out sorted but the numbers still work for choosing, andchoose_taskcan reuseshow_listinstead of repeating the loop (DRY, 04-04).sum(1 for t in agenda if t["completed"])counts the completed ones with no explicit loop, andsum(t['days'] for t in agenda)accumulates the days: comprehensions from 05-01 used insidesum.summary.setdefault(assignee, {...})creates the card of the assignee the first time they appear and returns it on the following ones: the counting pattern of 05-03, here building a dictionary of dictionaries on the fly. And{n.capitalize() for n in TEAM} - set(summary)is a set comprehension minus another set: who on the team has nothing assigned, in one line. Finally,if not agenda:takes advantage of an empty list being falsy (02-04) and reads better thanlen(agenda) == 0.
With this EasyTask finally does what Marta needs, and a single loose end remains, the one that gives the next lesson its title: when you close the program, everything disappears.
Common Mistakes and Tips
Forgetting the comma in the one-item tuple. ("Marta") is a string; ("Marta",) is a tuple. You notice when len() returns 5 instead of 1. And modifying a tuple raises TypeError: if you need to change values, use a list.
Unpacking with a different number of names. a, b = (1, 2, 3) raises ValueError; use a, *rest = ... if you do not know how many elements will come. Confusing the levels in a chained expression. agenda["title"] fails because agenda is a list: first you have to pick an element (agenda[0]) and then the key. If you get lost, print the intermediate steps. And copying a nested structure shallowly does not protect the dictionaries inside: use copy.deepcopy when you want an independent tree.
Tip: keep the list of dictionaries homogeneous. Having them all with the same keys is what lets you traverse it without checks; if a record may lack a field, give it a default value when creating it instead of omitting the key. And do not nest more than three levels: data["a"][0]["b"][2]["c"] is undecipherable and fragile. Extract substructures into named variables (task = agenda[i]) or rethink the design; beyond a certain complexity, what the problem is asking for is classes.
Exercises
Exercise 1: Predict the output
Say what each line prints and which errors occur.
t = ("Book fair poster", "Marta", 3)
title, assignee, days = t
print(days, t[-1], len(("Marta",)), len(("Marta")))
a, b = 1, 2
a, b = b, a
print(a, b)
first, *rest = ["Marta", "Luis", "Nuria"]
print(first, rest)
agenda = [{"title": "Poster", "days": 3}, {"title": "Menu", "days": 5}]
shallow = agenda.copy()
shallow[0]["days"] = 99
print(agenda[0]["days"])
print(agenda["title"])Exercise 2: Queries over the agenda
With the agenda below, write the expressions or the code needed to obtain: the titles of the high-priority tasks; the total pending days (those of the tasks not completed); the task with the most days; the list of titles sorted alphabetically ignoring case; and a dictionary {assignee: number_of_tasks}.
agenda = [
{"title": "Book fair poster", "assignee": "Marta", "priority": "high",
"days": 3, "completed": False},
{"title": "bakery menu", "assignee": "Luis", "priority": "medium",
"days": 5, "completed": True},
{"title": "Vidal logo", "assignee": "Marta", "priority": "high",
"days": 2, "completed": False}]Exercise 3: Grouping by assignee
Write group_by_assignee(agenda) that returns a dictionary of lists with the titles of each person, including the members of TEAM with no task at all (with an empty list). Then write print_groups(groups) that prints it with the assignees sorted by number of tasks in descending order and the titles alphabetically.
Solutions
Solution 1.
("Marta",) has one element; ("Marta") is the string "Marta", whose length is 5. The swap leaves a = 2 and b = 1 because the right-hand side is evaluated in full before assigning. *rest collects what is left over into a list. shallow is a shallow copy: its elements are the same dictionaries, so the 99 shows up in the original. And the last line fails because agenda is a list and "title" is not an index: the missing step was going down a level with agenda[0]["title"].
Solution 2.
high = [t["title"] for t in agenda if t["priority"] == "high"]
pending = sum(t["days"] for t in agenda if not t["completed"])
longest = max(agenda, key=lambda t: t["days"])
alphabetical = sorted([t["title"] for t in agenda], key=str.lower)
counts = {}
for t in agenda:
counts[t["assignee"]] = counts.get(t["assignee"], 0) + 1
print(high, pending) # ['Book fair poster', 'Vidal logo'] 5
print(longest["title"]) # bakery menu
print(alphabetical) # ['bakery menu', 'Book fair poster', 'Vidal logo']
print(counts) # {'Marta': 2, 'Luis': 1}Two details. max(agenda, key=...) returns the whole dictionary, not the days, which is why ["title"] is needed to see the name. And the key=str.lower of the alphabetical order is the one from 05-02: without it, "bakery menu" would end up last for starting with a lower-case letter.
Solution 3.
TEAM = ("Marta", "Luis", "Nuria")
def group_by_assignee(agenda): # TEAM capitalised for this exercise
"""Return {assignee: [titles]} including those with no tasks."""
groups = {name: [] for name in TEAM} # dictionary comprehension
for task in agenda:
groups.setdefault(task["assignee"], []).append(task["title"])
return groups
def print_groups(groups):
"""Print each assignee with their tasks in order."""
for assignee, titles in sorted(groups.items(), key=lambda p: -len(p[1])):
print(f"{assignee} ({len(titles)}):")
for title in sorted(titles, key=str.lower):
print(f" - {title}")
print_groups(group_by_assignee(agenda))The initial dictionary comprehension guarantees that all the team members show up, even if they have no tasks: without it, Nuria would be left out of the report. The setdefault that comes afterwards covers the opposite case —an assignee who is not in TEAM—, so the function does not break with unexpected data. And key=lambda p: -len(p[1]) sorts from most to fewest using the negative-sign trick, an alternative to reverse=True when the criterion is numeric.
Conclusion
A tuple is an immutable sequence: what creates it is the commas, the one-item one needs the trailing comma (x,), and its natural role is the record of fixed fields, the constant and the returned value. Unpacking distributes its elements into variables (title, assignee, days = task), allows the one-line swap and accepts *rest to collect what is left over. With that, the promise of 04-02 is fulfilled: returning several values is returning a tuple, and a function always returns a single object. Being immutable, tuples work as dictionary keys to index by combinations, and namedtuple exists in case you meet it.
Nested structures boil down in practice to three patterns: list of dictionaries for many records, dictionary of lists for grouping and dictionary of dictionaries for indexed cards. They are read from left to right going down a level with each bracket, they are traversed with nested loops and unpacking in the for, and they hide a trap: the shallow copy does not duplicate the inside, and that is what copy.deepcopy is for. EasyTask has reached v0.10, and it is a real program: the agenda is a list of dictionaries, the listing comes out numbered and sorted by priority, choose_task returns the task that is then modified in place, and a summary groups the work by assignee pointing out who is free. The parallel lists of v0.8 and their risk of falling out of sync have disappeared completely. But there is still an unhappy ending: when Marta closes the program, the entire agenda evaporates. All that structure lives only in memory. In Saving data to files: text, CSV and JSON you will learn to write it to disk and recover it on start-up, so that the studio's work is still there tomorrow morning. It is the last loose end of the module.
Fundamentals of Programming
Module 1: Introduction to Programming
- What is programming?
- History of programming
- Programming languages
- Development environments
- From problem to algorithm
Module 2: Core Concepts
- Variables and data types
- Operators and expressions
- Input and output
- Type conversion and data validation
Module 3: Control Structures
Module 4: Functions and Procedures
- Defining and using functions
- Parameters and return values
- Variable scope
- Breaking a program down into functions
- Functions as values: lambda and higher order
Module 5: Data Structures
- Lists and arrays
- Strings
- Dictionaries and sets
- Tuples and nested structures
- Saving data to files: text, CSV and JSON
Module 6: Basic Algorithms
Module 7: Objects and Code Organisation
- From data to objects: classes and instances
- Attributes, methods and the constructor
- Collections of objects
- Modules, packages and imports
Module 8: Good Practices and Tools
- Documentation and comments
- Debugging and error handling
- Version control
- Automated testing
- Style, readability and refactoring
