Skip to main content
Published / updated

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
  • collections helpers

Choosing

TypeOrderedMutableDuplicatesLookup
listYesYesYesO(n)
tupleYesNoYesO(n)
setNoYesNoO(1)
dictYes (insertion)YesKeys uniqueO(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.

Operationlistset / dict
inO(n)O(1)
Append / addO(1)O(1)
Insert at frontO(n)
Index accessO(1)

Errors you will hit

MessageCauseFix
KeyError: 'rollNumber'Key not in the dictionary.get() with a default
IndexError: list index out of rangeIndex past the endCheck len()
A default argument keeps old dataMutable default def f(x=[])Use None and create inside
Two variables change togetherBoth name the same listCopy with list(x) or x[:]
TypeError: unhashable type: 'list'Used a list as a dict keyUse 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(), giving None
  • remove or index on an absent value
  • Sorting a list containing None
  • {} for an empty set
  • (42) instead of (42,)
  • dict["key"] where get was 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 of deque

Practice

  1. Build a student list, then sort by name, by marks descending, and by class then marks descending.
  2. Write students = students.sort() and confirm the None.
  3. Sort results where some marks_obtained are None. Record the TypeError, then put absent last.
  4. Call remove on an absent value. Record the error, then guard it.
  5. Build a {roll_number: student} lookup with a dict comprehension.
  6. 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.
  7. Group students by class with defaultdict(list), then count them with Counter.
  8. Delete keys from a dict while iterating it. Record the RuntimeError, then fix it two ways.
  9. Assign a list to another name, mutate it, and confirm both changed.
  10. Shallow-copy a list of dictionaries, change a nested value, and confirm the original changed. Fix with deepcopy.
  11. Write a function with group=[] as a default. Call it three times and confirm the accumulation.
  12. Fix it with the None sentinel.
  13. Use a tuple as a dictionary key for (student_id, subject_id, date). Then try a list and record the error.
  14. Deduplicate a list preserving order, then without preserving it.
  15. 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

  1. Why does students = students.sort() produce None?
  2. What does a shallow copy share with the original?
  3. Why does a mutable default argument accumulate between calls?
  4. When does converting a list to a set change the complexity of an algorithm?

Next: Functions