◆ Chapter 04
Data structures: list, tuple, dict, set (and comprehensions)
list, tuple, dict and set — when to reach for each — plus comprehensions, the idiom that replaces half your map/filter reflexes.
~2,463 words · chapter 4 of 15
Four built-in collections carry almost all AI/data code you'll read: list, tuple, dict, set. Master these and you can trace a request through an SDK, dig into a parsed LLM response, and spot the bugs reviewers miss. We'll lean on your JS instincts and flag exactly where they mislead.
list — the everyday sequence
A list is Python's growable, ordered, mutable sequence. Like a JS array, it holds anything, including a mix of types.
models = ["gpt-4o", "claude-opus", "llama-3"]
mixed = [1, "two", 3.0, ["nested"], {"k": "v"}] # heterogeneous is fine
print(len(models)) # => 3 (len() is a function, not a .length property)
print(models[0]) # => gpt-4o
Negative indexing counts from the end — there is no JS arr[arr.length - 1] dance:
print(models[-1]) # => llama-3 (last element)
print(models[-2]) # => claude-opus
Slicing a[start:stop:step] is the feature JS devs stumble on. It returns a new list; start is inclusive, stop is exclusive, and any part is optional.
nums = [0, 1, 2, 3, 4, 5]
print(nums[1:4]) # => [1, 2, 3] start=1 up to (not incl.) 4
print(nums[:3]) # => [0, 1, 2] from the beginning
print(nums[3:]) # => [3, 4, 5] to the end
print(nums[::2]) # => [0, 2, 4] every 2nd item (step)
print(nums[::-1]) # => [5, 4, 3, 2, 1, 0] reverse — memorise this idiom
print(nums[-2:]) # => [4, 5] last two
a[::-1] reversing a sequence appears constantly (reversing token lists, undoing a sort). Slicing never raises on out-of-range bounds — nums[10:99] is just [].
Mutation methods change the list in place and mostly return None (a classic trap):
xs = [3, 1, 2]
xs.append(4) # add one item -> [3, 1, 2, 4]
xs.extend([5, 6]) # add many (not append!) -> [3, 1, 2, 4, 5, 6]
xs.insert(0, 99) # insert at index -> [99, 3, 1, 2, 4, 5, 6]
last = xs.pop() # remove & return last -> last == 6
xs.remove(99) # remove first matching value (raises if absent)
print(xs) # => [3, 1, 2, 4, 5]
Watch append vs extend: append([5, 6]) adds the list itself as one nested element; extend adds its elements.
Sorting has two forms — the distinction matters:
data = [3, 1, 2]
new = sorted(data) # returns a NEW sorted list; data untouched
print(new, data) # => [1, 2, 3] [3, 1, 2]
data.sort() # sorts IN PLACE, returns None
print(data) # => [1, 2, 3]
result = data.sort() # BUG magnet:
print(result) # => None (.sort() gives you nothing back)
# sort by a key — e.g. tokens by descending logprob
toks = [{"t": "cat", "lp": -0.9}, {"t": "dog", "lp": -0.2}]
toks.sort(key=lambda d: d["lp"], reverse=True)
print(toks[0]["t"]) # => dog
Membership, concatenation, repetition:
print("gpt-4o" in models) # => True (linear scan — O(n), see sets below)
print([1, 2] + [3, 4]) # => [1, 2, 3, 4] concatenation
print([0] * 3) # => [0, 0, 0] repetition
JS → Python: JS
arr↔ Pythonlist.arr.length↔len(xs).push↔append.arr.slice(1,4)↔xs[1:4](both stop-exclusive, but Python slices also dostepand negative indices).arr.concat(b)↔a + b.[...arr].reverse()↔xs[::-1]. JSsortmutates and returns the array; Python's.sort()mutates and returnsNone— usesorted()when you want a value back.
Reading & judging:
x = mylist.sort()then usingxis a bug —xisNone. Good code usessorted(...)when it needs the result,.sort()only for its side effect. And a linearincheck inside a loop over a big list is an O(n²) smell — a set or dict would be O(1) (more below).
tuple — the immutable sequence
A tuple looks like a list but uses () and cannot be changed after creation. Immutability is the point: it signals "fixed record," it can be a dict key or set member (lists can't), and it's the natural shape for returning several values.
point = (0.12, -0.34)
print(point[0]) # => 0.12
# point[0] = 9 # TypeError: 'tuple' object does not support item assignment
Packing and unpacking is idiomatic Python you'll read everywhere:
a, b = 1, 2 # pack RHS into a tuple, unpack into a, b
a, b = b, a # swap with no temp variable -> a=2, b=1
def embed_stats(vec):
return len(vec), sum(vec) # returns a tuple (many values)
dim, total = embed_stats([0.1, 0.2, 0.3])
print(dim, total) # => 3 0.6000000000000001
The parentheses are often optional — commas make the tuple. That creates the single-element gotcha:
one = (5) # this is just the int 5 — parens are grouping
one = (5,) # THIS is a 1-tuple; the trailing comma makes it
print(type(one).__name__) # => tuple
Many APIs hand back tuples: dict.items() yields (key, value) pairs, enumerate() yields (index, item), and shapes/coordinates are tuples.
for i, model in enumerate(models):
print(i, model) # => 0 gpt-4o / 1 claude-opus / 2 llama-3
JS → Python: JS has no true tuple; you fake it with a fixed-length array
const [a, b] = pair. Python'sa, b = pairis the same destructuring, but the tuple is genuinely immutable. JS returns multiple values via an object/array; Python returns a tuple and unpacks it — that's why so many functions "return two things."
dict — the workhorse
The dict (hash map) is the single most important structure in AI code. Every JSON payload, every model config, every parsed LLM response is a dict. Keys are usually strings; values are anything. Since Python 3.7 dicts preserve insertion order.
config = {"model": "claude-opus", "temperature": 0.7, "max_tokens": 1024}
print(config["model"]) # => claude-opus
config["temperature"] = 0.9 # update
config["stream"] = True # add a new key
print("model" in config) # => True (in checks KEYS, not values)
print(len(config)) # => 4
d[k] vs .get(k, default) is the safety distinction that bites hardest when parsing model output:
print(config["seed"]) # KeyError: 'seed' -> crashes
print(config.get("seed")) # => None -> safe
print(config.get("seed", 42)) # => 42 -> safe with fallback
Iteration goes over keys by default; use .items() for pairs:
for key in config: # keys
...
for value in config.values(): # values
...
for key, value in config.items(): # pairs (each is a tuple, unpacked)
print(f"{key} = {value}") # => model = claude-opus, ...
Merging — Python's version of object spread:
defaults = {"temperature": 0.7, "top_p": 1.0}
overrides = {"temperature": 0.2}
merged = {**defaults, **overrides} # later keys win
print(merged) # => {'temperature': 0.2, 'top_p': 1.0}
# 3.9+ also: merged = defaults | overrides
Nested dicts are how you read a parsed JSON LLM response. A chat-completion response is a dict of lists of dicts. Walking it looks exactly like the SDK examples you'll copy:
response = {
"id": "chatcmpl-abc",
"choices": [
{
"index": 0,
"message": {"role": "assistant", "content": "Hello there!"},
"finish_reason": "stop",
}
],
"usage": {"prompt_tokens": 12, "completion_tokens": 3},
}
# Dig in step by step: dict -> list[0] -> dict -> dict -> str
text = response["choices"][0]["message"]["content"]
print(text) # => Hello there!
print(response["usage"]["completion_tokens"]) # => 3
# Defensive version for real code, where a field may be missing:
choices = response.get("choices", [])
if choices:
text = choices[0].get("message", {}).get("content", "")
print(text) # => Hello there!
JS → Python: JS
{}object /Map↔ Pythondict.obj.keyandobj["key"]both work in JS; Python has onlyd["key"](dot access is for attributes, not dict keys). JSobj?.a?.boptional chaining ↔ chained.get("a", {}).get("b").{...a, ...b}↔{**a, **b}.Object.entries(o)↔d.items(),Object.keys↔d.keys(). Crucially: reading a missing JS property givesundefined; reading a missing Python key withd[k]throwsKeyError.
Reading & judging: the number-one bug in LLM/JSON-parsing code is
resp["choices"][0]["message"]["content"]on a response that came back empty, filtered, or shaped differently (tool call instead of text). It throwsKeyError/IndexErrorin production. Good code guards with.get(...)and checks the list isn't empty before[0]. Seeing raw[...]indexing on external model output is a red flag; seeing.getwith sensible defaults is a sign someone thought about failure.
set — unique, fast membership
A set is an unordered collection of unique, hashable items. Two jobs dominate: deduplication and fast membership tests.
tags = ["nlp", "rag", "nlp", "agents", "rag"]
unique = set(tags) # => {'agents', 'nlp', 'rag'} (order not guaranteed)
print(len(unique)) # => 3
seen = {"gpt-4o", "claude-opus"}
print("gpt-4o" in seen) # => True O(1) average, vs O(n) for a list
Set algebra reads like math and is great for comparing token/vocab collections:
a = {"cat", "dog", "fish"}
b = {"dog", "bird"}
print(a | b) # => {'cat','dog','fish','bird'} union
print(a & b) # => {'dog'} intersection
print(a - b) # => {'cat','fish'} difference
Note {} alone is an empty dict, not a set — use set() for an empty set.
Reading & judging: if code builds a
listof already-seen IDs and repeatedly doesif x in seen_list:inside a loop, that's O(n) per check and quietly O(n²) overall on large data. Asetmakes it O(1). Choosing a list where a set (membership) or dict (keyed lookup) belongs is a common performance red flag in data pipelines.
Comprehensions — the Pythonic superpower
Comprehensions build a collection in one expression. You must be able to read them fluently — AI codebases are saturated with them. The shape is [expression for item in iterable if condition].
nums = [1, 2, 3, 4, 5, 6]
squares = [n * n for n in nums] # => [1, 4, 9, 16, 25, 36]
evens = [n for n in nums if n % 2 == 0] # => [2, 4, 6]
A chained JS map/filter collapses into a single comprehension:
# JS: nums.filter(n => n % 2 === 0).map(n => n * 10)
result = [n * 10 for n in nums if n % 2 == 0] # => [20, 40, 60]
Dict and set comprehensions use {}:
lengths = {w: len(w) for w in ["rag", "agent"]} # => {'rag': 3, 'agent': 5}
first_letters = {w[0] for w in ["rag", "react"]} # => {'r'} (set)
Generator expressions use () and are lazy — they compute one item at a time instead of building the whole list. This matters for large data and streaming:
total = sum(len(c["message"]["content"]) for c in response["choices"])
print(total) # => 11 no intermediate list allocated
Nested comprehension — flattening a list of lists (e.g. batched embeddings). Read the for clauses left-to-right, as if they were nested loops:
batches = [[1, 2], [3, 4], [5]]
flat = [x for batch in batches for x in batch] # => [1, 2, 3, 4, 5]
JS → Python:
.map()↔[f(x) for x in xs]..filter()↔[x for x in xs if cond]. Chained.filter().map()↔ one comprehension with bothforandif. A generator expression(...)is roughly a lazy JS iterator/generator — nothing runs until consumed.
Reading & judging: comprehensions are excellent for a single map/filter, but a comprehension with multiple
fors plus multipleifs plus a ternary is write-once, read-never. Good judgment: if you can't parse it in one glance, an explicitforloop is the better code. Deeply nested comprehensions are a legitimate review comment, not nitpicking.
Unpacking & starred assignment
The * operator captures "the rest" into a list:
xs = [1, 2, 3, 4, 5]
first, *rest = xs # first=1, rest=[2, 3, 4, 5]
first, *mid, last = xs # first=1, mid=[2, 3, 4], last=5
print(mid) # => [2, 3, 4]
You'll also see *args/**kwargs in function signatures — a preview: *args collects extra positional arguments into a tuple, **kwargs collects extra keyword arguments into a dict. The same stars spread when calling:
def call_model(model, *, temperature=0.7, **extra):
print(model, temperature, extra)
opts = {"temperature": 0.2, "seed": 7}
call_model("claude-opus", **opts) # => claude-opus 0.2 {'seed': 7}
parts = ["claude-opus"]
call_model(*parts, temperature=0.9) # => claude-opus 0.9 {}
JS → Python: JS rest/spread
...splits into*(sequences/positional) and**(dicts/keyword) in Python.const [a, ...rest] = arr↔a, *rest = xs.fn(...args)↔fn(*args).{...obj}in a call ↔**obj.
Copy semantics — the trap that bites everyone
Assignment never copies — it binds another name to the same object. Mutating through one name is visible through the other (aliasing):
a = [1, 2, 3]
b = a # b and a point to the SAME list
b.append(4)
print(a) # => [1, 2, 3, 4] surprise — a changed too
.copy() (or list(a), a[:]) makes a shallow copy — a new outer container, but nested objects are still shared:
import copy
outer = {"msgs": [{"role": "user"}]}
shallow = outer.copy()
shallow["msgs"].append({"role": "assistant"})
print(len(outer["msgs"])) # => 2 the inner list was shared!
deep = copy.deepcopy(outer) # fully independent, nested objects copied too
deep["msgs"].append({"role": "system"})
print(len(outer["msgs"])) # => 2 original untouched
A notorious related trap is the mutable default argument — a default [] or {} is created once and shared across all calls:
def add_msg(msg, history=[]): # DON'T — history persists between calls
history.append(msg)
return history
print(add_msg("a")) # => ['a']
print(add_msg("b")) # => ['a', 'b'] the SAME list leaked across calls!
def add_msg_ok(msg, history=None): # DO this instead
history = [] if history is None else history
history.append(msg)
return history
Reading & judging:
def f(x, items=[])orcfg={}in a signature is an instant red flag — the shared default accumulates state across calls and causes ghost bugs. The fix is=Noneplus a guard inside. Likewise, passing a config dict around and having a function mutate it (config["temperature"] = ...) can corrupt a caller's data through aliasing — good code copies before mutating, or avoids mutation entirely (your immutability instinct is correct here).
Try it: paste the
add_msgexample into a REPL, call it three times, and watch the list grow. Then runb = a; b.append(99)and printa. Feeling these two behaviours in your fingers is the fastest way to stop them surprising you in real model-pipeline code.
| Type | Syntax | Ordered | Mutable | Duplicates | Typical AI use |
|---|---|---|---|---|---|
list |
[1, 2] |
yes | yes | yes | token/message sequences, batches |
tuple |
(1, 2) |
yes | no | yes | fixed records, multi-return, dict keys |
dict |
{"k": 1} |
yes (3.7+) | yes | keys unique | configs, JSON, LLM responses |
set |
{1, 2} |
no | yes | no | dedupe, fast membership, vocab math |