Mutability, Copying, and Iteration
Objective
Understand that variables bind objects, the layers of mutability and copying, and how to prevent accidental sharing of Agent state. Distinguish iterables, iterators, and generators, and reduce peak memory when downstream processing remains streaming.
Sequence recap
Sequences arrange elements by position and normally support:
messages = ["system", "user", "assistant"]
print(messages[0]) # First element.
print(messages[-1]) # Last element.
print(messages[1:]) # A new list from index 1 to the end.
print(len(messages))Indexes start at 0. A start:stop slice includes start and excludes stop. Accessing one nonexistent index raises IndexError; an out-of-range slice is safely truncated.
list, tuple, and str
| Type | Mutable? | Common uses |
|---|---|---|
list | Mutable | Messages, steps, batch results |
tuple | The tuple itself is immutable | Fixed coordinates, immutable record fragments, unpacking |
str | Immutable | Text, IDs, and string path representations |
“A tuple is immutable” means that a position cannot be rebound. If a tuple contains a list, that list can still be changed:
record = ("run-1", ["created"])
record[1].append("finished") # Legal: mutate the nested list.An immutable container therefore does not automatically make the whole nested object tree immutable.
Aliasing: two variables can refer to one list
history = ["hello"]
backup = history
backup.append("world")
print(history) # ['hello', 'world']Assignment does not copy a list; it binds two names to the same object. To obtain an independent outer list:
backup = history.copy()But this is a shallow copy. If elements are themselves lists or dictionaries, nested objects remain shared:
messages = [{"role": "user", "content": "hi"}]
snapshot = messages.copy()
snapshot[0]["content"] = "changed"
print(messages[0]["content"]) # changedTo isolate nested state, create new dictionaries, use copy.deepcopy, or design immutable data. First decide which layers may change; do not blindly deep-copy a large context.
The mutable-default-argument trap
# Incorrect: one list is shared across calls.
def add_message(message, history=[]):
history.append(message)
return history
# Correct: create a new list whenever none is passed.
def add_message(message, history=None):
if history is None:
history = []
history.append(message)
return historyShared mutable state is particularly risky when an Agent processes multiple tasks in parallel. Isolate state by run_id and update it through a clear interface.
Modifying a container while iterating
Removing or inserting list items during iteration changes later indexes and can cause skipped items:
items = [1, 2, 3, 4]
for value in items.copy():
if value % 2 == 0:
items.remove(value)A clearer approach is normally to create a new list: items = [x for x in items if x % 2 != 0]. Adding or deleting entries while iterating a dictionary view can raise RuntimeError or skip entries; iterate a snapshot of keys with list(mapping), or collect keys to delete first. Concurrent tasks still need higher-level synchronization: copying a key list is not concurrency control.
Iterable, iterator, and generator
- An iterable can be passed to
iter(), such as a list, tuple, dictionary, or file. - An iterator yields items one at a time through
next()and signals completion withStopIteration. - A generator is an iterator produced by a generator expression or a function containing
yield, retaining execution state.
def batched_ids(prefix: str, count: int):
for index in range(count):
yield f"{prefix}-{index}"
ids = batched_ids("run", 3)
print(next(ids)) # run-0
print(list(ids)) # ['run-1', 'run-2']; the generator continues and is exhausted.
print(list(ids)) # []The same generator object is a one-shot iterator and does not reset after exhaustion; call the generator function again to create a new object. If multiple steps need independent reads, have each create a generator or deliberately materialize an immutable snapshot. Do not implicitly share a half-consumed iterator with multiple consumers.
Streaming does not automatically save memory
def normalized(lines):
for line in lines:
cleaned = line.strip()
if cleaned:
yield cleanedPeak memory falls only when the downstream consumer also processes items one at a time:
for line in normalized(source):
process(line)list(normalized(source)) materializes every value again. A generator can also delay read or parse errors until consumption; define resource closing, error location, and whether repeat traversal is allowed. Document Parsing develops large-file chunking; this lesson covers only Python iteration semantics.
List comprehensions: transformation, not hidden side effects
latencies = [120, 250, 80, 500]
slow = [value for value in latencies if value >= 200]Comprehensions suit building a new collection. If logic includes network calls, exception handling, or multistep state updates, an ordinary for loop is clearer.
Exercises
- Predict this output, then run it:
a = [[1], [2]]
b = a.copy()
b[0].append(9)
print(a)- Write
last_n(messages, n): return the lastnmessages without changing the original list; return an empty list forn <= 0. - Represent
(tool_name, call_id)as a tuple and unpack its two fields. - Explain why giving the same
historylist to two concurrent tasks makes their state leak into one another. - Write a
non_empty(lines)generator and show that it can be consumed only once; then write a factory that gives two consumers independent generators. - Delete dictionary keys during iteration, observe the error, then change it to “collect first, delete later.”
Self-check
- Which indexes does
items[1:3]include? - Can a
tuplecontain alist? Can that list change? - Why can
copy()not isolate every nested object? - When should an ordinary loop be preferred to a list comprehension?
- What is the distinction between an iterable and an iterator?
- Why can a generator lower peak memory only when downstream processing stays streaming?
- After a generator is consumed by
list(), can it be read again from the beginning?
Next step and related concepts
Next: Stacks, Queues, and Deques.
References
Retrieved on 2026-07-14.