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

  1. What a tuple is
  2. Immutability: when a tuple and when a list
  3. Unpacking
  4. Returning several values is returning a tuple
  5. Tuples as keys and namedtuple
  6. Nested structures: the three patterns
  7. Reading a chained expression
  8. Traversing nested structures
  9. Shallow copy and deep copy
  10. Choosing the right structure
  11. EasyTask v0.10: the studio agenda
  12. Common mistakes and tips
  13. Exercises
  14. Conclusion

  1. 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.

  1. 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.

  1. 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.

  1. 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.5

In 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.

  1. Tuples as keys and namedtuple

Being 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 day

With 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.

  1. 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.

  1. 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:

  1. agenda → the complete list of tasks.
  2. agenda[0] → the first element of that list, which turns out to be a dictionary.
  3. 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.

  1. 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)      # sort

Here 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.

  1. 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 alone

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

  1. 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.

  1. 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_task returns 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 coordinated pop calls 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: enumerate produces pairs (number, task) and the key looks inside the second element of the pair. That way the listing comes out sorted but the numbers still work for choosing, and choose_task can reuse show_list instead of repeating the loop (DRY, 04-04).
  • sum(1 for t in agenda if t["completed"]) counts the completed ones with no explicit loop, and sum(t['days'] for t in agenda) accumulates the days: comprehensions from 05-01 used inside sum.
  • 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 than len(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.

3 3 1 5
2 1
Marta ['Luis', 'Nuria']
99
TypeError: list indices must be integers or slices, not str

("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.

© Copyright 2026. All rights reserved