Python's built-in collections — list, tuple, dict, set — handle 90% of real-world data shaping. Mastering them (plus their comprehension sugar) means most data-munging scripts end up being 5–10 lines rather than 50. This post is the core of practical Python; spend extra time on the exercises.

Series map: 00 Setup → 01 Syntax → 02 Data Structures (this post) → 03 Functions & Modules → 04 OOP → 05 Files & Exceptions → (future).


Learning Outcome

  • Use list, tuple, dict, set confidently — including when to choose each.
  • Read and write list / dict / set comprehensions.
  • Use len, in, sorted, min, max, sum, enumerate, zip on collections.
  • Understand mutability (and the bug it causes most often: shared default arguments).
  • Know the time-complexity rough ranking of common operations.

Chapter 1 — list (ordered, mutable, indexable)

1
2
3
4
5
6
7
8
9
10
11
12
13
xs = [1, 2, 3]
xs.append(4) # [1, 2, 3, 4]
xs.insert(0, 0) # [0, 1, 2, 3, 4]
xs.remove(2) # remove first occurrence of value 2 → [0, 1, 3, 4]
last = xs.pop() # 4, list is [0, 1, 3]
xs.extend([8, 9]) # [0, 1, 3, 8, 9]
xs.sort() # in-place sort
xs.reverse() # in-place reverse
xs[1:3] # slice — [1, 3]
xs[::-1] # reversed copy
len(xs) # 5
3 in xs # True/False (O(n))

Slicing

xs[start:stop:step] — all parts optional.

1
2
3
4
5
6
7
8
xs = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
xs[2:5] # [2, 3, 4]
xs[:3] # [0, 1, 2]
xs[7:] # [7, 8, 9]
xs[::2] # [0, 2, 4, 6, 8] (every other)
xs[::-1] # [9, 8, ..., 0] (reversed)
xs[-3:] # last three

Slicing produces a new list; mutating the slice doesn't affect the original.

Copy gotcha

1
2
3
4
5
a = [1, 2, 3]
b = a # b refers to the SAME list
b.append(4)
print(a) # [1, 2, 3, 4] surprise!

To copy: b = a.copy() or b = a[:] or b = list(a).

Where I got stuck: I once "duplicated" a list for safety, then watched the original change anyway. The fix took 5 seconds once I saw b = a for what it was — not a copy, just a second name for the same object.


Chapter 2 — tuple (ordered, immutable)

1
2
3
4
5
point = (3, 4)
x, y = point # tuple unpacking
point[0] # 3
len(point) # 2

Tuples are immutable: you can't append, remove, or change items. Use when:

  • You return multiple values from a function.
  • You want a key in a dict (lists can't be dict keys; tuples can).
  • The data is conceptually a "record", not a collection of similar items.

Singleton tuple

1
2
3
t = (5,)     # tuple with one element — note the trailing comma
not_t = (5) # this is just int 5 in parentheses

Named tuples (slight upgrade)

1
2
3
4
5
6
from collections import namedtuple
Point = namedtuple("Point", ["x", "y"])
p = Point(3, 4)
p.x # 3
p[0] # 3 (still indexable)

For modern code, prefer dataclasses (Post 04).


Chapter 3 — dict (key → value, ordered since 3.7)

1
2
3
4
5
6
7
8
9
d = {"name": "Ada", "age": 30}
d["name"] # "Ada"
d["email"] = "a@x" # add or update
del d["age"] # remove key
"name" in d # True
d.get("missing") # None (no exception)
d.get("missing", "?") # "?" (default)
len(d) # number of keys

Iteration

1
2
3
4
5
6
7
8
9
for k in d:                 # iterate keys
print(k)

for v in d.values(): # iterate values
print(v)

for k, v in d.items(): # iterate pairs
print(k, v)

Merging (Python 3.9+)

1
2
3
4
5
d1 = {"a": 1}
d2 = {"b": 2}
merged = d1 | d2 # {"a": 1, "b": 2}
d1 |= d2 # in-place merge

Common patterns

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
# count occurrences
counts = {}
for word in ["a", "b", "a", "c", "a"]:
counts[word] = counts.get(word, 0) + 1
# {"a": 3, "b": 1, "c": 1}

# even simpler: collections.Counter
from collections import Counter
Counter(["a", "b", "a", "c", "a"]) # Counter({'a': 3, 'b': 1, 'c': 1})

# default to a list
from collections import defaultdict
groups = defaultdict(list)
for n in range(10):
groups[n % 3].append(n)
# {0: [0,3,6,9], 1: [1,4,7], 2: [2,5,8]}

Trick: Counter and defaultdict from collections save you get(..., default) boilerplate in 80% of cases. Worth memorising on day 1.


Chapter 4 — set (unique, unordered)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
s = {1, 2, 3, 2}    # {1, 2, 3} — duplicates dropped
s.add(4)
s.remove(2)
3 in s # O(1)
len(s)

# set operations
a = {1, 2, 3}
b = {3, 4, 5}
a | b # union {1, 2, 3, 4, 5}
a & b # intersect {3}
a - b # difference {1, 2}
a ^ b # symmetric difference {1, 2, 4, 5}

Use cases

  • Deduplication: unique = list(set(items)) (loses order; use dict.fromkeys if order matters).
  • Membership testing: if x in big_set is O(1) vs O(n) for list.
  • Set math: filtering by overlap, exclusion.

Empty set

1
2
empty = set()        # NOT {} — that's an empty dict!

Chapter 5 — Comprehensions (the superpower)

A comprehension transforms one iterable into a new collection in one line.

List comprehension

1
2
3
4
5
6
7
8
9
squares = [n * n for n in range(10)]
# [0, 1, 4, 9, 16, 25, 36, 49, 64, 81]

evens = [n for n in range(20) if n % 2 == 0]
# [0, 2, 4, ..., 18]

# nested
pairs = [(i, j) for i in range(3) for j in range(3) if i != j]

General form: [expression for var in iterable if condition].

Dict comprehension

1
2
3
4
5
6
square_map = {n: n * n for n in range(5)}
# {0: 0, 1: 1, 2: 4, 3: 9, 4: 16}

# invert a dict
inv = {v: k for k, v in d.items()}

Set comprehension

1
2
3
unique_lengths = {len(word) for word in ["hi", "hello", "ok"]}
# {2, 5}

Generator expression (lazy)

1
2
total = sum(n * n for n in range(1_000_000))  # no list built — memory-efficient

Use generator (round-brackets) when you only need to consume once.

Trick: if a comprehension is more than ~2 lines or has a complicated condition, rewrite it as a regular loop. Clever one-liners that take 3 minutes to read aren't actually clever.


Chapter 6 — Built-in functions on collections

Function What
len(x) length
sum(x) sum of numbers
min(x), max(x) extremes (works on strings too — lexicographic)
sorted(x) new sorted list; original untouched
reversed(x) iterator in reverse
any(x) True if any element truthy
all(x) True if all elements truthy
zip(a, b) pair elements
enumerate(x) (index, value) pairs
map(f, x) apply f to each (lazy; usually use comprehension instead)
filter(pred, x) keep where pred(x) truthy (lazy)

sorted with key

1
2
3
4
5
6
words = ["banana", "fig", "apple"]
sorted(words) # alphabetical
sorted(words, key=len) # by length
sorted(words, key=str.lower) # case-insensitive
sorted(records, key=lambda r: r["age"]) # by dict field

Chapter 7 — Mutability bugs

Names pointing to lists: shared references, a copy, a shallow copy, and a mutable default argument

The shared default argument trap

1
2
3
4
5
6
7
8
def add(item, target=[]):       # BUG! default list shared across calls
target.append(item)
return target

add(1) # [1]
add(2) # [1, 2] <-- not [2]!
add(3) # [1, 2, 3]

The default list is created once when the function is defined. Fix:

1
2
3
4
5
6
def add(item, target=None):
if target is None:
target = []
target.append(item)
return target

Where I got stuck: I shipped a bug to production caused by this exact pattern. Wasted 2 hours bisecting. Never use mutable defaults.

Shallow vs deep copy

1
2
3
4
5
6
7
8
import copy
a = [[1, 2], [3, 4]]
b = a.copy() # shallow — outer list new, inner lists shared
b[0].append(99)
print(a) # [[1, 2, 99], [3, 4]] surprise

c = copy.deepcopy(a) # truly independent

Chapter 8 — Time complexity quick reference

Op list tuple dict set
Index access O(1) O(1) O(1) avg n/a
Search x in O(n) O(n) O(1) avg O(1) avg
Append O(1) amortised n/a n/a n/a
Insert / remove at index O(n) n/a n/a n/a
Insert / remove by key n/a n/a O(1) avg O(1) avg
Iterate O(n) O(n) O(n) O(n)

Trick: "I'm doing many in checks on a list" → convert to set first. 100-element list = no difference; 100k-element list = 1000× speedup.

list, tuple, dict and set compared: example, empty value, mutability, item access, search speed and typical use


Practice Exercises

Exercise 1 — Word frequency

Given a string, return a dict mapping each word to its count.

1
2
3
text = "the quick brown fox jumps over the lazy dog the"
# expected: {"the": 3, "quick": 1, "brown": 1, ...}
Solution
1
2
3
from collections import Counter
counts = Counter(text.split())

Exercise 2 — Two-sum

Given a list of integers and a target, return the indices of the two numbers that add up to target. Assume exactly one solution exists.

1
2
3
4
nums = [2, 7, 11, 15]
target = 9
# expected: (0, 1) since 2 + 7 = 9
Solution (O(n) using dict)
1
2
3
4
5
6
7
seen = {}
for i, n in enumerate(nums):
if target - n in seen:
print((seen[target - n], i))
break
seen[n] = i

Exercise 3 — Flatten one level

Turn [[1, 2], [3, 4], [5]] into [1, 2, 3, 4, 5].

Solution
1
2
3
4
5
flat = [x for sub in nested for x in sub]
# or
import itertools
flat = list(itertools.chain.from_iterable(nested))

Exercise 4 — Group by parity

Given a list of integers, return a dict with keys "even" and "odd" mapping to lists.

Solution
1
2
3
4
5
from collections import defaultdict
groups = defaultdict(list)
for n in nums:
groups["even" if n % 2 == 0 else "odd"].append(n)

Exercise 5 — Anagrams

Given a list of words, group anagrams together.

1
2
3
words = ["eat", "tea", "tan", "ate", "nat", "bat"]
# expected groups: [["eat","tea","ate"], ["tan","nat"], ["bat"]]
Solution
1
2
3
4
5
6
7
from collections import defaultdict
groups = defaultdict(list)
for w in words:
key = "".join(sorted(w))
groups[key].append(w)
print(list(groups.values()))

Cheat-sheet

What Example
List append xs.append(x)
List slice xs[a:b:c]
List sort (in-place) xs.sort()
New sorted list sorted(xs, key=..., reverse=True)
Tuple unpack a, b = (1, 2)
Dict get with default d.get(k, default)
Dict items loop for k, v in d.items():
Dict merge d1 | d2 (3.9+)
Set empty set() (not {})
Set ops | & - ^
List comp [expr for x in xs if cond]
Dict comp {k: v for ... }
Counter Counter(iterable)
defaultdict defaultdict(list)
Deep copy copy.deepcopy(x)
Avoid mutable default use None sentinel

What's next

Post 03 covers functions and modules: how to define functions with positional / keyword / variadic arguments, scope rules (LEGB), lambda, and how to organise code into reusable modules + packages.


References

Note: this post is in progress. Planned additions: visualising mutability with diagrams; deque from collections.