Collections as Types
So far, every variable you have created holds a single value:
title: str = "My First Note"
word_count: int = 350
is_draft: bool = True
One variable, one value. But what happens when a note needs three tags? Or a student has five quiz scores? You could create separate variables:
tag1: str = "python"
tag2: str = "ai"
tag3: str = "beginner"
This falls apart quickly. What if a note has ten tags? Or the number of tags changes? You need a way to store multiple values in a single variable.
That is what a collection is: a variable that holds a group of values instead of just one. Python gives you four collection types, each designed for a different situation:
| Type | What It Holds | Example |
|---|---|---|
| list | Multiple values in order | Tags for a note |
| dict | Pairs of names and values | Quiz name → score |
| tuple | A fixed group of values that never changes | An (x, y) coordinate |
| set | Unique values only, no duplicates | All unique tags across notes |
Just like you write title: str to tell Python "this is a string," you write tags: list[str] to tell Python "this is a list of strings." The type annotation goes inside square brackets to describe what the collection contains.
This lesson covers all four types: how to create them, how to access their values, and when to use each one. We will spend the most time on lists and dicts because you will use them constantly. Tuples and sets are shorter and lighter; they solve specific problems that lists and dicts do not.
Lists: Ordered, Typed Groups
A list holds multiple values in order. You create one with square brackets [], and separate the values with commas:
tags: list[str] = ["python", "ai", "beginner"]
This creates a list with three strings inside it. The type annotation list[str] tells Python (and pyright) two things: this variable is a list, and every item inside it is a str. If you accidentally put a number in a list[str], pyright catches it.
Lists can hold other types too:
scores: list[int] = [85, 92, 78, 95]
prices: list[float] = [9.99, 14.50, 3.25]
The pattern is always the same: list[type], where you replace type with whatever the items are. Strings go in list[str], integers go in list[int], floats go in list[float].
Accessing List Elements
You already know how to access characters in a string with title[0] (Lesson 2). Lists work the same way. Each item has a position starting from 0:
tags: list[str] = ["python", "ai", "beginner"]
# 0 1 2
Get a single item by its position:
tags: list[str] = ["python", "ai", "beginner"]
print(tags[0]) # first item
print(tags[1]) # second item
print(tags[-1]) # last item (negative indexing works here too)
Output:
python
ai
beginner
How many items? Use len()
Just like len("hello") counts characters in a string, len() counts items in a list:
tags: list[str] = ["python", "ai", "beginner"]
print(len(tags))
Output:
3
Is this item in the list? Use in
The word in checks whether a value exists in the list. It gives back True or False:
tags: list[str] = ["python", "ai", "beginner"]
print("ai" in tags)
print("web" in tags)
Output:
True
False
"ai" in tags checks each item in the list and asks: "is this item exactly "ai"?" It finds a match at position 1, so it returns True. "web" is not in the list, so it returns False.
Quick reference
Given this list:
tags: list[str] = ["python", "ai", "beginner"]
| What You Want | How You Write It | What You Get |
|---|---|---|
| First item | tags[0] | "python" |
| Last item | tags[-1] | "beginner" |
| Second item | tags[1] | "ai" |
| How many items? | len(tags) | 3 |
Is "ai" in the list? | "ai" in tags | True |
Is "web" in the list? | "web" in tags | False |
Dicts: Looking Up Values by Name
A list lets you store multiple values, but you access them by position number: scores[0], scores[1]. What if you want to look something up by name instead of by number?
That is what a dict (short for dictionary) does. Think of a real dictionary: you look up a word (the key) and get its definition (the value). In Python, you choose what the keys and values are:
scores: dict[str, int] = {"quiz1": 85, "quiz2": 92}
This creates a dict with two entries. Each entry has a key (a string like "quiz1") and a value (an integer like 85). You create a dict with curly braces {}, and separate each key from its value with a colon :.
The type annotation dict[str, int] tells Python two things: the keys are strings, and the values are integers.
Another example:
metadata: dict[str, str] = {"author": "James", "topic": "Python"}
Here both keys and values are strings, so the annotation is dict[str, str].
Keys and values can be any type. You pick what makes sense for your data:
# String keys, integer values (quiz scores)
scores: dict[str, int] = {"quiz1": 85, "quiz2": 92}
# String keys, string values (metadata)
metadata: dict[str, str] = {"author": "James", "topic": "Python"}
# Integer keys, string values (student IDs to names)
students: dict[int, str] = {101: "James", 102: "Emma", 103: "Alice"}
# String keys, float values (item prices)
prices: dict[str, float] = {"notebook": 9.99, "pen": 1.50}
The first type inside the brackets is always the key type, the second is always the value type. dict[int, str] means integer keys and string values. Python does not require keys to be strings.
Accessing Dict Values
With a list, you use a position number: tags[0]. With a dict, you use the key name:
scores: dict[str, int] = {"quiz1": 85, "quiz2": 92}
print(scores["quiz1"])
print(scores["quiz2"])
Output:
85
92
Give it the key, get back the value. Like looking up a word in a dictionary.
How many entries? Use len()
scores: dict[str, int] = {"quiz1": 85, "quiz2": 92}
print(len(scores))
Output:
2
Is this key in the dict? Use in
in checks whether a key exists (not a value):
scores: dict[str, int] = {"quiz1": 85, "quiz2": 92}
print("quiz1" in scores)
print("quiz3" in scores)
Output:
True
False
What if the key doesn't exist?
If you try to access a key that is not in the dict, Python crashes with a KeyError:
scores: dict[str, int] = {"quiz1": 85, "quiz2": 92}
print(scores["quiz3"]) # KeyError: 'quiz3'
The safe alternative is .get(). It returns a default value you choose instead of crashing:
scores: dict[str, int] = {"quiz1": 85, "quiz2": 92}
print(scores.get("quiz3", 0))
Output:
0
.get("quiz3", 0) means: "give me the value for "quiz3". If it does not exist, give me 0 instead."
Quick reference
Given this dict:
scores: dict[str, int] = {"quiz1": 85, "quiz2": 92}
| What You Want | How You Write It | What You Get |
|---|---|---|
Value for "quiz1" | scores["quiz1"] | 85 |
Value for "quiz2" | scores["quiz2"] | 92 |
| How many entries? | len(scores) | 2 |
Is "quiz1" a key? | "quiz1" in scores | True |
Is "quiz3" a key? | "quiz3" in scores | False |
Value for "quiz3" (missing key, no crash) | scores.get("quiz3", 0) | 0 |
Tuples: Groups That Never Change
A tuple is like a list, but with one big difference: once created, it cannot be changed. No adding, no removing, no modifying.
Why would you want that? Sometimes you have a small group of values that belong together and should stay exactly as they are. A coordinate pair (x, y), a person's name and age, a date with year, month, and day:
point: tuple[float, float] = (3.14, 2.71)
name_age: tuple[str, int] = ("James", 30)
You create a tuple with parentheses () instead of square brackets [].
Notice the type annotation: tuple[float, float] does not mean "a tuple of floats." It means "a tuple with exactly two items, and both are floats." Each position has its own type. Compare:
| Annotation | Meaning |
|---|---|
list[str] | Any number of strings |
tuple[str, int] | Exactly two items: a string, then an int |
tuple[str, str, int] | Exactly three items: two strings, then an int |
Accessing Tuple Items
Same indexing as lists and strings:
point: tuple[float, float] = (3.14, 2.71)
print(point[0])
print(point[1])
Output:
3.14
2.71
You can also unpack a tuple into separate variables in one line:
point: tuple[float, float] = (3.14, 2.71)
x, y = point
print(x)
print(y)
Output:
3.14
2.71
You cannot change a tuple
This is the whole point of tuples. If you try to change an item, Python raises an error:
point: tuple[float, float] = (3.14, 2.71)
# point[0] = 1.0 # TypeError: 'tuple' object does not support item assignment
This is the same immutability concept from strings in Lesson 3. Strings cannot be changed, and neither can tuples. If you need a group that can change, use a list.
Sets: Collections With No Duplicates
Imagine you have a list of tags from many notes, and you want to know which unique tags exist. Some tags will appear multiple times, but you only want each one once.
That is what a set does. It automatically removes duplicates:
all_tags: set[str] = {"python", "ai", "python", "beginner"}
print(all_tags)
Output:
{'python', 'ai', 'beginner'}
We put "python" in twice, but the set keeps only one copy. Sets use curly braces {} like dicts, but without the colon : between key and value.
Checking membership
in works on sets just like lists:
all_tags: set[str] = {"python", "ai", "beginner"}
print("ai" in all_tags)
print("web" in all_tags)
print(len(all_tags))
Output:
True
False
3
Sets have no order
Unlike lists, sets do not have positions. You cannot access items by index:
all_tags: set[str] = {"python", "ai", "beginner"}
# all_tags[0] # TypeError: 'set' object is not subscriptable
If you need to access items by position, use a list. If you only need to check "is this item in the collection?" and want no duplicates, use a set.
When to use a set vs a list
| Question | Use |
|---|---|
| Do you need items in a specific order? | list |
| Do you need to access items by position? | list |
| Do you only care about unique values? | set |
| Do you just need to check if something exists? | set |
Mutability Summary
In Lesson 3, you learned that strings are immutable: you cannot change them after creation. The same idea applies to collections. Some collections let you add, remove, or change items after creation. These are called mutable (changeable). Others lock their contents permanently. These are called immutable (unchangeable).
Now that you have seen all four collection types, here is how they compare:
| Type | Can change after creation? | Created with |
|---|---|---|
list | Yes — add, remove, modify items | [] square brackets |
dict | Yes — add, remove, modify entries | {} curly braces with : |
tuple | No — cannot change anything | () parentheses |
set | Yes — add and remove items | {} curly braces without : |
str | No — methods return new strings | "" quotes |
Lists, dicts, and sets can change after creation (they are mutable). Tuples and strings cannot (they are immutable). You already learned about string immutability in Lesson 3; tuples follow the same rule.
PRIMM-AI+ Practice: Predicting Collection Access
Predict [AI-FREE]
Press Shift+Tab to enter Plan Mode before predicting.
Look at these collection access expressions. Without running anything, predict the exact result of each one. Write your predictions and a confidence score from 1 to 5 before checking.
tags: list[str] = ["python", "ai", "web"]
scores: dict[str, int] = {"quiz1": 85, "quiz2": 92}
point: tuple[float, float] = (3.14, 2.71)
result1: str = tags[1]
result2: int = scores["quiz2"]
result3: float = point[0]
result4: bool = "ruby" in tags
Check your predictions
result1: "ai". Index 1 is the second element (zero-based). The list is ["python", "ai", "web"].
result2: 92. The key "quiz2" maps to the value 92.
result3: 3.14. Tuple indexing works like list indexing: position 0 is the first element.
result4: False. The tag "ruby" is not in the list ["python", "ai", "web"].
If you got all four correct, your collection access intuition is solid. The most common mistake is using the wrong index (forgetting zero-based) or confusing dict key lookup with list indexing.
Run
Press Shift+Tab to exit Plan Mode.
Create a file called collection_practice.py with the expressions above. Add print() calls for each one. Run it with uv run python collection_practice.py. Compare the output to your predictions.
Investigate
For each collection access, write one sentence explaining how Python finds the value. Lists and tuples use numeric positions; dicts use key names. What happens if you try scores[0]? (Hint: 0 is an int, not a str key.)
If you want to go deeper, run /investigate @collection_practice.py in Claude Code and ask what pyright reports when you try scores[0] on a dict[str, int].
Modify
Change the starting data and predict how the results change:
- Change
tagsto["python", "ai", "beginner", "web"](4 items instead of 3). Predictlen(tags)andtags[-1]before running. - Change
scoresto{"quiz1": 85, "quiz2": 92, "quiz3": 70}(3 entries instead of 2). Predictlen(scores)andscores["quiz3"]before running.
Make [Mastery Gate]
Write type annotations for SmartNotes data from scratch:
tags: list[str]with 3 tagsmetadata: dict[str, str]with author and topicscores: dict[str, int]with 2 quiz scores- Access one element from each collection and print it
Run uv run pyright on your file. Zero errors means you pass.
Try With AI
If Claude Code is not already running, open your terminal, navigate to your SmartNotes project folder, and type claude. If you need a refresher, Chapter 44 covers the setup.
Prompt 1: Test Your Type Annotation Understanding
I'm learning Python collection types. Here is my understanding:
"list[str] means a list of strings, dict[str, int] means keys
are strings and values are integers, and tuples cannot be
changed after creation." Is this accurate? What am I missing?
Read AI's response. Did it mention that tuple annotations specify the type of each position (not just "a tuple of strings")? Did it explain set uniqueness? Compare its answer to what you learned in this lesson.
What you're learning: You are verifying that your mental model covers all four collection types and their key differences. Gaps in understanding here lead to bugs in function signatures later.
Prompt 2: Generate Collection Declarations
Write Python declarations for a simple student grade tracker.
I need: a list of student names, a dict mapping names to
grades (integers), a tuple representing a grade range
(minimum and maximum as floats), and a set of passing
grades. Use full type annotations on every variable.
Read AI's output. Check: did it use list[str], dict[str, int], tuple[float, float], and set[int]? Are the annotations correct for each collection? If anything looks wrong, tell AI what to fix.
What you're learning: You are reviewing AI-generated type annotations against the collection rules from this lesson. Catching type annotation errors early prevents subtle bugs when functions use these collections.
James counts on his fingers. "Lists for ordered groups. Dicts for looking things up by name. Tuples for fixed data. Sets for unique values. And pyright checks all of them."
"You forgot the most important part," Emma says.
"What?"
".get(). When you access a dict key that might not exist, use .get() with a default value. Otherwise your program crashes with a KeyError."
James nods. "So now I can store tags as list[str] and scores as dict[str, int]. But what if I need a list of dicts? Like a gradebook where each student has their own score dict?"
"That is a nested type," Emma says. "A collection inside a collection. list[dict[str, int]]. It looks complex, but you already know both pieces. Lesson 5 puts them together."