Today

What a Python variable really is, and why changing one list can change another. About 25 minutes: warm-up, lesson, puzzle.

Warm-up

The idea

In Python, a variable is a name tag, not a box. Every value (a number, a string, a list, a function) is an object that lives in memory. A name like a just points at an object. b = a never copies anything: it puts a second name tag on the same object.

Whether that matters depends on one question: can the object change after it is created?

How it really works

Every object has three things:

PartWhat it isCan it change?
IdentityWhich object it is. id(x) shows it (in CPython, the memory address)Never
TypeWhat kind of object it is: type(x)Never (in practice)
ValueThe data insideOnly if the type is mutable

Mutable means “can be changed in place”: list, dict, set, and most objects of your own classes. Immutable means “cannot be changed”: int, float, str, tuple, frozenset, bytes, None.

Two kinds of operation look similar but are completely different:

  • Rebinding a name: x = something. The name now points at another object. No object changes.
  • Mutating an object: lst.append(4), d["k"] = 1. The object itself changes, so every name that points at it sees the change.

You can never mutate an immutable object. So for an int, y += 1 must build a new int and rebind y. For a list, += mutates in place.

== vs is: a == b asks “same value?” (it calls __eq__). a is b asks “same object?” (same identity). Use is only for singletons like None, True, False. For everything else, use ==.

Function calls follow the same rule. Python passes the object reference into the function (often called “pass by object reference” or “pass by assignment”). The parameter is a new name for the caller’s object. Mutating it changes the caller’s object; rebinding it does not.

Example

Predict each printed line before opening the answer.

a = [1, 2, 3]
b = a
b.append(4)
print(a)          # ?
print(a is b)     # ?
c = [1, 2, 3, 4]
print(a == c, a is c)   # ?
 
x = 10
y = x
y += 1
print(x, y)       # ?
 
nums = [1]; alias = nums; nums += [2]; print(alias)   # ?
tup = (1,); alias2 = tup; tup += (2,); print(alias2)   # ?
 
def change(lst):
    lst.append(1)
    lst = [9]
    return lst
 
mine = [0]
out = change(mine)
print(mine, out)  # ?

See it: step through this code in Python Tutor. It draws every name as an arrow to its object, so you can watch a and b point at the same list.

Traps

1. The mutable default argument. Default values are evaluated once, when the def line runs, and stored on the function. A mutable default is shared by every call.

def add_item(item, bucket=[]):
    bucket.append(item)
    return bucket
 
print(add_item("a"))          # ['a']
print(add_item("b"))          # ['a', 'b']   <- the same list again
print(add_item.__defaults__)  # (['a', 'b'],)
 
# Fix: use None as the signal and build a new list inside
def add_item(item, bucket=None):
    if bucket is None:
        bucket = []
    bucket.append(item)
    return bucket

The same trap appears with {} and set() defaults.

2. Shallow copy vs deep copy. lst.copy(), list(lst), lst[:] and dict.copy() make a shallow copy: a new outer container that holds the same inner objects. copy.deepcopy() copies everything, all the way down.

import copy
grid = [[0, 0], [0, 0]]
shallow = grid.copy()
deep = copy.deepcopy(grid)
shallow[0][0] = 1
print(grid)                                    # [[1, 0], [0, 0]]  <- changed!
print(deep)                                    # [[0, 0], [0, 0]]
print(shallow is grid, shallow[0] is grid[0])  # False True

3. [[0] * 2] * 2 makes one row twice. The outer * repeats the reference, not the row.

board = [[0] * 2] * 2
board[0][0] = 1
print(board)    # [[1, 0], [1, 0]]
board = [[0] * 2 for _ in range(2)]   # a new row each time
board[0][0] = 1
print(board)    # [[1, 0], [0, 0]]

4. is with numbers and strings is luck. CPython keeps one shared object for each small int from -5 to 256, so int("256") is int("256") is True but int("257") is int("257") is False. That is an implementation detail, not a rule. Compare values with ==.

5. A tuple is immutable; its contents may not be. t = ([1], 2) then t[0].append(99) works and gives ([1, 99], 2). The tuple still points at the same list; the list changed. And a tuple can only be a dict key or set member if everything inside it is hashable, so ([1], 2) cannot be a key.

How interviews ask it

Build it

Pick one of your Python projects. Find every function with a list, dict or set as a default value (search for =[], ={} and =set() in parameter lists) and every place a config dict is copied before it is changed. Fix the defaults with the None pattern and decide for each copy whether it needs to be deep. Then turn on the ruff rule B006 (“mutable argument default”) so the trap can’t come back.

Check yourself

Puzzle

Today: Contains Duplicate. Yesterday’s Two Sum now has its solution.

Market word: microservices

In 46 of 1,200 Canadian entry-level job posts this week. It means building one product as several small services that each own one job and their own data (users, payments, notifications), deploy on their own, and talk over HTTP or a message queue such as Kafka. Companies want it because large teams can ship and scale parts independently, at the cost of network calls, harder debugging and data spread across services. A FastAPI backend and a Next.js frontend talking over HTTP are already two services; Docker is how each one is usually packaged. Lesson 35 covers monolith vs microservices in depth.