Expert · Lesson 78 of 94

The memory model

Watch this lesson

View full playlist ↗

Read the lesson

What you'll learn

Concept

In Python, a variable is a name bound to an object; it is not a box that holds a value. a = [1, 2] creates a list object and binds the name a to it. b = a binds a second name to the same object; nothing is copied. This is why b.append(3) shows up through a: there is one list with two names. Every "surprising" behavior around mutation, defaults, and copying follows from this one fact, and the variables lesson introduced it with the label-and-box picture. Now you have the tools to see it directly: id() returns an object's identity, and is compares identities.

The memory model: a → 1, 2, 3 → b = a → c = a[: → 1, 2, 3
The memory model · Figure 1 · Open for full size

Mutability is a property of the object, not the name. Lists, dicts, sets, and most instances can be changed in place; ints, floats, strings, tuples, and frozensets cannot. Rebinding a name, x = x + 1, always creates or finds a new object; mutating, x.append(1), changes the object every name bound to it sees. That is why an immutable default argument is safe and a mutable one is a bug, why tuples can be dictionary keys, and why a function that receives a list can change the caller's list but a function that receives an int cannot change the caller's int.

Operation Creates a new object? Affects other names bound to the old one?
b = a no (they are the same object)
a = a + [4] yes no
a += [4] on a list no (in place) yes
a += 1 on an int yes (ints are immutable) no
a.append(4) no yes
b = a.copy() / a[:] yes (shallow) inner objects still shared
b = copy.deepcopy(a) yes (deep) nothing shared

Copying has two depths. A shallow copy, list(a), a[:], a.copy(), dict(d), creates a new outer container whose items are the same objects as before, so nested lists are still shared. copy.deepcopy recursively copies everything, so nothing is shared, at the cost of time and memory. Most of the time a shallow copy is what you want, because the items are immutable or you will not mutate them; reach for deepcopy when you have nested mutable data and will change the inner parts.

Objects live as long as something refers to them. CPython counts references: each name, container slot, or attribute pointing at an object adds one, and when the count reaches zero the object is freed immediately. Reference cycles, two objects that point at each other, cannot reach zero on their own, so a cyclic garbage collector runs periodically to find and free them; gc exposes it, and you rarely need to touch it. sys.getrefcount and sys.getsizeof let you see counts and sizes. For classes with many instances, __slots__ replaces the per-instance __dict__ with fixed slots, cutting memory per object substantially and preventing accidental new attributes; dataclasses accept slots=True for the same effect.

The memory model: refcount: names + container slots + attributes pointing at it → freed immediately → cyclic GC finds and frees it later
The memory model · Figure 2 · Open for full size

Code

Names and objects made visible with id and is. Rebinding gives a new identity; mutating keeps it and is seen through every name.

# names_objects.py
a = [1, 2]
b = a
print(a is b, id(a) == id(b))

b.append(3)
print(a, "<- a changed through b")

a = a + [4]
print(a is b, a, b)

x = 10
y = x
x += 1
print(x, y, x is y)

s = "hi"
t = s
s += "!"
print(s, t)
Output
True True
[1, 2, 3] <- a changed through b
False [1, 2, 3, 4] [1, 2, 3]
11 10 False
hi! hi

Shallow versus deep copies on nested data, and the function-argument consequence: the list is changed for the caller, the int is not.

# copies.py
import copy

config = {"name": "app", "tags": ["a", "b"], "limits": {"cpu": 2}}

shallow = dict(config)
deep = copy.deepcopy(config)

shallow["name"] = "other"
shallow["tags"].append("c")
deep["limits"]["cpu"] = 8

print(config["name"], config["tags"], config["limits"])
print(shallow["name"], shallow["tags"] is config["tags"], deep["tags"] is config["tags"])


def add_item(items, count):
    items.append("x")
    count += 1
    return count


things, n = [], 0
result = add_item(things, n)
print(things, n, result)
Output
app ['a', 'b', 'c'] {'cpu': 2}
other True False
['x'] 0 1

Reference counts, immediate freeing, and a cycle that needs the collector. weakref lets you observe an object being freed without keeping it alive.

# lifetimes.py
import gc
import sys
import weakref


class Node:
    def __init__(self, name):
        self.name = name
        self.other = None


a = Node("a")
print("refs to a (including the call's own):", sys.getrefcount(a))
holder = [a]
print("after adding to a list:", sys.getrefcount(a))

alive = weakref.ref(a)
del holder
del a
print("freed as soon as the last reference went:", alive() is None)

x, y = Node("x"), Node("y")
x.other, y.other = y, x                       # a cycle
watch = weakref.ref(x)
del x, y
print("cycle still alive after del:", watch() is not None)
gc.collect()
print("collected by the cyclic GC:", watch() is None)
Output
refs to a (including the call's own): 2
after adding to a list: 3
freed as soon as the last reference went: True
cycle still alive after del: True
collected by the cyclic GC: True

__slots__ and object size. The slotted class is smaller per instance and refuses attributes it did not declare; a dataclass gets the same with slots=True.

# slots.py
import sys
from dataclasses import dataclass


class Plain:
    def __init__(self, x, y):
        self.x, self.y = x, y


class Slotted:
    __slots__ = ("x", "y")

    def __init__(self, x, y):
        self.x, self.y = x, y


@dataclass(slots=True)
class Point:
    x: float
    y: float


p, s = Plain(1, 2), Slotted(1, 2)
plain_size = sys.getsizeof(p) + sys.getsizeof(p.__dict__)
print("slotted smaller than plain:", sys.getsizeof(s) < plain_size)
print("dataclass with slots has no __dict__:", not hasattr(Point(1.0, 2.0), "__dict__"))
p.z = 3
print("plain accepts new attributes:", p.z)
s.z = 3
Output
slotted smaller than plain: True
dataclass with slots has no __dict__: True
plain accepts new attributes: 3
Traceback (most recent call last):
  File "slots.py", line 29, in <module>
    s.z = 3
    ^^^
AttributeError: 'Slotted' object has no attribute 'z' and no __dict__ for setting new attributes

Try it yourself

Create memory.py and, for each statement, predict the output before running: bind two names to one list and mutate through one; rebind one with + and mutate again; shallow-copy a dict of lists and change an inner list; deep-copy and change again. Then create 100,000 instances of a plain class and of a __slots__ class and compare total memory with sys.getsizeof summed over instances and their __dict__s. Finally build a two-object cycle, watch it with weakref, and confirm gc.collect() frees it.

Common mistakes

What's next

You understand where objects live and die. Next you go one level deeper into how attributes and classes themselves work: descriptors, __init_subclass__, and metaclasses.

Course overview