Types & Mutability

The most common source of subtle bugs in Python code

Why

Most Python bugs in production come from unexpected mutation — a function silently modifies a list that was passed in, a mutable default argument accumulates state across calls, or two variables thought to be independent are actually pointing at the same object. Understanding how Python handles values and references prevents an entire category of bugs.

Mutability questions reveal whether you've truly internalised the language rather than just used it — they're quick to hit and reveal a lot.

Intuition

Python variables are labels, not boxes. When you write x = [1, 2, 3], you're sticking a label x on a list object. If you then write y = x, you've put a second label on the same object. Changing the object through either label affects both.

Immutable objects (int, str, tuple) can't change in place — Python creates a new object instead. Mutable objects (list, dict, set) change in place, so all labels pointing at them see the change.

Explanation
1. Mutable vs Immutable Types
Immutable
int, float, bool, str, tuple, frozenset, bytes. Cannot be changed after creation. Safe to use as dict keys. Reassignment creates a new object — the caller's reference is unaffected.
Mutable
list, dict, set, bytearray. Modified in place. Unhashable — cannot be dict keys. Passing a mutable to a function lets the function silently mutate the caller's data.
2. The Mutable Default Argument Trap

Python creates default argument values once when the function is defined — not on each call. So a mutable default like [] is shared across every call.

# BUG — same list reused every call def append_to(val, target=[]): target.append(val) return target append_to(1) # [1] # correct append_to(2) # [1, 2] # expected [2] # target was still [1] from the previous call, so 2 got added to it

Fix: use None as a placeholder and create the list inside the body.

# FIX — fresh list on every call def append_to(val, target=None): if target is None: target = [] target.append(val) return target

Rule: never use [], {}, or set() as a default. Always use None.

3. is vs == and id()

is checks identity — are both variables pointing to the same object in memory? == checks value — do they contain the same data? id() returns the object's memory address and is what is compares under the hood.

a = [1, 2, 3] b = a b is a # True — same object b == a # True — same value c = [1, 2, 3] c is a # False — different object c == a # True — same value # id() confirms what "is" is checking id(a) == id(b) # True — same address → same object id(c) == id(a) # False — different objects # small int / string caching (CPython implementation detail) x = 256; y = 256 x is y # True — CPython caches small ints (-5 to 256) x = 257; y = 257 x is y # False — outside cache range, two separate objects # Rule: use "is" only for None, True, False — never for value comparisons if x is None: ... # correct if x == None: ... # works but wrong — use "is"
4. Shallow vs Deep Copy

Assignment copies the reference, not the object. A shallow copy duplicates the outer container but keeps inner objects shared — only deep copy creates a fully independent structure.

import copy a = [[1, 2], [3, 4]] b = a # assignment — b and a point to the same object b.append(99) print(a) # [[1, 2], [3, 4], 99] a = [[1, 2], [3, 4]] c = a.copy() # shallow copy — new outer list, inner lists still shared c.append(99) print(a) # [[1, 2], [3, 4]] a = [[1, 2], [3, 4]] c = a.copy() # shallow copy — modifying inner list affects the original c[0].append(88) print(a) # [[1, 2, 88], [3, 4]] a = [[1, 2], [3, 4]] d = copy.deepcopy(a) # deep copy — fully independent, nothing is shared d[0].append(88) print(a) # [[1, 2], [3, 4]]

Shallow copy only copies the outer container — inner objects are still shared. Use deepcopy when your list contains other lists or objects.