gap·map

Python memory & data model

[ middle depth ]

Names bind to objects, they are not boxes

In Python a variable is a name pointing at an object, never a box that holds a value. b = a binds a second name to the same object, so mutating through one name shows up through the other. This one fact explains most "why did my data change?" bugs.

a = [1, 2, 3]
b = a
b.append(4)
a            # [1, 2, 3, 4] - same list, two names

The same aliasing crosses function calls. A list passed as an argument is the caller's list, so lst.append(x) inside the function is visible outside. Rebinding the parameter (lst = [...]) only repoints the local name and leaks nothing.

The mutable default argument trap

A default value is evaluated once, when the def statement runs, and the resulting object is stored on the function. Every call that omits the argument reuses that one object.

def add(item, bucket=[]):    # the [] is built once, at def time
    bucket.append(item)
    return bucket

add("a")     # ['a']
add("b")     # ['a', 'b']  - same list, still growing

Wrong "each call gets a fresh empty list." It does not. The fix is a None sentinel:

def add(item, bucket=None):
    if bucket is None:
        bucket = []
    bucket.append(item)
    return bucket

Same trap for a default dict or set. Anything mutable as a default is shared state.

How is differs from ==

== asks whether two objects are equal in value; is asks whether they are literally the same object in memory (identity). Use == for values, and reserve is for the singletons None, True, and False.

The gotcha that trips people in the REPL: CPython caches small integers (-5 through 256) and interns some strings, so is returns True for values it should not.

>>> x = 256; y = 256; x is y
True    # cached small int, one object
>>> x = 257
>>> y = 257
>>> x is y
False   # two separate objects

That True is an implementation detail, not a language guarantee, and the False needs the assignments entered as separate statements: inside one line or one script CPython folds equal constants into a single object, so the same test can print True. Which is the point. Comparing numbers or strings with is is a bug waiting for a value outside the cache.

Shallow copy versus deep copy

Slicing a list, dict(d), list(l), and copy.copy all make a shallow copy: a new outer container holding references to the same inner objects. Mutating a nested object still hits both.

import copy
original = {"tags": ["a", "b"]}
dup = copy.copy(original)
dup["tags"].append("c")
original["tags"]    # ['a', 'b', 'c'] - inner list was shared

copy.deepcopy recurses and clones every level, so the nested list becomes independent. Reach for it when a structure has mutable objects nested inside mutable objects and you need the copy to stand alone.

[ senior depth ]

Why reference counting alone leaks cycles

CPython's primary memory manager is reference counting. Every object carries an ob_refcnt; binding a name or appending to a list increments it, dropping a reference decrements it, and the object is freed the instant the count hits zero. Deterministic and cache-friendly, with one hole: a cycle keeps itself alive.

a = {}
b = {}
a["b"] = b
b["a"] = a
del a, b     # both unreachable, yet each still has refcount 1

Nothing outside references these dicts, but they reference each other, so neither count reaches zero. That garbage is what the generational cyclic collector (the gc module) reclaims. It scans container objects, subtracts the references they hold to each other, and any group left with no external reference is collected. It runs over three generations (0, 1, 2); a generation is collected when its allocation counter crosses a threshold (defaults 700, 10, 10), and survivors are promoted to an older, less-scanned generation on the theory that most objects die young. Only container types are tracked. An int or a str never enters the cycle collector, because it cannot form a cycle. Refcounting is never switched off; the collector is a supplement, not a replacement.

What slots actually saves

A normal instance stores its attributes in a per-instance __dict__, a full hash table. Declaring __slots__ replaces that dict with C-level descriptors at fixed offsets in the object, so instances are smaller and attribute access is a pointer read instead of a dict lookup.

class Point:
    __slots__ = ("x", "y")
    def __init__(self, x, y):
        self.x, self.y = x, y

p = Point(1, 2)
p.z = 3          # AttributeError - no __dict__ to absorb it

The savings matter when you hold millions of small instances: a real 30-to-50 percent cut on such objects. The costs are no ad-hoc attributes, no __weakref__ unless you list it, and a subclass that omits __slots__ grows a __dict__ again, erasing the benefit.

The eq / hash contract interviewers probe

If two objects compare equal they must hash equal, because dicts and sets find a key by hashing to a bucket and then checking equality. Break it and lookups silently miss. Defining __eq__ without __hash__ makes CPython set __hash__ to None, so instances become unhashable and cannot be a set member or dict key.

class Money:
    def __init__(self, cents): self.cents = cents
    def __eq__(self, other): return self.cents == other.cents
    __hash__ = lambda self: hash(self.cents)   # keep them in sync

Wrong "define __eq__ and equality just works everywhere." It works for ==, but the object silently drops out of hash-based containers until you supply a matching __hash__. A good __repr__ is the other data-model method worth writing for anything you will debug.

Late binding closures and immortal objects

A closure captures the variable, not its value at definition time, and the lookup happens when the inner function runs. So a loop that builds functions captures one shared name:

fns = [lambda: i for i in range(3)]
[f() for f in fns]        # [2, 2, 2], not [0, 1, 2]

The loop finished with i == 2, and every lambda reads that same i at call time. Bind at definition time with a default argument: lambda i=i: i evaluates the right-hand i now and stores it.

One internals note for the refcount question: since 3.12 (PEP 683) None, True, False, and small ints are immortal, given a fixed sentinel refcount that incref and decref skip. It removes contention on those hot singletons, which matters for the free-threaded 3.13+ builds where the GIL is off, and it is why sys.getrefcount(None) prints an enormous, meaningless number.

beta

The interviewer part is in the works.

The diagnostic, personal maps, and AI mock interviews are being finished right now. The notes stay free either way. Leave an email and you'll get the first-cohort invite, plus a month of Pro when it opens.

more Python

was this useful?