Search Tech Journey

Find topics, journeys and posts

6-month learning plan5 / 130
back to blog
pythonbeginner 50m read

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.

🧠SoftwareM01 · Python Foundations· Session 005 of 130 90 min

🎯 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.

You will be able to
  • 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



(a) Intuition · 5 min

Variables are name tags, not boxes
🌍 Real world

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.

💻 Code world

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

If these don't feel solid, everything Python does will feel like magic
  • 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

  1. 1991
    Python 0.9.0
    Guido releases Python. Everything is an object from day one — a design choice that shaped every future decision.
  2. 2001
    PEP 8 published
    The style guide that Python still enforces. Snake_case, 4-space indent, 79-char lines.
  3. 2008
    Python 3.0
    Strings become Unicode by default; print becomes a function. Breaks Python 2 forever. Painful, correct.
  4. 2015
    PEP 484 · type hints
    Guido adds optional static types. mypy ships. Python starts eating typed-language territory.
  5. 2024
    Python 3.13
    Free-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

Rebinding

`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
Mutation

`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

int, float, bool
Numbers. Immutable. `bool` is a subclass of `int` — that's why `True + 1 == 2`. Use `1_000_000` for readable big numbers.
immutable
str
Text. Unicode by default in Python 3. Immutable — every ‘transform’ returns a new string. Use f-strings: `f"hello {name}"`.
immutable
list
Ordered, mutable sequence. `[1, 2, 3]`. `.append()`, `.pop()`, `.sort()`. Your default ‘collection of things’.
mutable
tuple
Ordered, IMMUTABLE sequence. `(1, 2, 3)`. Use for fixed-length records, keys of dicts, function return values.
immutable
dict
Ordered (since 3.7) key→value map. `{'name': 'ada'}`. O(1) lookup. The workhorse of Python.
mutable
set
Unordered collection of unique hashable items. `{1, 2, 3}`. O(1) membership test. Use for dedup and ‘in this group?’ checks.
mutable
None
The sole instance of `NoneType`. Represents ‘no value’. Compare with `is None`, never `== None`.
singleton
bytes / bytearray
Raw byte sequences. `b'hello'`. Different from `str` — bytes are for files, network, binary protocols.
mixed

is vs == — the check that trips up everyone

1value
== compares VALUES

‘Do these two objects have the same content?’ Uses each type's __eq__ method.

2identity
is compares IDENTITY

‘Are these two names pointing at the exact same object in memory?’ Uses id() comparison.

3correct
Use is for singletons

None, True, False, Ellipsis. `x is None` is idiomatic and fast.

4correct
Use == for everything else

`x == 5`, `name == 'ada'`, `a == b` for lists.

5gotcha
The 3.12 warning

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

Common misconception
✗ What most people think

"Assignment copies the value. After y = x, I have two independent things — changing one can't affect the other."

✓ What is actually true

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.

Why the myth is so sticky

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.

Prove it to yourself

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)   # False
From first principles
Start with the question

Why is a list forbidden as a dict key while a tuple is allowed? This looks like an arbitrary rule — it isn't.

  1. 1
    A 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
  2. 2
    That 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
  3. 3
    So 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
  4. 4
    Therefore anything usable as a key must guarantee its hash never changes for its lifetime.
    forced by · correctness of lookup depends on hash stability
  5. 5
    The 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"
⇒ Therefore

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.

Mental modelName tags on objects

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 tag x. It never touches the object x previously 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?". is asks "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.
🔔 Fires when you see

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.

The tradeoff

You need to hand a caller's list to code that will modify it. Copy defensively, or mutate in place?

Mutate in place
+ you gain O(1) memory, no allocation, the caller sees results without a return value; the only option for very large data
− you pay action at a distance — the caller's object changes under them; nearly impossible to reason about in concurrent code; a classic source of Heisenbugs
pick when the mutation IS the contract and the name says so (list.sort(), random.shuffle()), or the data is too big to copy
Copy first
+ you gain the caller's data is safe; the function becomes referentially transparent and trivially testable; safe under threads
− you pay O(n) time and memory per call; and .copy() is shallow — nested mutables are still shared, which quietly reintroduces the original bug
pick when the function's job is to compute, not to modify — which is the vast majority of functions you will ever write
What a senior engineer actually does

Default 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

Part 1 · reference sharing
`y = x` doesn't copy — both names point to the same list. `x is y` is True. This is where 80% of Python surprises originate.
identity
Part 1 · rebinding
`b = b + [4]` builds a NEW list and rebinds `b` to it. `a` still points to the original. Contrast with `.append()` which mutates in place.
rebind
Part 2 · immutable rebind
Strings and ints have no mutation methods. `s = s + " world"` creates a new string; `t` is untouched. Same for `n += 1`.
immutable
Part 3 · mutable-default trap
The default list is created once. Fix: use None as sentinel, create inside the function. Reviewers WILL catch this.
gotcha
Part 4 · shallow vs deep copy
`list.copy()` copies the outer list; inner lists are still shared. `copy.deepcopy()` recursively copies. Know both.
copy
Part 5 · CPython small-int cache
CPython caches ints in [-5, 256] as singletons. That's an implementation detail. Never rely on `is` for value comparison.
impl
Part 6 · type awareness
The 8 types cover 95% of your code. Knowing which are mutable is the single most valuable Python fact.
types
Try itProve to yourself that tuples of mutable objects are ‘kinda mutable’

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.

💡 Hint · Tuples themselves are immutable — you can't reassign an element. But if an element is a list, you CAN mutate the list. What does `t[0].append(99)` do here?

(d) Production reality · 15 min

War story Common failure mode · every Python codebase over 3 years oldRecurring bug reports labelled ‘cannot reproduce’
🔥 What broke

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.

🧯 The fix

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.

🎓 Lesson to steal
Mutable default arguments are Python's oldest footgun. Every reviewer should catch it on sight; every linter should be configured to catch what reviewers miss.
War story Google · TensorFlow issue trackerThousands of GitHub issues over 5 years
🔥 What broke

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.

🧯 The fix
Use 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.
🎓 Lesson to steal
The three singletons — None, True, False — are the only values you should ever compare with is. For everything else, use ==.
Post-mortem
War story Instagram · Django-based backendMemory leak from a shared list
🔥 What broke
A dev added a small in-memory cache: _cache: list = [] at module level, appended on every request. Under production traffic, the process's RSS grew until the OOM killer fired.
🧯 The fix
Bounded size (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.
🎓 Lesson to steal
Any long-lived mutable at module scope is a leak waiting to happen. If it needs bounds, use 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

The memory model shapes every Python session after this one
S007 · Functions
Argument passing, closures, and the mutable-default trap in full.
S008 · Data structures
When to use list vs tuple vs dict vs set — mutability is the deciding factor.
S009 · Classes & objects
`self` is a reference too. Class attributes vs instance attributes trip up the same way.
S011 · Errors & debugging
pdb + `id()` + `type()` are the diagnostic tools for the model you learned today.
S045 · Pandas
SettingWithCopyWarning is the same bug — you thought you had a copy, you had a view.
S056 · Docker
Long-running Python processes leak memory when devs forget module-scope state persists.

(e) Recall + stretch · 10 min

Recall — click each to reveal · click to reveal
★ = stretch question

Explain-out-loud test

If you can't teach these three without notes, redo the session:

  1. What is a variable in Python? (one sentence, no analogies)
  2. What's the mutable-default-argument bug? (name it AND the fix)
  3. When do you use is vs ==? (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.