The two previous lessons left a very concrete problem on the table. The list solved the "many tasks" part, but to store the fields of each task it forced us to keep five parallel lists in sync by hand: one forgotten pop and the listing starts lying. The underlying reason is that in a list data is identified by position, and position means nothing: task[1] does not tell you whether 1 is the assignee or the priority.

This lesson introduces two structures that solve different problems and are almost always taught together. The dictionary stores each piece of data with its name, so that task["assignee"] explains itself. The set stores unique elements with no order, and it is the right tool for questions of the kind "is this in here?" or "which distinct tags do we use?". At the end, EasyTask will make the jump to v0.9 and a task will become, at last, a single thing.

Contents

  1. Key and value: the problem the dictionary solves
  2. Creating dictionaries and accessing their values
  3. Adding, modifying and deleting
  4. Traversing a dictionary
  5. update, setdefault and what can be a key
  6. Dictionary comprehension and frequency counting
  7. Sets: unique elements with no order
  8. Operations between sets
  9. Typical uses of sets
  10. List, dictionary or set: decision table
  11. EasyTask v0.9: the task is a dictionary
  12. Common mistakes and tips
  13. Exercises
  14. Conclusion

  1. Key and value: the problem the dictionary solves

A dictionary (type dict) is a collection of key → value pairs. The key replaces the numeric index: instead of remembering that the assignee is at position 1, you ask for it by name.

task = {"title": "Book fair poster", "assignee": "Marta",
        "priority": "high", "days": 3, "completed": False}
print(task["assignee"])         # Marta
print(len(task))                # 5  -> number of pairs

Compare the two ways of storing the same task:

List Dictionary
Accessed by Position: task[1] Name: task["assignee"]
Reads by itself No: you must remember the order Yes
Adding a new field Breaks every later index Just adds a key
Order of the elements Meaningful Irrelevant for access
Good use Many things of the same type The different fields of one thing

That last row is the decision rule: list for "many", dictionary for "one with several fields". The people of the studio are a list (["Marta", "Luis", "Nuria"]); a specific task is a dictionary. And when you want many tasks, it will be a list of dictionaries, which is exactly 05-04.

  1. Creating dictionaries and accessing their values

There are two ways of creating one, and an empty dictionary is written {}:

task = {"title": "Bakery menu", "days": 5}      # literal with braces
other = dict(title="Bakery menu", days=5)       # with dict() and keyword arguments
empty = {}
print(task == other)                # True -> they are equivalent
print(task["title"])                # Bakery menu
print(task["assignee"])             # KeyError: 'assignee'
print(task.get("assignee"))         # None  -> it does not fail
print(task.get("assignee", "unassigned"))       # unassigned

To read a value there are therefore two routes, and choosing well matters:

Form If the key exists If it does not
d[key] Returns the value KeyError and the program stops
d.get(key) Returns the value Returns None
d.get(key, default) Returns the value Returns default

The practical rule: use d[key] when the key must be there (if it is not, that is a programming error and you want to know about it), and .get() when its absence is a normal possibility. To check explicitly there is the in operator, which in a dictionary looks at the keys and not at the values: "title" in task is True, but "Bakery menu" in task is False because that is a value.

  1. Adding, modifying and deleting

Adding and modifying are written the same way, and that is one of the traps of the type: if the key does not exist, it is created; if it exists, it is overwritten without warning.

task = {"title": "Bakery menu", "days": 5}
task["assignee"] = "Luis"           # the key does not exist: it is added
task["days"] = 6                    # the key exists: it is overwritten
del task["days"]                    # deletes; KeyError if it does not exist
value = task.pop("assignee")        # deletes and returns the value
another = task.pop("client", "none")    # with a default: no failure if absent
task.clear()                        # leaves the dictionary empty

del is the direct way of deleting; .pop(key) is the one you use when you also need the value that comes out, just as with lists. And as with lists, the dictionary is mutable: if you pass it to a function and the function modifies it, the change is visible outside. This is what will let change_priority(task) work without returning anything.

  1. Traversing a dictionary

A direct for over a dictionary traverses the keys. The three view methods let you choose what you want to traverse:

Method Produces on each pass
.keys() The keys (the same as the direct for)
.values() The values
.items() Pairs (key, value)
task = {"title": "Book fair poster", "assignee": "Marta", "days": 3}
for key in task:                        # equivalent to task.keys()
    print(key, end=" ")                 # title assignee days
for value in task.values():
    print(value, end=" ")               # Book fair poster Marta 3
for key, value in task.items():
    print(f"{key:<12}: {value}")
for key, value in sorted(task.items()): # alphabetical traversal by key
    print(key, value)

.items() is the one you will use 90 % of the time, because you almost always need both things at once. Notice that the unpacking for key, value in ... is the same mechanism you already saw with enumerate and zip in 05-01. Since Python 3.7 the dictionary preserves the insertion order when traversed, but do not lean on that to give meaning to positions: if you need a specific order, ask for it explicitly with sorted(), as in the last line.

  1. update, setdefault and what can be a key

.update(other) merges another dictionary into the first one: it adds the new keys and overwrites the matching ones. It is the clean way of applying several changes at once.

.setdefault(key, value) returns the value of the key if it exists and, if not, creates it with the given value: it is used to guarantee that a key is present without crushing whatever was there. And about keys there is a strict rule: a key must be immutable. Strings, numbers, booleans and tuples (05-04) are allowed; a list or another dictionary is not.

task = {"title": "Book fair poster", "days": 3}
task.update({"days": 4, "priority": "high"})    # merges: days becomes 4
task.setdefault("completed", False)     # it was not there: creates it as False
task.setdefault("days", 99)             # it was there: touches nothing, returns 4

valid = {"marta": 3, 2026: "year", (1, 2): "coordinate"}
invalid = {["marta"]: 3}        # TypeError: unhashable type: 'list'

The reason, without going into detail: the dictionary places each value in a spot computed from the key, so if the key could change afterwards, the value would be lost in the wrong spot. The values, by contrast, can be whatever you like: lists, other dictionaries, functions. And keys are unique: writing the same one twice in a literal leaves only the last.

  1. Dictionary comprehension and frequency counting

Just like lists, dictionaries accept a comprehension, with the same syntax plus the key: value pair:

days_per_task = {"Book fair poster": 3, "Bakery menu": 5, "Vidal logo": 2}
in_hours = {t: d * 8 for t, d in days_per_task.items()}
print(in_hours)     # {'Book fair poster': 24, 'Bakery menu': 40, 'Vidal logo': 16}
long_ones = {t: d for t, d in days_per_task.items() if d > 2}
print(long_ones)    # {'Book fair poster': 3, 'Bakery menu': 5}

And now a classic pattern that shows up in every program in the world: counting frequencies. The idea is to use the dictionary as a scoreboard, with the element as the key and the count as the value.

assignees = ["Marta", "Luis", "Marta", "Nuria", "Marta", "Luis"]
counts = {}
for name in assignees:
    counts[name] = counts.get(name, 0) + 1
print(counts)                       # {'Marta': 3, 'Luis': 2, 'Nuria': 1}
print(max(counts, key=counts.get))  # Marta -> who has the most tasks
print(sorted(counts.items(), key=lambda p: p[1], reverse=True))
# [('Marta', 3), ('Luis', 2), ('Nuria', 1)]

The decisive line is counts[name] = counts.get(name, 0) + 1, and it deserves a slow read. counts.get(name, 0) returns the current count or 0 if it is the first time that name appears; 1 is added to it and it is stored. That default 0 is what avoids the KeyError of the first pass and saves an if name in counts:. Then, max(counts, key=counts.get) traverses the keys and compares them by their value —the key from 04-05 applied to a dictionary— and sorted(...) sorts the pairs by the second element; how it sorts internally is still the subject of Sorting algorithms.

  1. Sets: unique elements with no order

A set (set) is a collection of unique elements with no order. There are no indices: my_set[0] raises an error. In exchange, it answers the question "is this inside?" immediately and removes duplicates effortlessly.

tags = {"design", "printing", "client", "design"}
print(tags)             # {'client', 'design', 'printing'}  -> the duplicate is gone
print(len(tags), "printing" in tags)        # 3 True
empty_dict = {}                                 # careful: this is a dict
empty_set = set()                               # the empty set is written like this
from_list = set(["Marta", "Luis", "Marta"])     # {'Marta', 'Luis'}

The order in which it prints is not the order you wrote and you must not rely on it. And beware of the creation trap shown by the last three lines: {} is not an empty set, but an empty dictionary. There are four basic methods, and the difference between the two deletion ones is the usual one:

Method What it does If the element is absent
.add(x) Adds x (if it was already there, nothing happens)
.discard(x) Removes it Does nothing
.remove(x) Removes it KeyError
.pop() Takes out one arbitrary element KeyError if it is empty

There is also frozenset, the immutable version of the set: once created it accepts neither add nor remove, and its main usefulness is being usable as a dictionary key, which a normal set cannot.

  1. Operations between sets

This is where sets shine: they directly implement the operations of set theory, with single-character operators.

We start from marta = {"poster", "menu", "logo"} and luis = {"menu", "flyer"}.

Operation Operator Method Result Means
Union marta | luis .union() {'poster','menu','logo','flyer'} In one or the other
Intersection marta & luis .intersection() {'menu'} In both
Difference marta - luis .difference() {'poster','logo'} In the first and not in the second
Symmetric difference marta ^ luis .symmetric_difference() {'poster','logo','flyer'} In one but not in both
graph LR
    A["Only MARTA<br/>poster, logo"] --- C["SHARED<br/>menu"]
    C --- D["Only LUIS<br/>flyer"]

The diagram reads like this: menu is in the shared zone (the intersection), poster and logo are exclusive to Marta (the difference marta - luis) and flyer is exclusive to Luis. The union is all four; the symmetric difference, the three outside the centre. There is also luis <= marta, which checks whether a set is contained in another (here, False), and >= for the opposite: they are useful for validating things like "are all the priorities entered among the allowed ones?".

  1. Typical uses of sets

Two uses cover almost every real case. The first, removing duplicates from a list in one line: with assignees = ["Marta", "Luis", "Marta", "Nuria", "Luis"], the expression sorted(set(assignees)) returns ['Luis', 'Marta', 'Nuria']. Notice the sorted(): converting to a set loses the original order, so if order matters you have to sort afterwards (or use another technique). The second use is checking membership in large collections: asking x in my_set is vastly faster than x in my_list when there are thousands of elements, because the set traverses nothing. The exact reason is studied in Search algorithms and Efficiency and Big-O notation; for now keep the practical consequence: if your program does many membership checks over a collection with no duplicates, that collection should be a set.

  1. List, dictionary or set: decision table

Criterion List Dictionary Set
Syntax [1, 2] {"a": 1} {1, 2}
Accessed by Index Key Not accessed: you ask with in
Does it keep the order? Yes, and it means something Yes, insertion order No
Does it allow duplicates? Yes Keys no, values yes No
Finding an element Traverses Direct Direct
Typical case Many things of the same kind The fields of one thing Tags, membership, deduplicating

The questions that lead to the right answer: does the order matter, or am I going to traverse them in sequence? List. Does each piece of data have a name of its own? Dictionary. Do I only care whether something is there or not, and I want no repeats? Set.

  1. EasyTask v0.9: the task is a dictionary

In v0.8 we achieved many tasks at the cost of five fragile parallel lists. Now we are going to fix the other half of the problem —the shape of a task— and to see it clearly we go back temporarily to managing a single one: in v0.9 the task is a dictionary, and in 05-04 we will join the two ideas into a list of dictionaries.

# easytask.py - Alba Studio / Version 0.9: every task is a dictionary

WIDTH = 46
PRIORITIES = ("high", "medium", "low")
TEAM = ("marta", "luis", "nuria")
OPTIONS = ("1", "2", "3", "4", "5")
TAGS = {"design", "printing", "client"}

# --- Input and logic unchanged: ask_text, ask_option, ask_integer,
#     confirm, classify_urgency, show_menu, pause ---

def register_task():
    """Ask for the data of a task and return the complete dictionary."""
    return {
        "title": ask_text("Title         : "),
        "assignee": ask_option("Assignee      : ", TEAM).capitalize(),
        "priority": ask_option("Priority      : ", PRIORITIES),
        "days": ask_integer("Days (1-365)  : ", 1, 365),
        "tag": ask_option("Tag           : ", sorted(TAGS)),
        "completed": False,
    }

def show_card(task):
    """Print the full card of the task received."""
    status = "Completed" if task["completed"] else "Pending"
    days_status = str(task["days"]) + " / " + status
    urgency = classify_urgency(task["priority"], task["days"])
    print("-" * WIDTH)
    for label, key in (("Title", "title"), ("Assignee", "assignee"),
                       ("Priority", "priority"), ("Tag", "tag")):
        print(f"{label:<14}{task[key]:>{WIDTH - 14}}")
    print(f"{'Days / status':<14}{days_status:>{WIDTH - 14}}")
    print(f"{'Urgency':<14}{urgency:>{WIDTH - 14}}")

def change_priority(task):
    """Change the priority of the task received (modifies it in place)."""
    previous = task["priority"]
    task["priority"] = ask_option("New priority: ", PRIORITIES)
    print(f"Priority of '{task['title']}': {previous} -> {task['priority']}")

def mark_completed(task):
    """Mark the task as completed after confirming."""
    if task["completed"]:
        print("The task was already completed.")
    elif confirm(f"Mark '{task['title']}' as completed?"):
        task["completed"] = True
        print("Task completed. Good work.")

def main():
    """Run the main loop of the application."""
    task = None                     # None means 'there is no task yet'
    while True:
        show_menu()
        option = ask_option("Choose an option (1-5): ", OPTIONS)
        if option == "5":
            if confirm("Are you sure you want to exit?"):
                break
        elif option == "1":
            if task is None or confirm(f"'{task['title']}' exists. Replace it?"):
                task = register_task()
                print("Task registered successfully.")
        elif task is None:
            print("There is no task registered yet.")
        elif option == "2":
            show_card(task)
        elif option == "3":
            change_priority(task)
        elif option == "4":
            mark_completed(task)
        pause()

if __name__ == "__main__":
    main()

What has changed with respect to v0.7, and why it matters:

  • show_card(task) has one parameter instead of five. The six separate variables that travelled together from function to function were, indeed, a single thing; now they have a name and a shape.
  • Adding a field no longer forces you to touch any signature. We have brought in "tag" without changing the header of anything. With v0.7 it would have meant one more parameter in four functions.
  • The action functions modify the dictionary and return nothing. change_priority(task) works because the dictionary is mutable and is passed by reference, like the lists of 05-01. The name clearly announces that it modifies.
  • TAGS is a set, because they are unique unordered values whose use is checking membership and offering options; sorted(TAGS) is passed to ask_option so the user always sees them the same way. And task is None replaces the has_task flag: one less piece of data to keep in sync.

The important thing is still unsolved: there is only one task. But now that a task is a single value, storing all of them will be as easy as putting them in a list.

Common Mistakes and Tips

KeyError when reading a key that does not exist. Check with in or use .get(key, default). And remember that keys are case sensitive: "Title" and "title" are different; pick a convention (lower case) and stick to it. Misspelling a key when assigning. task["assignne"] = "Luis" does not raise an error: it creates a new key. The symptom shows up later, when task["assignee"] returns the old value. It is the most treacherous mistake of the type.

Believing that {} is an empty set. It is an empty dictionary; the empty set is set(). And do not expect order in a set: if you need to display it sorted, sorted(my_set).

Using a list as a key. TypeError: unhashable type. If you need a compound key, use a tuple (05-04).

Modifying a dictionary while you traverse it. Adding or deleting keys inside the for raises RuntimeError; traverse a copy (for k in list(d.keys()):) or build a new dictionary. Tip: use the dictionary for fields, not as an improvised database. If you end up with keys "task1", "task2", "task3", what you want is a list of dictionaries (05-04).

Exercises

Exercise 1: Predict the output

Say what each line prints and which errors occur.

task = {"title": "Book fair poster", "days": 3}
print(task.get("assignee", "unassigned"))
task["days"] = task["days"] + 2
task.setdefault("days", 99)
task.setdefault("priority", "medium")
print(task)
print("Book fair poster" in task)
print(len({"a", "b", "a", "b"}))
print({"a", "b"} & {"b", "c"}, {"a", "b"} - {"b", "c"})
print(task["completed"])

Exercise 2: Studio summary

Given the list of assignees of the open tasks, write a program that prints: how many tasks each person carries (sorted from most to fewest), who carries the most, and which members of the team TEAM = ("Marta", "Luis", "Nuria") have no task assigned. Use a dictionary to count and a set for the last question.

assignments = ["Marta", "Luis", "Marta", "Marta", "Luis"]

Exercise 3: Card with a dictionary

Write create_task(title, assignee, priority, days) that returns the dictionary of a task with completed set to False, and summary(task) that returns a one-line string with the title, the assignee, the priority in upper case, the days and [X] or [ ] depending on the status. Add apply_changes(task, changes) that receives a dictionary of changes and applies them, warning if some key did not exist in the task.

Solutions

Solution 1.

unassigned
{'title': 'Book fair poster', 'days': 5, 'priority': 'medium'}
False
2
{'b'} {'a'}
KeyError: 'completed'

.get with a default does not fail. task["days"] + 2 leaves 5, and setdefault("days", 99) does not touch it because the key already exists; setdefault("priority", ...) does create it. "Book fair poster" in task is False because in looks at keys, not values. The set {"a","b","a","b"} has 2 elements. The intersection is {'b'} and the difference {'a'}. And the last line raises KeyError because that key was never created: with task.get("completed", False) it would have returned False.

Solution 2.

TEAM = ("Marta", "Luis", "Nuria")
assignments = ["Marta", "Luis", "Marta", "Marta", "Luis"]

counts = {}
for name in assignments:
    counts[name] = counts.get(name, 0) + 1

for name, total in sorted(counts.items(), key=lambda p: p[1], reverse=True):
    print(f"{name:<10}{total:>3} tasks")
print("Who carries the most:", max(counts, key=counts.get))

without_tasks = set(TEAM) - set(assignments)
print("Without tasks:", ", ".join(sorted(without_tasks)) or "nobody")

It prints "Marta 3 tasks", "Luis 2 tasks", "Who carries the most: Marta" and "Without tasks: Nuria". The last part is the perfect example of what a set is for: set(TEAM) - set(assignments) answers "who is in the team and not in the assignments" in a single expression, with no loops and no flags. With lists you would have had to traverse the team checking each name.

Solution 3.

def create_task(title, assignee, priority, days):
    """Return the dictionary of a new task, pending by default."""
    return {"title": title, "assignee": assignee,
            "priority": priority, "days": days, "completed": False}

def summary(task):
    """Return one line with the main data of the task."""
    mark = "[X]" if task["completed"] else "[ ]"
    return (f"{mark} {task['title']:<24}{task['assignee']:>8}"
            f"{task['priority'].upper():>8}{task['days']:>4}d")

def apply_changes(task, changes):
    """Apply the changes to the task and warn about unknown keys."""
    for key, value in changes.items():
        if key not in task:
            print(f"Warning: '{key}' is not a field of the task; it is ignored.")
            continue
        task[key] = value

t = create_task("Book fair poster", "Marta", "high", 3)
apply_changes(t, {"days": 4, "completed": True, "colour": "red"})
print(summary(t))       # [X] Book fair poster           Marta    HIGH   4d

The interesting part of apply_changes is the check if key not in task. Without it, a typo in the field name would silently add a new key —the treacherous mistake from the tips section— and you would never know why the change "did not work". Notice too that the function modifies the dictionary it receives and returns nothing: that is legitimate because the name announces it.

Conclusion

The dictionary stores key → value pairs and replaces position with name: it is created with {} or dict(), read with d[key] —which raises KeyError if it is missing— or with .get(key, default) —which does not fail—, extended and modified with the same assignment, and traversed with .keys(), .values() and above all .items(). .update() merges, .setdefault() guarantees without crushing, keys must be immutable and unique, and the dictionary comprehension and the frequency counting pattern with .get(x, 0) + 1 solve in three lines things that used to take twenty. The set stores unique elements with no order, is not indexed, is created with {...} or set() —never with {}, which is a dictionary—, and offers union |, intersection &, difference - and symmetric difference ^, besides being the right structure for deduplicating and for checking membership.

EasyTask is at v0.9 and a task is at last a single thing: a dictionary with title, assignee, priority, days, tag and completed. show_card(task) has one parameter instead of five, adding a field no longer forces you to touch any signature, and the studio's tags live in a set. One last assembly remains: v0.8 knew how to carry many tasks but with fragile parallel lists, and v0.9 knows how to represent one task perfectly but carries only one. Joining both ideas —a list of dictionaries— is what Tuples and nested structures does, where you will also meet the tuple, will finally understand what return a, b returned back in 04-02 and will learn to read expressions such as agenda[0]["assignee"] without blinking.

© Copyright 2026. All rights reserved