S005 · Python Variables & Types — Mental Model of Memory
Not ‘what is a variable’ — the actual model of what happens in memory when you write `x = [1, 2, 3]`. References, mutability, boxes, arrows. The mental picture every senior Python dev has that every junior doesn't.
🎯 Predict what a piece of Python code does to memory — references, mutation, identity vs equality — before running it, without exceptions.
Why this session exists
Every Python developer eventually gets bitten by the same three bugs: a mutable default argument that grows across calls, an assignment that "unexpectedly" changes another variable, and an is check that returns True when they expected False (or vice versa). All three come from missing the same mental model — variables in Python are labels on objects, not boxes containing values. Get this model right and Python stops surprising you.
- Draw the memory diagram for any assignment: `x = 5`, `x = [1,2,3]`, `y = x`, `x.append(4)`.
- Explain the difference between `is` and `==`, and predict which one to use in interviews and reviews.
- Name the ~8 built-in types you'll use daily, and know which are mutable vs immutable — instantly.
- Reason about type annotations well enough to add `def add(a: int, b: int) -> int:` and understand why it matters.
- Diagnose the mutable-default-argument bug on sight, and know at least two fixes.
Prerequisites
- S001 — Dev Environment — a working
uv+ Python 3.12. - S003 — The Command Line — for the REPL and script runs.
(a) Intuition · 5 min
Imagine a hotel. Guests are people (objects). Room numbers are names (variables). When you write x = [1, 2, 3], you don't put the list inside a box called x. You put the list somewhere in memory, print a name tag x, and stick it on the list.
When you write y = x, you print another tag y and stick it on the same list. Now the list has two name tags. Mutating the list via y.append(4) is like feeding one of the two-name-tagged guests dinner — the guest changes; both name tags still point to it.
Formally: every value in Python is an object living somewhere on the heap. Every name (variable) is a reference — an entry in a namespace dict that maps a string to an object.
x = y does NOT copy. It rebinds. If you truly want a copy, you must ask: y = list(x), y = x.copy(), or y = copy.deepcopy(x) for nested structures.
The three ideas that unlock Python's memory model
- Objects are values. Every object has an identity (`id()`), a type (`type()`), and a value. Identity and type never change; value can (if the object is mutable).
- Names are labels. `x = obj` stores the mapping ‘name x → id of obj’ in the current namespace. No copying happens.
- Mutability is a property of the OBJECT, not the name. `list`, `dict`, `set` are mutable. `int`, `str`, `tuple`, `frozenset` are immutable. Assignment is always ‘rebind name’ regardless.
A quick history so you know why the world looks like this
- 1991Python 0.9.0Guido releases Python. Everything is an object from day one — a design choice that shaped every future decision.
- 2001PEP 8 publishedThe style guide that Python still enforces. Snake_case, 4-space indent, 79-char lines.
- 2008Python 3.0Strings become Unicode by default; print becomes a function. Breaks Python 2 forever. Painful, correct.
- 2015PEP 484 · type hintsGuido adds optional static types. mypy ships. Python starts eating typed-language territory.
- 2024Python 3.13Free-threaded (no-GIL) build ships as experimental. The biggest change to CPython in 20 years.
(b) Visual walkthrough · 15 min
What x = [1, 2, 3]; y = x; y.append(4) looks like in memory
Two names, one object. y.append(4) mutates the object; both names still point to it, so both "see" the change. That's the whole trick.
The two kinds of "changes" in Python
`x = 5` then `x = 6`
- Creates or reuses object 6
- Points name x to it
- Old object (5) may be garbage-collected
- The name has been REBOUND to a new object
- Works on any name; irrelevant to mutability
`lst = [1,2]` then `lst.append(3)`
- The object itself changes state
- All names pointing at it see the change
- Requires a mutable type (list, dict, set)
- int, str, tuple can't do this — no methods to mutate
- This is where all the ‘spooky action at a distance’ bugs come from
The built-in types you'll use daily
The eight types that account for 95% of your code
is vs == — the check that trips up everyone
‘Do these two objects have the same content?’ Uses each type's __eq__ method.
‘Are these two names pointing at the exact same object in memory?’ Uses id() comparison.
None, True, False, Ellipsis. `x is None` is idiomatic and fast.
`x == 5`, `name == 'ada'`, `a == b` for lists.
Python now warns on `x is 5` because small ints are cached only up to 256 — behaviour will change in 3.14.
Type hints — optional, but seniors use them
Type hints (PEP 484) are optional annotations that don't affect runtime behaviour but let tools (mypy, Pyright, your editor) catch bugs before you run.
def greet(name: str, times: int = 1) -> str:
return (f"hello, {name}! " * times).strip()
items: list[str] = []
counts: dict[str, int] = {}
maybe_age: int | None = None"Assignment copies the value. After y = x, I have two independent things — changing one can't affect the other."
Assignment never copies. y = x creates a second name pointing at the same object. If that object is mutable, mutating it through either name is visible through both.
Because it's true for the types beginners meet first. x = 5; y = x; y = 6 leaves x as 5 — so "copying" looks correct. But that worked because you rebound y to a different object, not because anything was copied. Ints are immutable, so the two behaviours are indistinguishable until you hit a list.
Run this and watch it break your intuition:
x = [1, 2, 3]
y = x
y.append(4)
print(x) # [1, 2, 3, 4] <- x changed
print(x is y) # True <- same object
a = 5
b = a
b += 1 # REBIND, not mutate
print(a) # 5
print(a is b) # FalseWhy is a list forbidden as a dict key while a tuple is allowed? This looks like an arbitrary rule — it isn't.
- 1A dict must find a key in ~O(1), so it cannot scan every entry. It computes
hash(key)and jumps straight to a bucket.forced by · the entire value proposition of a dict is constant-time lookup - 2That means the bucket a key lives in is decided once, at insert time, from the key's hash.forced by · the hash is computed when you store, and re-used when you look up
- 3So if a key's hash ever changed after insertion, the dict would look in the new bucket and find nothing. The entry becomes unreachable — silently lost.forced by · nothing re-indexes the table when an object mutates; the dict is never notified
- 4Therefore anything usable as a key must guarantee its hash never changes for its lifetime.forced by · correctness of lookup depends on hash stability
- 5The only cheap way to guarantee that is to forbid the object from changing at all — i.e. require immutability.forced by · Python cannot police "you may mutate, but not in ways that alter the hash"
Hashability requires immutability. tuple is immutable ⇒ hashable ⇒ valid key. list is mutable ⇒ unhashable ⇒ TypeError: unhashable type: 'list'.
Note the subtlety this predicts: a tuple containing a list is also unhashable, because its hash depends on its contents. Try hash((1, [2])) — it fails, exactly as the derivation says it must.
Never picture a variable as a box holding a value. Picture a warehouse of objects, each with a permanent ID, and sticky name tags you can peel off and re-stick freely.
Assignment moves a tag. Mutation changes an object. Those are the only two operations, and confusing them causes nearly every Python surprise.
x = ...always moves the tagx. It never touches the objectxpreviously pointed at.x.append(...),x[0] = ...,x.sort()change the object itself — visible through every tag on it.==asks "do these two objects look the same?".isasks "are these the same object?" — same warehouse slot.- Immutable objects (int, str, tuple) make the distinction invisible, which is exactly why the wrong model survives so long.
Fire this model the moment you see: a function that "unexpectedly" changed its caller's list · a default argument that remembers old calls · two variables that change together · SettingWithCopyWarning in pandas · a class attribute shared across all instances.
You need to hand a caller's list to code that will modify it. Copy defensively, or mutate in place?
list.sort(), random.shuffle()), or the data is too big to copy.copy() is shallow — nested mutables are still shared, which quietly reintroduces the original bugDefault to copying, and make mutation loud when you choose it. Python's own stdlib encodes this: sorted(x) returns a new list, x.sort() mutates and returns None — that None exists specifically to stop you writing y = x.sort() and believing you got a copy.
And when you do copy, know your depth: x.copy() for flat data, copy.deepcopy(x) for nested. Shallow-copying a list of dicts protects the outer list and nothing else.
(c) Hands-on · 25 min
Copy this into a file, run it, then predict the output of each print BEFORE running it — write your prediction in a comment. Then run and check.
"""s005_memory_model.py — the exercises that install the mental model."""
from __future__ import annotations
import copy
# =========================
# Part 1 · Names and objects
# =========================
print("--- Part 1 ---")
x = [1, 2, 3]
y = x
y.append(4)
print("x:", x) # PREDICT: ?
print("y:", y) # PREDICT: ?
print("x is y:", x is y) # PREDICT: ?
print("id(x) == id(y):", id(x) == id(y))
# Rebinding vs mutation
a = [1, 2, 3]
b = a
b = b + [4] # NEW list — rebind
print("a:", a) # PREDICT: ?
print("b:", b) # PREDICT: ?
print("a is b:", a is b) # PREDICT: ?
# =========================
# Part 2 · Immutable ‘change’
# =========================
print("\n--- Part 2 ---")
s = "hello"
t = s
s = s + " world" # rebind — strings are immutable
print("s:", s) # PREDICT: ?
print("t:", t) # PREDICT: ?
n = 5
m = n
n += 1 # rebind — ints are immutable
print("n:", n, "m:", m) # PREDICT: ?
# =========================
# Part 3 · The mutable-default trap
# =========================
print("\n--- Part 3 ---")
def bad_append(item, target=[]):
target.append(item)
return target
print(bad_append("a")) # PREDICT: ?
print(bad_append("b")) # PREDICT: ?
print(bad_append("c", [])) # PREDICT: ?
def good_append(item, target=None):
target = [] if target is None else target
target.append(item)
return target
print(good_append("a"))
print(good_append("b"))
print(good_append("c", []))
# =========================
# Part 4 · Copy vs deep copy
# =========================
print("\n--- Part 4 ---")
grid = [[0, 0, 0], [0, 0, 0]]
shallow = grid.copy() # or list(grid)
deep = copy.deepcopy(grid)
grid[0][0] = 99
print("grid:", grid)
print("shallow:", shallow) # PREDICT: shares inner lists?
print("deep:", deep) # PREDICT: fully isolated?
# =========================
# Part 5 · is vs ==
# =========================
print("\n--- Part 5 ---")
small_a = 100
small_b = 100
print("small ints — a is b:", small_a is small_b) # PREDICT: ?
big_a = 100_000
big_b = 100_000
print("big ints — a is b:", big_a is big_b) # PREDICT: ?
# CPython caches small ints in [-5, 256]. Above that, identity is not guaranteed.
# NEVER rely on it — use == for value, is only for singletons (None, True, False).
# =========================
# Part 6 · Types you should know
# =========================
print("\n--- Part 6 ---")
samples: dict[str, object] = {
"int": 42,
"float": 3.14,
"bool": True,
"str": "hi",
"list": [1, 2],
"tuple": (1, 2),
"dict": {"a": 1},
"set": {1, 2, 3},
"none": None,
"bytes": b"raw",
}
for name, val in samples.items():
mutable = val.__class__.__name__ in {"list", "dict", "set", "bytearray"}
print(f" {name:<6} type={type(val).__name__:<10} mutable={mutable}")What each block teaches
Anatomy of the exercises
Run in the REPL:
t = ([1, 2], [3, 4])
print(t)
t[0].append(99) # allowed?
print(t)
t[0] = [7, 8] # allowed?The first mutation succeeds; the second raises TypeError: 'tuple' object does not support item assignment. The tuple's contents (which references) are frozen; the objects those references point to are not. This is why dict keys must be truly immutable — a tuple of ints is fine; a tuple of lists is not hashable.
(d) Production reality · 15 min
A team ships a Flask endpoint:
def process(items, seen=set()):
for i in items:
if i not in seen:
seen.add(i)
handle(i)Test suite passes. Production runs fine for a week. Then handlers stop firing for previously-seen items — but only for the ones that appeared in the very first request after deploy.
Change to seen=None + if seen is None: seen = set(). The default set was shared across every request and slowly filled up over the lifetime of the process.
Add a linter rule: ruff's B006 catches mutable defaults automatically. Wire it into pre-commit.
A user writes if tensor == None to check for a missing input. TensorFlow overrides __eq__ to return a tensor, not a bool — so the if tries to convert a tensor to bool and blows up with Truth value of a tensor is ambiguous.
if tensor is None. This is why PEP 8 says: ‘Comparisons to None should always be done with is or is not, never the equality operators.’ Types can override __eq__; they can't override is.None, True, False — are the only values you should ever compare with is. For everything else, use ==._cache: list = [] at module level, appended on every request. Under production traffic, the process's RSS grew until the OOM killer fired.collections.deque(maxlen=1000)) or a real cache (functools.lru_cache, or an external Redis). The bug wasn't mutability itself — it was assuming a Python list would garbage-collect stuff that was still referenced.deque(maxlen=…) or lru_cache(maxsize=…). If it grows unboundedly, it belongs in an external store.Where this shows up in the rest of the plan
(e) Recall + stretch · 10 min
Explain-out-loud test
If you can't teach these three without notes, redo the session:
- What is a variable in Python? (one sentence, no analogies)
- What's the mutable-default-argument bug? (name it AND the fix)
- When do you use
isvs==? (one rule)
What comes next
Hub: The 6-Month Learning Plan
Part of a 130-session evergreen learning series. Session structure: intuition → visual → hands-on → production war stories → recall. Duration: 90 minutes.