Collections
Before you start
You need: control flow (Article 02).
Time: about 45 minutes, plus the practice.
Learning objective
Choose the correct collection for a task, and avoid the mutation and copying bugs Python's references invite.
Topics
- Lists
- Sorting
- Tuples
- Sets
- Dictionaries
- Nested data
- Copying — shallow versus deep
- The mutable default trap
collectionshelpers
Choosing
| Type | Ordered | Mutable | Duplicates | Lookup |
|---|---|---|---|---|
list | Yes | Yes | Yes | O(n) |
tuple | Yes | No | Yes | O(n) |
set | No | Yes | No | O(1) |
dict | Yes (insertion) | Yes | Keys unique | O(1) |
The lookup column decides most designs. Searching a list of 10,000 students for a roll number scans on average 5,000 items; a dictionary finds it in one step.
Lists
students = ["Ravi Kumar", "Priya Sharma", "Arjun Reddy"]
students.append("Sneha Patel")
students.insert(0, "Kiran Rao")
students.extend(["A", "B"])
students.remove("Ravi Kumar") # by value — ValueError if absent
popped = students.pop() # last
first = students.pop(0) # by index — O(n)
students.clear()
len(students)
"Ravi Kumar" in students # O(n)
students.index("Priya Sharma") # ValueError if absent
students.count("Ravi Kumar")
students.reverse() # in place
students[0]
students[-1]
students[1:3]
students[:2]
students[::-1] # reversed copy
students[::2] # every second item
remove and index raise ValueError when the value is absent. Check first, or catch it:
if "Ravi Kumar" in students:
students.remove("Ravi Kumar")
pop(0) shifts every remaining element. For a queue, use collections.deque, whose popleft is O(1).
Mutation versus a new list
numbers = [3, 1, 2]
numbers.sort() # mutates, returns None
sorted(numbers) # returns a new list
numbers.reverse() # mutates
reversed(numbers) # returns an iterator
sort() returns None. Assigning its result is a common error:
students = students.sort() # students is now None
students = sorted(students) # correct
The failure appears later, as TypeError: 'NoneType' object is not iterable, far from the cause.
Sorting
students.sort(key=lambda s: s.name)
students.sort(key=lambda s: s.marks, reverse=True)
by_marks = sorted(results, key=lambda r: r.marks_obtained, reverse=True)
# Several keys — class ascending, then marks descending
students.sort(key=lambda s: (s.class_name, -s.marks))
from operator import attrgetter, itemgetter
students.sort(key=attrgetter("class_name", "name"))
rows.sort(key=itemgetter(2))
key is a function producing the value to sort by. A tuple sorts by each element in turn; negating a number reverses just that one.
For a descending string alongside an ascending one, sort twice — Python's sort is stable, so the second sort preserves the first's order within ties:
students.sort(key=attrgetter("name")) # secondary
students.sort(key=attrgetter("class_name"), reverse=True) # primary
Sorting objects with None values fails:
# TypeError: '<' not supported between 'NoneType' and 'int'
results.sort(key=lambda r: r.marks_obtained)
# Put absent students last
results.sort(key=lambda r: (r.marks_obtained is None, r.marks_obtained or 0))
Tuples
point = (3, 4)
student = ("Ravi Kumar", "NCA-2024-0012", 87)
single = (42,) # the trailing comma is required
empty = ()
name, roll, marks = student # unpacking
(42) is the integer 42, not a tuple. The comma makes the tuple.
Tuples are immutable, which makes them hashable — so they can be dictionary keys and set members:
attendance = {}
attendance[(student_id, subject_id, date)] = True
seen = {("10th", "A"), ("10th", "B")}
A list cannot be a key; a tuple can. That is the practical reason tuples exist.
Use a tuple for a fixed group of related values, and a list for a homogeneous collection that may grow.
For anything with more than two or three fields, a named tuple or a dataclass is far more readable:
from typing import NamedTuple
class ExamResult(NamedTuple):
student_id: int
marks_obtained: int | None
is_absent: bool
result = ExamResult(student_id=12, marks_obtained=87, is_absent=False)
result.marks_obtained # rather than result[1]
Sets
class_names = {"9th", "10th", "11th"}
empty = set() # {} is an empty dict, not a set
class_names.add("12th")
class_names.discard("9th") # no error if absent
class_names.remove("9th") # KeyError if absent
"10th" in class_names # O(1)
a = {"Ravi", "Priya", "Arjun"}
b = {"Priya", "Arjun", "Sneha"}
a | b # union
a & b # intersection — in both
a - b # difference — in a, not b
a ^ b # symmetric difference — in one, not both
a <= b # subset
Set operations replace nested loops. Finding students present in one list and not another is a - b, not a double loop:
enrolled = {s.roll_number for s in enrolled_students}
attended = {a.roll_number for a in attendance_records}
absent = enrolled - attended
Deduplicating while keeping order:
unique = list(dict.fromkeys(names)) # preserves order
unique = list(set(names)) # loses order
set membership is O(1); list membership is O(n). Converting a list to a set before repeated in checks turns O(n × m) into O(n + m).
Dictionaries
student = {
"name": "Ravi Kumar",
"roll_number": "NCA-2024-0012",
"class_name": "10th",
"marks": 87
}
student["name"] # KeyError if absent
student.get("address") # None if absent
student.get("address", "Not recorded")
student["section"] = "A"
student.setdefault("section", "A") # only if absent
student.update({"marks": 91})
del student["marks"] # KeyError if absent
value = student.pop("marks", None) # safe
"name" in student
len(student)
dict["key"] raises KeyError; dict.get("key") returns None. Use get for optional data, and indexing when absence is a genuine error you want to hear about.
student.keys()
student.values()
student.items()
for key, value in student.items():
print(f"{key}: {value}")
# Merge — later wins
merged = {**defaults, **overrides}
merged = defaults | overrides # Python 3.9+
by_roll = {s.roll_number: s for s in students}
Grouping and counting
from collections import defaultdict
by_class = defaultdict(list)
for student in students:
by_class[student.class_name].append(student)
defaultdict(list) creates an empty list on first access, which removes the if key not in dict check.
from collections import Counter
class_counts = Counter(s.class_name for s in students)
class_counts.most_common(3)
Counter is a dictionary specialised for counting — one line where a manual loop takes four.
Iteration and modification
# RuntimeError: dictionary changed size during iteration
for key in student:
if not student[key]:
del student[key]
# Iterate a copy of the keys
for key in list(student):
if not student[key]:
del student[key]
# Or build a new dict
student = {k: v for k, v in student.items() if v}
Same rule as lists: do not modify a collection while iterating it.
Nested data
school = {
"name": "NexCoding Academy",
"classes": {
"10th": {
"A": [
{"name": "Ravi Kumar", "marks": {"maths": 87, "science": 72}},
{"name": "Priya Sharma", "marks": {"maths": 91, "science": 88}}
]
}
}
}
school["classes"]["10th"]["A"][0]["marks"]["maths"] # 87
That chain raises KeyError or IndexError at the first missing level, and the message names only the missing key — not the path.
maths = (school.get("classes", {})
.get("10th", {})
.get("A", [{}])[0]
.get("marks", {})
.get("maths"))
Safe, and unreadable. Deeply nested dictionaries are a design smell — model them as classes or dataclasses:
from dataclasses import dataclass, field
@dataclass
class Student:
name: str
roll_number: str
marks: dict[str, int] = field(default_factory=dict)
Now student.marks is typed, editors autocomplete it, and a typo in an attribute name is caught rather than returning None.
Copying
Assignment copies a reference, not the data.
original = [1, 2, 3]
alias = original
alias.append(4)
print(original) # [1, 2, 3, 4] — the same list
shallow = original.copy()
shallow = original[:]
shallow = list(original)
A shallow copy copies one level.
students = [{"name": "Ravi", "marks": {"maths": 87}}]
copied = students.copy()
copied[0]["marks"]["maths"] = 0
print(students[0]["marks"]["maths"]) # 0 — the nested dict is shared
import copy
deep = copy.deepcopy(students)
deepcopy recurses through every level. It is slower and handles cycles correctly.
Know which you need. A shallow copy of a list of immutable values is fine; a shallow copy of a list of dictionaries shares every dictionary.
The mutable default trap
# Wrong — the default list is created ONCE, at definition
def add_student(name, group=[]):
group.append(name)
return group
add_student("Ravi") # ["Ravi"]
add_student("Priya") # ["Ravi", "Priya"] — the same list
The default value is evaluated once when the function is defined, so every call without an argument shares one list.
# Right
def add_student(name, group=None):
if group is None:
group = []
group.append(name)
return group
This is Python's most famous gotcha, and it applies to [], {} and set(). Linters flag it; the None sentinel is the fix.
The same applies to dataclass fields, which is why field(default_factory=dict) exists.
collections helpers
from collections import defaultdict, Counter, deque, namedtuple
# deque — O(1) at both ends
queue = deque([1, 2, 3])
queue.append(4)
queue.appendleft(0)
queue.popleft()
recent = deque(maxlen=10) # keeps only the last 10
# namedtuple — a lightweight record
Point = namedtuple("Point", ["x", "y"])
p = Point(3, 4)
p.x
deque(maxlen=n) is a bounded history — appending past the limit discards from the other end automatically.
For structured records, prefer a dataclass over namedtuple unless you specifically want immutability and tuple behaviour.
Performance
# O(n × m) — a scan per lookup
for record in attendance:
student = next(s for s in students if s.id == record.student_id)
# O(n + m) — build a lookup once
by_id = {s.id: s for s in students}
for record in attendance:
student = by_id[record.student_id]
With 1,000 students and 5,000 records that is 5,000,000 comparisons versus 6,000. A loop containing a search over another collection is the signal — build a dictionary first.
| Operation | list | set / dict |
|---|---|---|
in | O(n) | O(1) |
| Append / add | O(1) | O(1) |
| Insert at front | O(n) | — |
| Index access | O(1) | — |
Errors you will hit
| Message | Cause | Fix |
|---|---|---|
KeyError: 'rollNumber' | Key not in the dictionary | .get() with a default |
IndexError: list index out of range | Index past the end | Check len() |
| A default argument keeps old data | Mutable default def f(x=[]) | Use None and create inside |
| Two variables change together | Both name the same list | Copy with list(x) or x[:] |
TypeError: unhashable type: 'list' | Used a list as a dict key | Use a tuple |
def add(item, items=[]) shares one list across every call. It is the classic Python trap and it produces no error, just data from a previous call.
Common mistakes
students = students.sort(), givingNoneremoveorindexon an absent value- Sorting a list containing
None {}for an empty set(42)instead of(42,)dict["key"]wheregetwas needed- Modifying a collection while iterating it
- Assuming assignment copies
- Shallow copy treated as deep
- A mutable default argument
- Deeply nested dictionaries instead of classes
- A search inside a loop instead of a lookup dictionary
pop(0)in a queue instead ofdeque
Practice
- Build a student list, then sort by name, by marks descending, and by class then marks descending.
- Write
students = students.sort()and confirm theNone. - Sort results where some
marks_obtainedareNone. Record theTypeError, then put absent last. - Call
removeon an absent value. Record the error, then guard it. - Build a
{roll_number: student}lookup with a dict comprehension. - Find students in an enrolled list but not an attendance list, first with nested loops, then with set difference. Time both over 1,000 × 5,000.
- Group students by class with
defaultdict(list), then count them withCounter. - Delete keys from a dict while iterating it. Record the
RuntimeError, then fix it two ways. - Assign a list to another name, mutate it, and confirm both changed.
- Shallow-copy a list of dictionaries, change a nested value, and confirm the original changed. Fix with
deepcopy. - Write a function with
group=[]as a default. Call it three times and confirm the accumulation. - Fix it with the
Nonesentinel. - Use a tuple as a dictionary key for
(student_id, subject_id, date). Then try a list and record the error. - Deduplicate a list preserving order, then without preserving it.
- Replace a five-level nested dictionary with dataclasses.
Exercises 6, 10 and 11 correspond to a performance problem and two real bugs.
You can now
- Choose between list, tuple, set and dict
- Read a dictionary safely with
.get() - Copy a collection rather than aliasing it
- Avoid the mutable default argument trap
- Say why a list cannot be a dict key
Review questions
- Why does
students = students.sort()produceNone? - What does a shallow copy share with the original?
- Why does a mutable default argument accumulate between calls?
- When does converting a list to a set change the complexity of an algorithm?
Next: Functions