JJoeven

Curriculum/Python

Copy vs Change in Place

Lists and dicts change in place. Copy when you must not share. Never give two agents one transcript list.

beginner20 min23 / 37

Some values can change in place. That means you edit the same object. You do not make a new one.

Lists and dicts can change in place. nums.append(3) edits nums. Strings and numbers cannot. name = name + "!" makes a new string. step += 1 makes a new number and moves the name.

If two names point at one list, a change through either name shows up in both. That is the whole lesson. It is also a privacy bug if those names belong to two users.

Two names, one list
agent Atranscriptagent B

If both agents point at one list, an append shows up in both chats. Give each agent its own list.

Two names, one list

What you will learn

  • Change in place vs a new value
  • id() as a light check for “same object”
  • copy.copy vs copy.deepcopy
  • Why two agents must not share one transcript list

One list, two names

python
transcript = [class="tok-s">"hello"]
shared = transcript
shared.append(class="tok-s">"oops")
print(transcript)  class="tok-c"># ['hello', 'oops']

shared = transcript does not copy. It sticks a second name on the same list.

Rebind is different. transcript = ["hello"] later points the name at a new list. The old list is unchanged if another name still holds it. append is not rebind. = of a new list is rebind.

Functions get the same rule. If you pass a list and the function appends, the caller sees the extra item. If the function assigns a new list to its parameter name, the caller’s name is unchanged. Passing a list is not passing a copy.

KindExamplesIn-place change?
Mutablelist, dict, setyes: append, d[k]=v, add
Immutablestr, int, float, bool, tuple, Noneno; you make a new value

A tuple’s slots cannot be replaced. If a slot holds a list, that inner list can still change. Frozen outer, mutable inner: the same story as a frozen dataclass with a dict field.

Rebind vs mutate

CodeWhat happens to the caller’s list
xs.append(row)caller sees the new row
xs[0] = rowcaller’s first slot changes
xs = xs + [row]new list; caller unchanged
xs = []parameter name rebound; caller unchanged
python
def record(log, row):
    log.append(row)

memory = []
record(memory, class="tok-s">"step 1")
print(memory)  class="tok-c"># ['step 1']

That can be what you want (record a step). It is a bug when two users share one list. Name the contract: “this function mutates log” or “this function returns a new list.” Silent sharing is the defect. Visible sharing is a tool.

id() lightly

id(x) is a number for that object in memory. Same id means the same object. Different id means two objects.

You do not need id in production code. It is a teaching light: “are these two names the same list?”

python
a = [class="tok-s">"hi"]
b = a
c = list(a)  class="tok-c"># a new list with the same items
print(id(a) == id(b))  class="tok-c"># True
print(id(a) == id(c))  class="tok-c"># False

list(a) copies the outer list. Inner lists inside a would still be shared. That is a shallow copy: a new box, same insides. a[:] is the same kind of copy for lists. dict(d) is a shallow copy of a dict.

Do not use id of small ints as a uniqueness scheme. Some small integers are interned. Use id only to compare “same list?” while you learn.

copy.copy vs copy.deepcopy

The copy module is stdlib.

CallWhat you get
copy.copy(x)New outer list or dict; inner lists still shared
copy.deepcopy(x)New outer object and new copies of what is inside
list(xs) / dict(d)Shallow copy, like copy.copy for those types
python
import copy
a = {class="tok-s">"msgs": [class="tok-s">"hi"]}
shallow = copy.copy(a)
shallow[class="tok-s">"msgs"].append(class="tok-s">"there")
print(a)  class="tok-c"># inner list changed — shared

b = {class="tok-s">"msgs": [class="tok-s">"hi"]}
deep = copy.deepcopy(b)
deep[class="tok-s">"msgs"].append(class="tok-s">"there")
print(b)  class="tok-c"># still ['hi']

Use a deep copy when the object has lists inside lists (or dicts inside dicts) and you must not share any of them. Traces are dicts of lists of dicts. Isolating a trace for a second agent is a deep copy — or, simpler, start from [] and do not share.

Deep copy is slower and can copy too much. Do not deep-copy on every turn as a reflex. Copy when you branch: snapshot before a risky experiment, or split two agents.

Two agents, one transcript

A transcript is a list of messages. If you write:

python
memory = []
agent_a = {class="tok-s">"name": class="tok-s">"atlas", class="tok-s">"transcript": memory}
agent_b = {class="tok-s">"name": class="tok-s">"bolt", class="tok-s">"transcript": memory}

then Atlas’s append also appears in Bolt’s memory. Users can see each other’s prompts. That is a serious bug.

Fix: give each agent its own list.

python
agent_a = {class="tok-s">"name": class="tok-s">"atlas", class="tok-s">"transcript": []}
agent_b = {class="tok-s">"name": class="tok-s">"bolt", class="tok-s">"transcript": []}

If you must start from old rows, copy: list(old) or copy.deepcopy(old) when rows are dicts. list(old) on a list of dicts still shares the dict rows. Editing agent_b["transcript"][0]["content"] would edit Atlas’s first message too. Nested rows need a deep copy, or you must not mutate old rows.

The same bug appears if __init__ does self.log = default_log and every Agent gets the same argument. Pass None and create [] inside, like the defaults lesson. A list on a class body (class Bad: log = []) is shared by every instance. Classes lesson, same object.

Strings and ints hide the issue because assignment makes a new object. a = 1; b = a; b = b + 1 does not change a. Beginners then believe b = a always copies. It copies the label. For lists, the label points at a changeable object. Draw two arrows to one row of boxes. That picture is the lesson.

Walkthrough: shallow copy of a list of dicts

python
old = [{class="tok-s">"role": class="tok-s">"user", class="tok-s">"content": class="tok-s">"hi"}]
clone = list(old)
clone[0][class="tok-s">"content"] = class="tok-s">"secret"
print(old[0][class="tok-s">"content"])  class="tok-c"># secret — the dict was shared

list(old) made a new list. It did not copy the dict inside. A second agent that “cloned” a transcript this way still edits the first agent’s words. For rows that are dicts, copy.deepcopy(old) or a loop that builds new dicts: {"role": row["role"], "content": row["content"]}.

What goes wrong

  • b = a as a copy.
  • Shallow copy of a trace of dicts, then mutating a row.
  • A list default (previous lesson) — same shared object.
  • Putting log = [] on a class body so every instance shares it.
  • Trusting id of small ints as a uniqueness scheme.
  • Popping a transcript row and thinking the side effect (sent email) undid itself.
Live PythonOpen full playgroundpython
Output
Run to execute this in your browser. Nothing is sent to a server.

Give agent 2 its own list in the last shared block if you change agent2 = agent1. The prints should then diverge. Same id means same list. The final line should show edited: shallow list copy does not copy inner dicts.

How agents use this

Each agent needs its own transcript list. Sharing one list mixes chats. append changes the list in place, so every name on that list sees the new row. Copy before you isolate. Use copy.deepcopy when messages are dicts nested in lists. id() can prove two names are one object while you learn. In real code, just make a new list per agent.

Retries that “roll back” a step must not assume the world rolled back. Popping the last transcript row undoes the log, not the email you sent. Copy-on-write snapshots of state you own are for search trees and what-if plans. Side effects outside Python are not undone by deepcopy.

When you pass state into run(state), decide if run may mutate it. If tests reuse one state dict, mutate will leak between tests. Either copy at the start of run, or treat state as owned by the caller and document that appends are visible.

Global MEMORY = [] at module top is the same bug at file scale. Every request appends to one diary. Return new state. Or pass a per-run list. Tool registries as constant dicts of functions are fine to share: you are not appending user text to them. Share code. Do not share memory.

Prompt compactors should build a new list of small dicts, not delete keys from the live trace. If they mutate, your debug file loses the HTML you needed. Isolation is a copy (or a new object). It is not a comment that says “do not share.”

Check your understanding

Two agents share one transcript list. Agent A appends a message. What happens?