Skip to content

I changed my copy, and the original changed too

  • Python · Foundations
  • 20 min read, about 1 h 15 min with practice
  • Concept
  • Asked often: high
  • Reviewed 2026-11

New to some of the words above? Open a quick recap. It takes about two minutes.

Quick recapLists and dicts in two minutes· 2 min

A list keeps items in order. A dict maps keys to values. Both can be changed after they’re made.

nums = [3, 1, 2]
nums[0] # 3: positions start at 0
nums.append(5) # nums is now [3, 1, 2, 5]
pen = {"item": "pen", "price": 10}
pen["price"] # 10
pen["price"] = 9 # change one value

You’re building a shop’s sale page. It shows each item’s old price next to its sale price. You write sale_prices, which cuts every price by a percent, and call it like this:

cart = [{"item": "pen", "price": 10}, {"item": "book", "price": 50}]
sale = sale_prices(cart, 10) # 10% off
sale.append({"item": "tote bag", "price": 0}) # a free gift, on the sale only
print(cart)

Your first version changes each price and returns the list. The page then shows this as the old prices:

[{'item': 'pen', 'price': 9}, {'item': 'book', 'price': 45}, {'item': 'tote bag', 'price': 0}]

You never touched cart. Yet its prices are the sale prices, and it has the free bag. How did it change?

  • Input: items, a list of dicts. Each has an "item" name and a whole-number "price", and maybe other keys. Also percent, a whole number from 0 to 100.
  • Output: a new list of new dicts, in the same order. Each is the same as before, except its price is cut and rounded down: price * (100 - percent) // 100.
  • The hard part: the caller keeps using items. After your function returns, items and every dict in it must be exactly as they were, even if the caller changes your result.

Example: sale_prices(cart, 10) gives [{'item': 'pen', 'price': 9}, {'item': 'book', 'price': 45}], and cart still says 10 and 50.

Hints open one at a time:

Hint 1 of 3

Writing sale = cart doesn’t copy anything. What would it do?

Hint 2 of 3

cart.copy() makes a new list. What does the new list hold?

Hint 3 of 3

Make a new dict for each item with dict(item), set the new price on that, and collect them in a new list.

Think of a name as a sticky label, not a box. cart = [...] makes a list and sticks the label cart on it. same = cart sticks a second label on the same list. Nothing is copied, so a change made through one label shows through the other.

Python calls the things labels point to objects, and the link from a name to its object a reference. Two questions tell them apart:

  • a == b asks: are the values equal?
  • a is b asks: are these one object?

Watch the cart example. Names are on the left and objects on the right. Each arrow is a reference, and → #2 inside a list means that slot points to object #2. The player stops to ask what happens next: answer, or press Just show me. Then type your own lines under it.

Names pointing at objects

Names

none yet

Objects

no lists, dicts or tuples yet

Step 1 of 7. Before the first line runs, there are no names and no objects.

cart = [{"price": 10}, {"price": 50}]
same = cart
same.append({"price": 5})
sale = cart.copy()
sale.append({"price": 0})
sale[0]["price"] = 9

Try your own input

Use numbers, text, lists, dicts and tuples. It knows =, +=, del, print(), comparisons, slices, comprehensions, the common list methods, sorted(), list(), dict(), tuple(), range(), and copy.deepcopy() after import copy.

Numbers and text sit next to their names in the picture. They’re objects too, but they can never change. So sharing them can’t surprise you.

All three rungs look at each item once, so all take O(n) time for n items. What changes is what they share with the caller.

Okay

Change the prices in place

The idea: Loop over the items, set each new price on the dict itself, and return the list.

On the cart: the loop sets the pen’s price to 9 and the book’s to 45, on the very dicts inside cart. Then it returns cart itself, so sale is cart is True.

def sale_prices(items: list[dict], percent: int) -> list[dict]:
"""Cut every price by percent, rounding down."""
for item in items:
item["price"] = item["price"] * (100 - percent) // 100
return items
Time
O(n)
Space
O(1)
Why
No new lists or dicts: it changes the ones it was given.

Where it breaks: the caller still needs cart. Its old prices are gone. And the free bag added to sale lands in cart too, because they’re one list. This is fine only when nobody else needs the old data.

Better

Copy the list first

The idea: Make a new list with items.copy(), then change the prices in it.

The waste in Okay: it hands the caller’s own list back, so anything done to the result is done to cart.

On the cart: items.copy() makes a new list. The free bag now goes only into sale, and cart keeps 2 items. But the new list holds the same two dicts, and the loop sets prices on them. So cart shows 9 and 45 too.

Highlighted lines are new in this rung.

def sale_prices(items: list[dict], percent: int) -> list[dict]:
"""Cut every price by percent, rounding down, in a copy of the list."""
result = items.copy() # a new list, holding the same dicts
for item in result:
item["price"] = item["price"] * (100 - percent) // 100
return result
Time
O(n)
Space
O(n)was O(1)
Why
A new list of n references. The dicts themselves aren’t copied.
What changed from the previous rung
def sale_prices(items: list[dict], percent: int) -> list[dict]:
"""Cut every price by percent, rounding down."""
for item in items:
"""Cut every price by percent, rounding down, in a copy of the list."""
result = items.copy() # a new list, holding the same dicts
for item in result:
item["price"] = item["price"] * (100 - percent) // 100
return items
return result

Where it breaks: a copy made with .copy(), list(items) or items[:] is shallow. Only the outer list is new. Anything you change inside it is still the caller’s.

Best

New dicts for what changes

The idea: Build a new list of new dicts: dict(item) copies one item, then set the new price on the copy.

The waste in Better: it copies the list, which it doesn’t change, and shares the dicts, which it does.

On the cart: dict(item) makes a new pen dict with price 10, and the loop sets its price to 9. The same happens for the book. cart still points to the old dicts, so it keeps 10 and 50. Anything inside an item that the function doesn’t change, like a list of tags, stays shared. That’s safe, because nothing changes it.

Highlighted lines are new in this rung.

def sale_prices(items: list[dict], percent: int) -> list[dict]:
"""A new list of new dicts, each price cut by percent, rounding down.
The caller's list and dicts stay exactly as they were.
"""
result = []
for item in items:
new_item = dict(item) # a new dict with the same keys and values
new_item["price"] = item["price"] * (100 - percent) // 100
result.append(new_item)
return result
Time
O(n · k)was O(n)
Space
O(n · k)was O(n)
Why
One new dict per item, each with its k keys. Items have a few keys, so this is close to O(n).
What changed from the previous rung
def sale_prices(items: list[dict], percent: int) -> list[dict]:
"""Cut every price by percent, rounding down, in a copy of the list."""
result = items.copy() # a new list, holding the same dicts
for item in result:
item["price"] = item["price"] * (100 - percent) // 100
"""A new list of new dicts, each price cut by percent, rounding down.
The caller's list and dicts stay exactly as they were.
"""
result = []
for item in items:
new_item = dict(item) # a new dict with the same keys and values
new_item["price"] = item["price"] * (100 - percent) // 100
result.append(new_item)
return result

When Best isn’t best: if the data is yours alone, changing it in place is fine and saves memory. That’s when you just built it, or the caller asked for changes in place. Python works this way too: list.sort() sorts in place, while sorted() returns a new list. And when you’ll change things nested deeper, copy.deepcopy() copies every level for you, at a cost (see Compare).

Now write it in the page. Press Run tests to check your code. Python runs in your browser, so nothing is sent anywhere. Your code is saved in this browser for next time.

In the tests, cart is the two-item cart from the top of the page, and bag is a free cloth bag. Try Okay and Better from the ladder first: each fails a different test.

Sale prices

Ctrl + Enter runs

Loads about 6 MB the first time. Best on Wi-Fi.

  1. Not run yet: sale_prices([{'item': 'pen', 'price': 10}, {'item': 'book', 'price': 50}], 10) → [{'item': 'pen', 'price': 9}, {'item': 'book', 'price': 45}]
  2. Not run yet: sale = sale_prices(cart, 10); sale.append(bag); len(cart) → 2
  3. Not run yet: sale_prices(cart, 10), and cart must not change → [{'item': 'pen', 'price': 9}, {'item': 'book', 'price': 45}]
  4. Not run yet: Extra test
  5. Not run yet: Extra test
  6. Not run yet: Extra test
  7. Not run yet: Extra test

Extra tests check tricky cases. You see their input only if they fail.

Try your own input
WayTimeExtra memoryGoodNot so goodUse it when
Okay: change in placeO(n)O(1)No copies at allChanges the caller’s dataNobody else needs the old data
Better: shallow copyO(n)O(n)The caller’s list keeps its itemsChanges to the items still reach the callerThe items can’t change (numbers, text), or you won’t change them
Best: new dictsO(n · k)O(n · k)The caller is untouched; only unchanged parts are sharedCopies each itemYou change items the caller still holds
copy.deepcopy(), then changeO(all the data)O(all the data)Safe at any depthCopies parts you never change, so it’s slowerDeep data, changed in many places
  • is for numbers and text: two equal numbers made separately may or may not be one object. It depends on Python’s internals. Compare values with ==, and keep is for None and for “are these one object?”
  • Assigning inside a function: nums = [...] inside a function moves only the function’s own name. The caller’s list is unchanged. To change the caller’s list, change the list itself: nums.append(...) or nums[:] = [...]. Practice 2 is about this.
  • Removing while looping: removing items from the list you’re looping over skips items. Loop over a copy, or build a new list.
  • A list as a default value: def add(item, items=[]) makes that list once, when the function is defined. Every call that leaves items out shares it. Use items=None, then if items is None: items = [].

Questions to ask first

  • Does the caller still need the original data, or may I change it in place?
  • How deep is the data? Will I change anything nested inside the items?
  • How big is it? A copy costs as much memory again.

A 60-second walkthrough

“In Python, a name is a reference to an object, and assignment never copies. If I set prices on the dicts I’m given, the caller sees the change. Copying the list with .copy() isn’t enough: that’s a shallow copy, so the dicts inside are still shared. So I build a new list of new dicts, with dict(item) and then the new price. Each item is copied once, so it’s linear, and the caller’s data stays untouched. If nobody else needed the original, I’d change it in place and save the memory.”

Likely follow-ups

  • What’s the difference between a shallow and a deep copy? A shallow copy makes a new outer container that holds the same items. A deep copy copies every level, so nothing is shared.
  • What’s the difference between == and is? == compares values. is checks for one object. Use is for None.
  • Does Python pass arguments by value or by reference? By assignment: the parameter becomes another name for the caller’s object. Changing that object shows to the caller. Pointing the parameter at a new object doesn’t.
  • When does copy.deepcopy() hurt? On big data, because it copies everything. It also can’t copy some objects, like an open file.

Three problems, from easy to hard. Each has hints, tests you can run here, and its own Okay, Better and Best answers. Try before you open them.

Return a grid of rows lists, each holding cols zeros. Each row must be its own list, so setting one cell changes only that row.

Example: make_grid(2, 3) gives [[0, 0, 0], [0, 0, 0]]. The one-liner [[0] * cols] * rows looks right, but it isn’t. Why?

Hint 1 of 3

The outer * rows repeats one row object. It doesn’t make new rows.

Hint 2 of 3

Make a new row each time round a loop.

Hint 3 of 3

[0] * cols is fine: numbers can’t change, so setting a cell puts a new number in that slot.

Make an empty grid

Ctrl + Enter runs

Loads about 6 MB the first time. Best on Wi-Fi.

  1. Not run yet: make_grid(2, 3) → [[0, 0, 0], [0, 0, 0]]
  2. Not run yet: grid = make_grid(3, 2); grid[0][0] = 1; grid → [[1, 0], [0, 0], [0, 0]]
  3. Not run yet: Extra test
  4. Not run yet: Extra test

Extra tests check tricky cases. You see their input only if they fail.

Try your own input
Show the Okay, Better and Best answers
Okay

Two loops

The idea: Build each row by appending zeros, then append the row to the grid.

Each pass of the outer loop makes a new row, so no two rows are the same list. It works, but it takes seven lines.

def make_grid(rows: int, cols: int) -> list[list[int]]:
"""rows lists of cols zeros, each row its own list."""
grid = []
for _ in range(rows):
row = []
for _ in range(cols):
row.append(0)
grid.append(row)
return grid
Time
O(rows · cols)
Space
O(rows · cols)
Why
Every cell is made once.
Better

One loop, [0] * cols

The idea: Make each row in one step with [0] * cols, a new list each time round the loop.

[0] * cols puts the same 0 in every slot. That’s safe: numbers can’t change, so row[1] = 5 puts a new number in slot 1 and leaves the others alone. The trap is [row] * rows, which repeats a list.

Highlighted lines are new in this rung.

def make_grid(rows: int, cols: int) -> list[list[int]]:
"""rows lists of cols zeros, each row its own list."""
grid = []
for _ in range(rows):
grid.append([0] * cols) # a new row each time round the loop
return grid
Time
O(rows · cols)
Space
O(rows · cols)
Why
The same cells, made faster by [0] * cols.
What changed from the previous rung
def make_grid(rows: int, cols: int) -> list[list[int]]:
"""rows lists of cols zeros, each row its own list."""
grid = []
for _ in range(rows):
row = []
for _ in range(cols):
row.append(0)
grid.append(row)
grid.append([0] * cols) # a new row each time round the loop
return grid
Best

A comprehension

The idea: [[0] * cols for _ in range(rows)] runs [0] * cols again for every row.

The comprehension runs its expression once per row, so each row is new. Type grid = [[0] * 2 for _ in range(2)] into the player above, then grid[0][0] = 1, and compare it with [[0] * 2] * 2.

Highlighted lines are new in this rung.

def make_grid(rows: int, cols: int) -> list[list[int]]:
"""rows lists of cols zeros, each row its own list."""
return [[0] * cols for _ in range(rows)]
Time
O(rows · cols)
Space
O(rows · cols)
Why
The same work in one line.
What changed from the previous rung
def make_grid(rows: int, cols: int) -> list[list[int]]:
"""rows lists of cols zeros, each row its own list."""
grid = []
for _ in range(rows):
grid.append([0] * cols) # a new row each time round the loop
return grid
return [[0] * cols for _ in range(rows)]

When Best isn’t best: for big number grids, NumPy’s numpy.zeros((rows, cols)) is faster and smaller. It isn’t part of standard Python, though.

2. Remove the negatives, in place (medium)

Section titled “2. Remove the negatives, in place (medium)”

Remove the negative numbers from nums and keep the rest in order. Change the caller’s list itself, and return nothing, like list.sort() does. Other parts of the program hold this list, and they must see the change.

Example: after remove_negatives(nums) with nums = [3, -1, -2, 4, -5, 0], nums is [3, 4, 0].

Hint 1 of 3

Removing from the list you’re looping over skips the item after each one you remove.

Hint 2 of 3

Inside the function, nums = [...] points the function’s own name at a new list. The caller never sees it.

Hint 3 of 3

nums[:] = [...] replaces every item of the same list object.

Remove the negatives, in place

Ctrl + Enter runs

Loads about 6 MB the first time. Best on Wi-Fi.

  1. Not run yet: nums = [3, -1, -2, 4, -5, 0]; remove_negatives(nums); nums → [3, 4, 0]
  2. Not run yet: nums = [-1, -1]; remove_negatives(nums); nums → []
  3. Not run yet: Extra test
  4. Not run yet: Extra test
  5. Not run yet: Extra test

Extra tests check tricky cases. You see their input only if they fail.

Try your own input
Show the Okay, Better and Best answers
Okay

Loop over a copy, remove from the list

The idea: Loop over nums.copy(), so removing doesn’t skip items, and call nums.remove(n) for each negative.

Right, and the caller sees the change. But on 60,000 numbers the searching and shifting take several seconds, so it fails the speed test.

def remove_negatives(nums: list[int]) -> None:
"""Remove the negative numbers from nums, in place."""
for n in nums.copy(): # loop over a copy, so removing doesn't skip items
if n < 0:
nums.remove(n)
Time
O(n²)
Space
O(n)
Why
Each remove() searches from the front and shifts every later item left.
Better

Build the kept list

The idea: Build the list of numbers to keep in one pass, and assign it to nums.

The waste in Okay: every remove() searches and shifts, when one pass can find all the items to keep.

Fast, but wrong. nums = [...] points the function’s own name at the new list. The caller’s list never changes, so the first test fails.

Highlighted lines are new in this rung.

def remove_negatives(nums: list[int]) -> None:
"""Remove the negative numbers from nums, in place."""
nums = [n for n in nums if n >= 0] # only moves the local name nums
Time
O(n)was O(n²)
Space
O(n)
Why
One pass, with no shifting.
What changed from the previous rung
def remove_negatives(nums: list[int]) -> None:
"""Remove the negative numbers from nums, in place."""
for n in nums.copy(): # loop over a copy, so removing doesn't skip items
if n < 0:
nums.remove(n)
nums = [n for n in nums if n >= 0] # only moves the local name nums

Where it breaks: assigning to a parameter never reaches the caller. Change the object instead.

Best

Slice assignment: same list, new items

The idea: nums[:] = [...] replaces all the items of the caller’s list with the kept ones.

The waste in Better: it builds the right list, then drops it.

nums[:] = ... doesn’t move the name. It puts new items into the same list object, so every name that points to it sees them.

Highlighted lines are new in this rung.

def remove_negatives(nums: list[int]) -> None:
"""Remove the negative numbers from nums, in place."""
nums[:] = [n for n in nums if n >= 0] # puts new items into the same list
Time
O(n)
Space
O(n)
Why
One pass to build the kept list, one to copy it into nums.
What changed from the previous rung
def remove_negatives(nums: list[int]) -> None:
"""Remove the negative numbers from nums, in place."""
nums = [n for n in nums if n >= 0] # only moves the local name nums
nums[:] = [n for n in nums if n >= 0] # puts new items into the same list

When Best isn’t best: if the caller doesn’t need the same list object, return the new list instead. Code that returns new data is easier to test and to reason about.

Write deep_copy(value) without the copy module. value holds numbers, text, lists and dicts, nested to any depth. Return an equal value that shares no list or dict with it, so changing anything in the copy never changes value.

Example: deep_copy([1, [2, [3]]]) gives [1, [2, [3]]], and appending to the innermost list of the copy leaves value as it was.

Hint 1 of 3

A list’s copy is a new list of copies of its items.

Hint 2 of 3

A copy of an item is the same problem again: let deep_copy call itself.

Hint 3 of 3

Numbers and text can’t change, so they can be shared. Only lists and dicts need new objects.

Deep copy by hand

Ctrl + Enter runs

Loads about 6 MB the first time. Best on Wi-Fi.

  1. Not run yet: c = deep_copy(value); c[0].append(9); value → [[1, 2], [3], 4]
  2. Not run yet: value = [1, [2, [3, [4]]]]; c = deep_copy(value); c[1][1][1].append(5); value → [1, [2, [3, [4]]]]
  3. Not run yet: c = deep_copy(value); c["tags"].append("y"); value → {'name': 'a', 'tags': ['x']}
  4. Not run yet: Extra test
  5. Not run yet: Extra test
  6. Not run yet: Extra test
  7. Not run yet: Extra test

Extra tests check tricky cases. You see their input only if they fail.

Try your own input
Show the Okay, Better and Best answers
Okay

Copy two levels

The idea: Copy the outer list, and .copy() each list inside it.

Right for a list of lists. But it stops after two levels: in [1, [2, [3]]], the [3] is still shared. And a dict comes back as the same object.

def deep_copy(value):
"""A copy of nested lists and dicts that shares no list or dict with value."""
if not isinstance(value, list):
return value
# Copy the outer list, and each list inside it.
return [item.copy() if isinstance(item, list) else item for item in value]
Time
O(n)
Space
O(n)
Why
Each item of the first two levels is looked at once.
Better

Let it call itself on lists

The idea: A list’s copy is a new list of deep_copy(item) for each item, so it goes as deep as the lists go.

The waste in Okay: it copies a fixed number of levels, when each level is the same problem as the last.

Any depth of lists now works. But a dict is still returned as itself, so lists inside dicts stay shared.

Highlighted lines are new in this rung.

def deep_copy(value):
"""A copy of nested lists and dicts that shares no list or dict with value."""
if not isinstance(value, list):
return value
# Each item gets the same treatment, however deep the lists go.
return [deep_copy(item) for item in value]
Time
O(n)
Space
O(n)
Why
Every list and item is visited once, at any depth. n is the size of all the data.
What changed from the previous rung
def deep_copy(value):
"""A copy of nested lists and dicts that shares no list or dict with value."""
if not isinstance(value, list):
return value
# Copy the outer list, and each list inside it.
return [item.copy() if isinstance(item, list) else item for item in value]
# Each item gets the same treatment, however deep the lists go.
return [deep_copy(item) for item in value]

Where it breaks: dicts. deep_copy({"tags": ["x"]}) hands back the very same dict.

Best

Lists and dicts, at any depth

The idea: New lists and new dicts all the way down; numbers and text are shared, since they can’t change.

The waste in Better: it treats dicts as if they couldn’t change.

A dict comprehension builds the new dict, calling deep_copy on each value.

Highlighted lines are new in this rung.

def deep_copy(value):
"""A copy of nested lists and dicts that shares no list or dict with value."""
if isinstance(value, list):
return [deep_copy(item) for item in value]
if isinstance(value, dict):
return {key: deep_copy(item) for key, item in value.items()}
return value # numbers, text and None can't change, so sharing them is safe
Time
O(n)
Space
O(n)
Why
Every list, dict and item is visited once.
What changed from the previous rung
def deep_copy(value):
"""A copy of nested lists and dicts that shares no list or dict with value."""
if not isinstance(value, list):
return value
# Each item gets the same treatment, however deep the lists go.
return [deep_copy(item) for item in value]
if isinstance(value, list):
return [deep_copy(item) for item in value]
if isinstance(value, dict):
return {key: deep_copy(item) for key, item in value.items()}
return value # numbers, text and None can't change, so sharing them is safe

When Best isn’t best: in real code, use copy.deepcopy(). It also copies tuples, sets and your own classes. It handles a list that contains itself, which would make this version call itself forever. And a list held in two places is copied once, so the copy keeps that sharing.

A few quick questions. Ones you miss are remembered, so you can find your weak spots later.

  1. Predict the outputWhat does this print?
    a = [1, 2]
    b = a
    b.append(3)
    print(a)
  2. Predict the outputWhat does this print?
    rows = [[1], [2]]
    copy = rows.copy()
    copy[0].append(9)
    copy.append([3])
    print(rows)
  3. Spot the bugThis should make a 3 by 3 board and put an X in the top-left corner. What goes wrong?
    board = [["."] * 3] * 3
    board[0][0] = "X"
    print(board)
  4. Multiple choiceAfter a = [1, 2] and b = [1, 2], what are a == b and a is b?
  5. Pick the patternInside a function, you want to drop the empty strings from the caller's list words, so the caller sees the change. Which line does it?

Add these to your review deck. Each card comes back a little later every time you get it right.

  • b = a never copies. It gives the same object a second name.
  • a == b: equal values. a is b: one object. Use is for None.
  • .copy(), list(a), a[:] and dict(d) are shallow: a new outside, the same items inside.
  • copy.deepcopy(x) copies every level. Or make new objects for just the parts you change.
  • [[0] * n for _ in range(m)], never [[0] * n] * m.
  • In a function, nums[:] = ... changes the caller’s list. nums = ... doesn’t.

Can you do these now?

If you’re not sure of any of these, scroll back to that part before moving on.

Status: Not started