Python for AI Engineers

◆ 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 ↔ Python list. arr.lengthlen(xs). pushappend. arr.slice(1,4)xs[1:4] (both stop-exclusive, but Python slices also do step and negative indices). arr.concat(b)a + b. [...arr].reverse()xs[::-1]. JS sort mutates and returns the array; Python's .sort() mutates and returns None — use sorted() when you want a value back.

Reading & judging: x = mylist.sort() then using x is a bug — x is None. Good code uses sorted(...) when it needs the result, .sort() only for its side effect. And a linear in check 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's a, b = pair is 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 ↔ Python dict. obj.key and obj["key"] both work in JS; Python has only d["key"] (dot access is for attributes, not dict keys). JS obj?.a?.b optional chaining ↔ chained .get("a", {}).get("b"). {...a, ...b}{**a, **b}. Object.entries(o)d.items(), Object.keysd.keys(). Crucially: reading a missing JS property gives undefined; reading a missing Python key with d[k] throws KeyError.

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 throws KeyError/IndexError in 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 .get with 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 list of already-seen IDs and repeatedly does if x in seen_list: inside a loop, that's O(n) per check and quietly O(n²) overall on large data. A set makes 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 both for and if. 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 multiple ifs plus a ternary is write-once, read-never. Good judgment: if you can't parse it in one glance, an explicit for loop 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] = arra, *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=[]) or cfg={} in a signature is an instant red flag — the shared default accumulates state across calls and causes ghost bugs. The fix is =None plus 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_msg example into a REPL, call it three times, and watch the list grow. Then run b = a; b.append(99) and print a. 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