Module 1

The object model & mutability

The single most useful mental model in Python: variables are not boxes. Every value — an int, a list, a function, a class — is an object living on the heap, and a variable is just a name bound to an object, like a sticky note on it. Assignment moves the sticky note; it never copies the object.

a = [1, 2]
b = a          # no copy — b is a second name for the SAME list
b.append(3)
print(a)       # [1, 2, 3]  — "a" changed, because there is only one list

So a = b creates an alias. Whether that alias can hurt you depends on one thing: is the shared object mutable?

Python splits its built-ins cleanly. Mutable: list, dict, set — they can change in place, so every alias sees the change. Immutable: int, str, tuple, frozenset— “changing” them always means building a new object and rebinding the name, so aliases to immutables are harmless.

s = "abc"
t = s
t += "d"       # builds a NEW string "abcd" and rebinds t
print(s)       # "abc" — untouched. Immutable aliasing is safe.

This is also why is and == are different questions. == asks “do these objects have equal values?”; is asks “are these the same object — same id()?” A classic footgun: CPython interns small integers (−5..256) and many short strings, so is sometimes appearsto work for value comparison — until it doesn't:

x = 256; y = 256
x is y         # True  — small ints are interned (cached)
x = 257; y = 257
x is y         # False (typically) — two distinct objects, equal values
x == y         # True — always the right question for values

Rule: use is only for singletons (is None, is True, sentinels). Use == for values.

🎛 Names & objects lab

a = [1, 2]
b = a
b.append(3)
print(a)

Names

a──▶object #1

Objects (the heap)

object #1id: 0x7f3a40

[1, 2]

list

step 1 of 4

A simplified picture of CPython's real model: every value is a heap object with an identity (id()), and a variable is an entry in a namespace dict mapping a name to an object. Assignment rebinds the name; methods like .append() mutate the object itself. Ids shown are illustrative. Educational tool.

The senior-interview classic: mutable default arguments

Default argument values are evaluated once — at def time — and stored on the function object. If the default is mutable, every call that omits the argument shares the same object:

def add_trade(trade, acc=[]):   # [] built ONCE, at def time
    acc.append(trade)
    return acc

add_trade("AAPL")   # ['AAPL']
add_trade("MSFT")   # ['AAPL', 'MSFT']   <- surprise: same list!
add_trade("GOOG")   # ['AAPL', 'MSFT', 'GOOG']

The function looks pure but carries hidden state between calls. The idiom every senior engineer writes on autopilot: default to None and build the fresh object inside the call:

def add_trade(trade, acc=None):
    if acc is None:          # "is None" — the correct use of "is"
        acc = []             # new list PER CALL
    acc.append(trade)
    return acc

Same trap, different costumes: def f(when=datetime.now()) freezes the timestamp at import time, and def f(cfg={}) shares one dict across all callers.

Aliasing bugs in real systems — and defensive copying

The production version of this bug: you pass a list into a cache or a config object, keep your own reference, and mutate it later. The cache is now silently corrupted — no line of cache code ever ran:

cache = {}
tickers = ["AAPL", "MSFT"]
cache["universe"] = tickers      # cache holds an ALIAS, not a snapshot
tickers.append("ENRN")           # ...much later, far away
cache["universe"]                # ['AAPL', 'MSFT', 'ENRN']  — corrupted

Three defenses, in order of preference:

  • Copy at the boundary: cache["universe"] = list(tickers) — a shallow copy snapshots the container. Note it does not copy nested objects; for a list of dicts you need copy.deepcopy (recursive, and much more expensive — reach for it deliberately, not by default).
  • Store immutables: tuple(tickers) is cheap immutability — nobody can mutate it, so aliasing becomes free and safe.
  • Don't hold references you don't own — a rule that matters double for anything long-lived (caches, module-level config, class attributes).

If this instinct feels familiar from the Java side, it should: it's the same “validated immutable value” discipline behind records — see Records & immutability in the Financial Engineering track. In Python the equivalents are tuples, frozenset, and frozen=True dataclasses (Module 3).

Things to try

  • • Run the aliasing scenario in the lab and watch both names point at one object — then run rebinding and see why b = [9] can never affect a.
  • • Step through the mutable default scenario: the default list is created once and survives between calls.
  • • In a real Python shell, compare id(a) before and after a += [3] (same id — in-place) vs a = a + [3] (new id — rebinding).
  • • Predict, then check: t = ([1], [2]); t[0].append(9) — an immutable tuple holding mutable lists. Immutability is one level deep.