Memory Model & Garbage Collection
Topic 5 of 7, with 3 concept checks. Reference counting, cycles, and __slots__
Reason about references, lifetimes, and collection
Object lifetime
Count references conceptually, identify cycles and weak references, and distinguish object identity from storage details before drawing conclusions about Python memory behavior.
Core lesson 01
CPython uses reference counting as its primary strategy: every object tracks how many references point to it, and is freed immediately when that count hits zero.
Every Python object has a hidden reference count. Assigning it to a variable, appending it to a list, or passing it to a function all increment the count; deleting a reference, reassigning a variable, or a scope ending decrements it. The moment the count reaches zero, CPython frees the object immediately and deterministically — simple objects are cleaned up right away rather than accumulating until some later sweep.
import sys
a = [1, 2, 3]
print(sys.getrefcount(a)) # 2 (the variable `a`, plus the temporary arg to getrefcount)
b = a
print(sys.getrefcount(a)) # 3 -- another reference
del b
print(sys.getrefcount(a)) # back to 2What to remember
How does CPython primarily manage memory — and when does it actually free an object?
Common footguns
- Assuming Python has no memory management at all — reference counting IS a form of it, just automatic; the cyclic garbage collector (next question) only handles cases reference counting can't.
Core lesson 02
Reference counting can't free reference cycles since their counts never naturally reach zero. Python's separate cyclic garbage collector periodically scans for and cleans up exactly these unreachable cycles.
If object A holds a reference to B, and B holds a reference back to A, and nothing else references either, both still have a reference count of 1 (from each other) — reference counting alone would leak them forever. Python's generational cyclic garbage collector (the gc module) periodically looks for groups of objects unreachable from the rest of the program even though their internal reference counts are nonzero, and collects them.
import gc
class Node:
def __init__(self):
self.other = None
a, b = Node(), Node()
a.other = b
b.other = a # a cycle: a -> b -> a
del a, b # no external references left, but the cycle still exists internally
gc.collect() # the cyclic collector finds and frees the cycleWhat to remember
If reference counting frees objects immediately, why does Python still need a separate garbage collector?
Common footguns
- Relying on __del__ methods for critical cleanup on objects that might be part of a cycle — prefer context managers for deterministic cleanup instead of __del__.
a --other--> b ^ | |__other_______| Refcount(a) = 1 (from b), Refcount(b) = 1 (from a) Neither reaches 0 on its own -- cyclic GC required to free both
Core lesson 03
Defining __slots__ = ('x', 'y') tells Python to store those attributes in a fixed, compact structure instead of a per-instance dict, reducing memory use significantly for many instances — at the cost of losing arbitrary new attributes.
By default, every instance carries its own __dict__ to hold attributes, which is flexible but has real memory overhead — noticeable creating millions of small objects. __slots__ tells Python exactly which attribute names are allowed, letting it allocate a fixed-size structure per instance instead of a full dict. The tradeoff: you can no longer set arbitrary new attributes not listed in __slots__, and multiple inheritance gets trickier.
class PointDict:
def __init__(self, x, y):
self.x, self.y = x, y
class PointSlots:
__slots__ = ('x', 'y')
def __init__(self, x, y):
self.x, self.y = x, y
p = PointSlots(1, 2)
# p.z = 3 # AttributeError -- 'z' not declared in __slots__What to remember
What does __slots__ do, and why would you use it?
Common footguns
- Adding __slots__ to a class that's also expected to support dynamic attributes (e.g. monkey-patching in tests) — silently breaks that flexibility.
- Forgetting __slots__ interactions get tricky across multiple inheritance, since each class's slots layout must be compatible.
Python lab
Browser Python lab
Runtime · idle
Python loads on your first run. Your code stays in this browser.
Best practices
- Use __slots__ for classes you'll instantiate in large numbers (thousands+) where memory matters.
- Don't reach for manual gc.collect() calls in normal code — let the automatic cyclic collector do its job.
- Prefer context managers over __del__ for deterministic cleanup — __del__ timing is not guaranteed.
- Use sys.getsizeof() and tracemalloc for actual memory profiling instead of guessing.
Apply the concept in Interview practice
LRU CachemediumLeetCode #146 · O(1) get/put
Revisit once more from a memory-discipline angle: bounding a cache's size is exactly the kind of explicit memory-management thinking __slots__ and reference-counting awareness support in production systems.
Open problemConcept checks
How does CPython primarily manage memory — and when does it actually free an object?
Hint
Most objects are freed the moment nothing points to them anymore.
It's counting how many references exist, not waiting for a periodic sweep.
Answer
CPython uses reference counting as its primary strategy: every object tracks how many references point to it, and is freed immediately when that count hits zero.
Every Python object has a hidden reference count. Assigning it to a variable, appending it to a list, or passing it to a function all increment the count; deleting a reference, reassigning a variable, or a scope ending decrements it. The moment the count reaches zero, CPython frees the object immediately and deterministically — simple objects are cleaned up right away rather than accumulating until some later sweep.
import sys
a = [1, 2, 3]
print(sys.getrefcount(a)) # 2 (the variable `a`, plus the temporary arg to getrefcount)
b = a
print(sys.getrefcount(a)) # 3 -- another reference
del b
print(sys.getrefcount(a)) # back to 2Watch out
- Assuming Python has no memory management at all — reference counting IS a form of it, just automatic; the cyclic garbage collector (next question) only handles cases reference counting can't.
If reference counting frees objects immediately, why does Python still need a separate garbage collector?
Hint
Reference counting has one specific blind spot.
Two objects that only reference each other never hit a count of zero on their own.
Answer
Reference counting can't free reference cycles since their counts never naturally reach zero. Python's separate cyclic garbage collector periodically scans for and cleans up exactly these unreachable cycles.
If object A holds a reference to B, and B holds a reference back to A, and nothing else references either, both still have a reference count of 1 (from each other) — reference counting alone would leak them forever. Python's generational cyclic garbage collector (the gc module) periodically looks for groups of objects unreachable from the rest of the program even though their internal reference counts are nonzero, and collects them.
import gc
class Node:
def __init__(self):
self.other = None
a, b = Node(), Node()
a.other = b
b.other = a # a cycle: a -> b -> a
del a, b # no external references left, but the cycle still exists internally
gc.collect() # the cyclic collector finds and frees the cyclea --other--> b ^ | |__other_______| Refcount(a) = 1 (from b), Refcount(b) = 1 (from a) Neither reaches 0 on its own -- cyclic GC required to free both
Watch out
- Relying on __del__ methods for critical cleanup on objects that might be part of a cycle — prefer context managers for deterministic cleanup instead of __del__.
What does __slots__ do, and why would you use it?
Hint
It changes how instance attributes are stored under the hood.
Normally each instance gets a full dict for its attributes — this trades that away for less memory.
Answer
Defining __slots__ = ('x', 'y') tells Python to store those attributes in a fixed, compact structure instead of a per-instance dict, reducing memory use significantly for many instances — at the cost of losing arbitrary new attributes.
By default, every instance carries its own __dict__ to hold attributes, which is flexible but has real memory overhead — noticeable creating millions of small objects. __slots__ tells Python exactly which attribute names are allowed, letting it allocate a fixed-size structure per instance instead of a full dict. The tradeoff: you can no longer set arbitrary new attributes not listed in __slots__, and multiple inheritance gets trickier.
class PointDict:
def __init__(self, x, y):
self.x, self.y = x, y
class PointSlots:
__slots__ = ('x', 'y')
def __init__(self, x, y):
self.x, self.y = x, y
p = PointSlots(1, 2)
# p.z = 3 # AttributeError -- 'z' not declared in __slots__Watch out
- Adding __slots__ to a class that's also expected to support dynamic attributes (e.g. monkey-patching in tests) — silently breaks that flexibility.
- Forgetting __slots__ interactions get tricky across multiple inheritance, since each class's slots layout must be compatible.