Python: Identity, Mutability & Memory
is vs ==
== checks value equality; is checks identity — whether two names point to the exact same object in memory.
a = [1, 2, 3]
b = [1, 2, 3]
a == b # True — same value
a is b # False — different objects
# Gotcha: CPython caches small ints (-5 to 256) and some strings
x = 256; y = 256
x is y # True — cached
x = 257; y = 257
x is y # False — not cached, separate objects
Rule of thumb: use == for value comparison, is only for None/singleton checks (x is None, never x == None) — relying on is for value equality works accidentally for small cached ints and breaks silently once numbers exceed the cache range.
Mutable vs Immutable Types
Mutable (changed in place): list, dict, set, bytearray. Immutable (cannot be changed after creation): int, float, str, tuple, frozenset, bytes.
s = "hello"
s[0] = "H" # TypeError — strings are immutable, reassignment creates a NEW object
lst = [1, 2, 3]
lst[0] = 99 # fine — lists mutate in place
The consequence that actually matters: only immutable types are hashable, and only hashable types can be dict keys or set members.
d = {(1, 2): "tuple key"} # fine — tuple is immutable/hashable
d = {[1, 2]: "list key"} # TypeError — list is unhashable
The Mutable Default Argument Gotcha
Default argument values are evaluated once, at function definition time — not fresh on every call. A mutable default is silently shared across every call that doesn't override it.
def append_to(item, lst=[]): # BUG: this [] is created ONCE
lst.append(item)
return lst
append_to(1) # [1]
append_to(2) # [1, 2] ← not a fresh list! same object reused across calls
# Fix: use None as sentinel, create fresh inside
def append_to(item, lst=None):
if lst is None:
lst = []
lst.append(item)
return lst
This is one of the single most common real Python bugs and one of the most common interview gotcha questions — know both the failure mode and the fix cold.
Shallow Copy vs Deep Copy
copy.copy() creates a new outer container, but nested objects inside it are still shared references. copy.deepcopy() recursively copies every nested object.
import copy
original = [[1, 2], [3, 4]]
shallow = copy.copy(original)
deep = copy.deepcopy(original)
original[0][0] = 99
shallow[0][0] # 99 — shares the inner list with original
deep[0][0] # 1 — fully independent copy
The [[]] * n Trap
matrix = [[0] * 3] * 3 # multiplying a list REPEATS REFERENCES, doesn't create 3 independent lists
matrix[0][0] = 1
matrix # [[1, 0, 0], [1, 0, 0], [1, 0, 0]] ← all three rows are the SAME object!
# Fix: build each row independently
matrix = [[0] * 3 for _ in range(3)]
matrix[0][0] = 1
matrix # [[1, 0, 0], [0, 0, 0], [0, 0, 0]] ← correct
This is a direct consequence of the mutability rule above — [0] * 3 creates one list, and outer * 3 copies the reference to that one list three times, not three independent lists.
Memory Management
Python uses reference counting plus a cyclic garbage collector:
- Every object tracks how many references point to it; when the count hits zero, it's freed immediately (deterministic, unlike a purely tracing GC).
- Reference counting alone can't break cycles (
AreferencesB,BreferencesA, both otherwise unreachable) — a separate cyclic garbage collector periodically detects and collects these. del xdecrements the reference count forx's target — it does not guarantee the object is immediately freed (something else may still hold a reference), and it doesn't force a GC cycle.
Interview Drills
- Why does
x is 256; y is 256printTruebutx is 257; y is 257printFalse? — CPython caches small integers (-5 to 256) as singletons for performance; values outside that range create genuinely separate objects on each assignment, soiscorrectly reports them as different objects even though==would say they're equal. - A function with
def f(items=[])behaves unexpectedly across calls. Why, and how do you fix it? — The default[]is created once at function-definition time and shared across every call that doesn't pass its own list; fix by defaulting toNoneand creating a fresh list inside the function body whenNoneis seen. - Why can a tuple be a dict key but a list can't? — Dict keys must be hashable, and hashability requires immutability (a mutable key could change after insertion, breaking the hash table's internal invariants); tuples are immutable and hashable (if their contents are), lists are mutable and explicitly unhashable.
Cross-links
[[Python Scoping & Closures]] · [[Python OOP & Data Model]]