Back to Notes

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 (A references B, B references A, both otherwise unreachable) — a separate cyclic garbage collector periodically detects and collects these.
  • del x decrements the reference count for x'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

  1. Why does x is 256; y is 256 print True but x is 257; y is 257 print False? — CPython caches small integers (-5 to 256) as singletons for performance; values outside that range create genuinely separate objects on each assignment, so is correctly reports them as different objects even though == would say they're equal.
  2. 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 to None and creating a fresh list inside the function body when None is seen.
  3. 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]]