Lists, tuples and dictionaries
Lesson 2 — Python
Learning objectives
- Use lists, tuples, dictionaries and sets, and choose between them
- Write comprehensions for filtering and transforming
- Explain mutability and why it causes surprising bugs
- Copy collections correctly, shallow and deep
- Use the
collectionsmodule for common patterns
Lists
Ordered, mutable, any types mixed.
x = [1, 2, 3]
mixed = [1, "a", 3.14, None, [1, 2]]
empty = []
x[0] # 1
x[-1] # 3
x[1:] # [2, 3]
len(x) # 3
x.append(4) # [1, 2, 3, 4] add one
x.extend([5, 6]) # [1, 2, 3, 4, 5, 6] add many
x.insert(0, 0) # [0, 1, 2, ...] at a position
x.remove(3) # remove first occurrence of the VALUE 3
x.pop() # remove and return the last
x.pop(0) # remove and return position 0
x.sort() # sorts IN PLACE, returns None
x.reverse() # in place
x.count(2)
x.index(2) # position of the first 2
x.clear()
sorted(x) # returns a NEW sorted list
sorted(x, reverse=True)
sorted(people, key=lambda p: p["age"])
reversed(x) # an iterator
3 in x # membership
x + [7, 8] # concatenation, new list
x * 2 # repetitionx = [3, 1, 2]
y = x.sort() # y is None! sort() mutates x and returns nothing
x # [1, 2, 3]
y = sorted(x) # y is a new sorted list, x unchangedThis trips up every R user. list.sort(), .append(), .reverse() and .extend() all mutate and return None. The functions sorted() and reversed() return new objects.
Tuples
Ordered, immutable.
t = (1, 2, 3)
t = 1, 2, 3 # parentheses are optional
single = (1,) # the trailing comma is what makes it a tuple
empty = ()
t[0] # 1
len(t) # 3
t[0] = 99 # TypeError: 'tuple' object does not support item assignmentTuples are used for:
# Multiple return values
def min_max(x):
return min(x), max(x)
lo, hi = min_max([3, 1, 4]) # unpacking
# Swapping
a, b = b, a
# Unpacking with a rest
first, *rest = [1, 2, 3, 4] # first=1, rest=[2,3,4]
first, *middle, last = [1, 2, 3, 4]
# Dictionary keys (lists cannot be keys — they are mutable)
lookup = {(2026, 3): "March 2026"}
# Fixed records
point = (10, 20)Use a tuple when the collection has a fixed size and meaning; a list when it is a variable-length collection of similar things.
Dictionaries
Key-value mapping. Ordered by insertion since Python 3.7.
d = {"name": "Ram", "age": 45}
d = dict(name="Ram", age=45)
empty = {}
d["name"] # "Ram"
d["missing"] # KeyError!
d.get("missing") # None — safe
d.get("missing", 0) # 0 — with a default
d["email"] = "a@b.com" # add or update
del d["email"]
d.pop("age") # remove and return
d.pop("age", None) # with a default, no KeyError
"name" in d # True — checks KEYS
d.keys()
d.values()
d.items() # (key, value) pairs
d.update({"age": 46, "city": "Hyderabad"})
d.setdefault("count", 0) # set only if absent
{**d1, **d2} # merge, d2 wins
d1 | d2 # same, Python 3.9+Iteration:
for key in d: # keys by default
print(key)
for key, value in d.items():
print(f"{key}: {value}")
for value in d.values():
print(value)Dictionaries are the Python equivalent of R’s named list, and they are used everywhere — JSON, function keyword arguments, configuration, DataFrame construction.
Sets
Unordered, unique, fast membership testing.
s = {1, 2, 3}
s = set([1, 2, 2, 3]) # {1, 2, 3}
empty = set() # NOT {} — that is an empty dict
s.add(4)
s.discard(2) # no error if absent
s.remove(2) # KeyError if absent
a = {1, 2, 3}
b = {2, 3, 4}
a | b # union {1,2,3,4}
a & b # intersection {2,3}
a - b # difference {1}
a ^ b # symmetric difference {1,4}
a <= b # subsetSets are the right tool for deduplication and membership:
# Which subjects are in ADAE but not ADSL?
set(adae_ids) - set(adsl_ids)
# Fast lookup — O(1) versus O(n) for a list
valid = set(adsl_ids)
[x for x in records if x.usubjid in valid]That last pattern matters: x in some_list scans the list, so a loop over 100,000 records checking membership in a 50,000-element list is 5 billion comparisons. Converting to a set makes it instant.
Comprehensions
The most characteristic Python idiom. They replace sapply, Filter and Map.
# Map
squares = [x**2 for x in range(10)]
# Filter
evens = [x for x in range(20) if x % 2 == 0]
# Both
result = [x**2 for x in range(20) if x % 2 == 0]
# Transform with a conditional
labels = ["high" if x > 5 else "low" for x in values]
# Nested
pairs = [(a, b) for a in [1, 2] for b in ["x", "y"]]
#> [(1,'x'), (1,'y'), (2,'x'), (2,'y')]
# Flatten
flat = [x for sublist in nested for x in sublist]
# Dictionary comprehension
lengths = {word: len(word) for word in words}
inverted = {v: k for k, v in d.items()}
# Set comprehension
unique_first = {name[0] for name in names}
# Generator expression — lazy, no parentheses needed inside a call
total = sum(x**2 for x in range(1_000_000))[x**2 for x in range(10_000_000)] # builds a 10M-element list in memory
(x**2 for x in range(10_000_000)) # a generator; computes on demandUse a generator when you are consuming the values once — sum(), max(), any(), a for loop. Use a list when you need to index into it or iterate more than once.
Readability limit: if a comprehension needs more than one for and one if, write a loop.
# Too clever
result = [f(x) for sub in data for x in sub if g(x) and h(x, sub)]
# Clearer
result = []
for sub in data:
for x in sub:
if g(x) and h(x, sub):
result.append(f(x))Mutability
The concept that causes the most surprising Python bugs.
a = [1, 2, 3]
b = a # NOT a copy — both names point to the SAME list
b.append(4)
a # [1, 2, 3, 4] — a changed too!# R copies on modify
a <- c(1, 2, 3)
b <- a
b[4] <- 4
a # 1 2 3 — unchangedThis is the deepest difference between the languages, and it applies to lists, dictionaries and sets — but not to strings, tuples, ints or floats, which are immutable.
Copying
import copy
a = [1, 2, 3]
b = a.copy() # shallow copy
b = a[:] # same
b = list(a) # same
nested = [[1, 2], [3, 4]]
shallow = nested.copy()
shallow[0].append(99)
nested # [[1, 2, 99], [3, 4]] — inner lists are shared!
deep = copy.deepcopy(nested)
deep[0].append(99)
nested # unchangedA shallow copy copies the outer container but not the objects inside it. For a list of lists, a list of dicts, or a DataFrame containing objects, you usually want deepcopy.
Mutable default arguments
The classic Python trap:
def add_item(item, items=[]): # WRONG
items.append(item)
return items
add_item("a") # ["a"]
add_item("b") # ["a", "b"] — the default persists between calls!The default is evaluated once, when the function is defined. The fix:
def add_item(item, items=None):
if items is None:
items = []
items.append(item)
return itemsThis is important enough that linters flag it, and you should never use a mutable default.
Mutation in loops
values = [1, 2, 3, 4, 5]
for v in values:
if v % 2 == 0:
values.remove(v) # modifying while iterating
values
# [1, 3, 5] — looks right, but it is luckRemoving while iterating skips elements. Build a new list instead:
values = [v for v in values if v % 2 != 0]The collections module
from collections import Counter, defaultdict, namedtuple, deque
# Counter — frequency counting
c = Counter(["a", "b", "a", "c", "a"])
c["a"] # 3
c.most_common(2) # [("a", 3), ("b", 1)]
c.total() # 5
# defaultdict — no KeyError, creates on access
by_subject = defaultdict(list)
for record in records:
by_subject[record["usubjid"]].append(record)
# no need to check whether the key exists first
counts = defaultdict(int)
for term in terms:
counts[term] += 1
# namedtuple — a lightweight record with named fields
Subject = namedtuple("Subject", ["id", "age", "arm"])
s = Subject("001", 45, "Placebo")
s.age # 45
s[1] # 45 — still indexable
# deque — fast append and pop at BOTH ends
d = deque([1, 2, 3])
d.appendleft(0)
d.popleft()defaultdict(list) is the Python answer to “group these records by a key” without pandas, and it appears constantly in data processing code.
R and Python side by side
| Task | R | Python |
|---|---|---|
| Ordered collection | c(1, 2, 3) or list() |
[1, 2, 3] |
| Named collection | list(a = 1, b = 2) |
{"a": 1, "b": 2} |
| Get element | x[[1]], x$a |
x[0], x["a"] |
| Safe get | x[["a"]] (NULL if absent) |
x.get("a") |
| Length | length(x) |
len(x) |
| Append | c(x, 4) |
x.append(4) |
| Map | sapply(x, f) |
[f(i) for i in x] |
| Filter | Filter(f, x) |
[i for i in x if f(i)] |
| Names | names(x) |
x.keys() |
| Unique | unique(x) |
set(x) or list(dict.fromkeys(x)) |
| Membership | 3 %in% x |
3 in x |
| Set difference | setdiff(a, b) |
set(a) - set(b) |
| Sort | sort(x) |
sorted(x) |
| Assignment copies | Yes | No |
The last row is the one to internalise.
Common mistakes
| Mistake | Consequence | Fix |
|---|---|---|
b = a expecting a copy |
Both change | a.copy() or copy.deepcopy() |
x = list.sort() |
x is None |
x = sorted(list) |
| Mutable default argument | State leaks between calls | Default None |
| Shallow copy of nested data | Inner objects shared | copy.deepcopy() |
d["key"] on a missing key |
KeyError |
d.get("key", default) |
| Membership test against a big list | Very slow | Convert to a set |
| Modifying a list while iterating | Skipped elements | Build a new list |
{} for an empty set |
It is a dict | set() |
Exercise 2.1 — Group records by key
Given a list of dictionaries representing adverse events, write a function that groups them by usubjid and returns, for each subject, the count of events and the set of distinct preferred terms.
events = [
{"usubjid": "001", "term": "Headache", "severity": "MILD"},
{"usubjid": "001", "term": "Nausea", "severity": "MODERATE"},
{"usubjid": "001", "term": "Headache", "severity": "MILD"},
{"usubjid": "002", "term": "Rash", "severity": "SEVERE"},
]Show solution
from collections import defaultdict
def summarise_by_subject(events):
"""Summarise adverse events per subject.
Returns a dict keyed by usubjid, each value a dict with the event count,
the distinct preferred terms, and the worst severity.
"""
SEVERITY_RANK = {"MILD": 1, "MODERATE": 2, "SEVERE": 3}
grouped = defaultdict(list)
for e in events:
grouped[e["usubjid"]].append(e)
return {
subj: {
"n_events": len(evs),
"terms": sorted({e["term"] for e in evs}),
"n_distinct_terms": len({e["term"] for e in evs}),
"worst_severity": max(
(e["severity"] for e in evs),
key=lambda s: SEVERITY_RANK.get(s, 0),
),
}
for subj, evs in grouped.items()
}
import json
print(json.dumps(summarise_by_subject(events), indent=2)){
"001": {
"n_events": 3,
"terms": ["Headache", "Nausea"],
"n_distinct_terms": 2,
"worst_severity": "MODERATE"
},
"002": {
"n_events": 1,
"terms": ["Rash"],
"n_distinct_terms": 1,
"worst_severity": "SEVERE"
}
}The pieces:
defaultdict(list)avoidsif subj not in grouped: grouped[subj] = []on every iteration. This is the canonical grouping idiom in plain Python.- A set comprehension
{e["term"] for e in evs}deduplicates the terms;sorted()makes the output deterministic, which matters if this feeds a test. max(..., key=...)with a rank dictionary finds the worst severity by an ordering that is not alphabetical.max()on the raw strings would give"SEVERE"by luck here but"MODERATE"over"MILD"correctly and"MODERATE"over"SEVERE"incorrectly in general —"S" > "M"is true, so it happens to work, which is exactly the kind of accident not to rely on.
Exercise 2.2 — Reconcile two datasets
Given lists of subject IDs from ADSL and ADAE, report: subjects in both, subjects only in ADSL, subjects only in ADAE, and whether either list has duplicates. Explain why a set is the right tool.
Show solution
from collections import Counter
def reconcile(adsl_ids, adae_ids):
"""Compare subject identifiers between two datasets."""
adsl_set = set(adsl_ids)
adae_set = set(adae_ids)
adsl_dupes = {k: v for k, v in Counter(adsl_ids).items() if v > 1}
adae_dupes = {k: v for k, v in Counter(adae_ids).items() if v > 1}
return {
"n_adsl": len(adsl_ids),
"n_adsl_distinct": len(adsl_set),
"n_adae": len(adae_ids),
"n_adae_distinct": len(adae_set),
"in_both": sorted(adsl_set & adae_set),
"adsl_only": sorted(adsl_set - adae_set),
"adae_only": sorted(adae_set - adsl_set),
"adsl_duplicates": adsl_dupes,
"adae_duplicates": adae_dupes,
}
adsl = ["001", "002", "003", "004"]
adae = ["002", "003", "003", "005"]
result = reconcile(adsl, adae)
for k, v in result.items():
print(f"{k:20} {v}")n_adsl 4
n_adsl_distinct 4
n_adae 4
n_adae_distinct 3
in_both ['002', '003']
adsl_only ['001', '004']
adae_only ['005']
adsl_duplicates {}
adae_duplicates {'003': 2}
Why a set
Three reasons:
The operations exist.
&,-and|express intersection, difference and union directly. The list equivalent is a nested loop or a comprehension with a membership test, and it is both slower and harder to read.Membership is O(1). A set is a hash table. Checking
x in some_settakes constant time regardless of size;x in some_listscans. With 50,000 subjects on each side, the list version is ~2.5 billion comparisons and takes minutes; the set version is instantaneous.
import timeit
big_list = list(range(100_000))
big_set = set(big_list)
timeit.timeit(lambda: 99_999 in big_list, number=1000) # ~0.9 s
timeit.timeit(lambda: 99_999 in big_set, number=1000) # ~0.00005 s- Deduplication is implicit, which is what you want for “which subjects appear in both” — but not what you want for detecting duplicates. Hence
Counterfor that part.
That last point is the design lesson: a set answers “which values” and a Counter answers “how many times”. Using a set alone would silently hide the duplicate "003" in ADAE, which is exactly the data problem you most want to find.
adae_only result is the important one. A subject with adverse events who is not in ADSL means either a data error or a population definition mismatch, and it should stop the pipeline — the same check as relationship = "many-to-one" in R’s dplyr joins.
Recap
- Lists are mutable and ordered; tuples immutable; dicts are key-value; sets are unique
b = adoes not copy — this is the deepest difference from R.sort()mutates and returnsNone;sorted()returns a new list- Comprehensions replace
sapply/Filter/Map; generators for one-pass consumption - Never use a mutable default argument
defaultdict(list)for grouping,Counterfor frequencies- Convert to a
setbefore repeated membership tests
Next: Functions and classes.