Python · lesson 30 of 34
Beginner
16- 1 Introduction to Python
- 2 Setting Up Python
- 3 Python Variables and Type System
- 4 Python Data Types
- 5 Python Operators
- 6 Python Strings
- 7 Control Flow in Python
- 8 Python Functions and Parameters
- 9 Python Data Structures
- 10 Python Collections
- 11 Python Modules and Packages
- 12 Object-Oriented Programming in Python
- 13 Encapsulation and Name Mangling in Python
- 14 Polymorphism and Duck Typing in Python
- 15 Abstraction with Abstract Base Classes in Python
- 21 Building a Python CLI Tool
Intermediate
12- 16 Multiple Inheritance and MRO in Python
- 17 Composition vs Inheritance in Python
- 18 Python Dataclasses
- 19 Error Handling in Python
- 20 File I/O, pathlib, and JSON in Python
- 22 Python Decorators and Closures
- 23 Python Generators
- 24 Python Context Managers
- 25 Python Concurrency
- 27 Testing Python Code
- 28 Python Security
- 34 Python Interview Prep
Advanced
6Python Memory Management
Watch CPython free objects: reference counts, cycles and gc.collect, weakref caches, __slots__ savings and tracemalloc profiling, with measured output.
By SkillByExample · Updated
CPython frees memory automatically using two mechanisms: reference counting, which frees an object the moment nothing refers to it, and a cyclic garbage collector for objects that refer to each other. Output below is from CPython 3.13 on 64-bit Linux; byte counts differ on other builds and versions.
Reference counting
sys.getrefcount shows how many references an object has — including the temporary one created by passing it to getrefcount itself.
import sys
class Tracked:
def __del__(self):
print(" freed")
x = Tracked()
print(sys.getrefcount(x))
y = x
holder = [x]
print(sys.getrefcount(x))
del y
holder.clear()
print(sys.getrefcount(x))
print("deleting x")
del x
print("after del")
$ python refcount.py
2
4
2
deleting x
freed
after del
The baseline is 2: x plus the function argument. Another name and a list slot raised it to 4. __del__ ran during del x — before “after del” printed — because the count hit zero at that instant. That immediacy is reference counting’s big advantage.
Cycles need the garbage collector
Two objects that point at each other keep each other’s count above zero, even when nothing else can reach them.
import gc
class Node:
def __init__(self, name):
self.name = name
self.other = None
def __del__(self):
print(f" freeing {self.name}")
gc.disable() # make the timing explicit
a, b = Node("a"), Node("b")
a.other, b.other = b, a # a -> b -> a
del a, b
print("after del: nothing freed yet")
print("collected:", gc.collect())
gc.enable()
$ python cycles.py
after del: nothing freed yet
freeing a
freeing b
collected: 2
del a, b removed the names, but each node still held a reference to the other, so neither was freed. gc.collect() found the unreachable cycle and freed both. Normally the collector runs on its own after enough allocations (gc.get_threshold()); it was disabled here only to show the gap.
weakref: refer without owning
A weak reference doesn’t count toward an object’s reference count.
import weakref
class Resource:
def __init__(self, name):
self.name = name
obj = Resource("db connection")
weak = weakref.ref(obj)
print(weak() is obj, weak().name)
del obj
print(weak())
cache = weakref.WeakValueDictionary()
big = Resource("report")
cache["report"] = big
print("report" in cache)
del big
print("report" in cache, len(cache))
$ python weak.py
True db connection
None
True
False 0
Calling the weak reference returns the object while it lives and None once it’s gone. A WeakValueDictionary is a cache that empties itself: deleting the last strong reference, big, evicted the entry automatically. A normal dict would have kept the report alive forever.
__slots__
By default each instance stores its attributes in its own __dict__; __slots__ replaces that with fixed storage.
import sys
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
p1, p2 = PointDict(1, 2), PointSlots(1, 2)
print(hasattr(p1, "__dict__"), hasattr(p2, "__dict__"))
print(sys.getsizeof(p1) + sys.getsizeof(p1.__dict__), "bytes with __dict__")
print(sys.getsizeof(p2), "bytes with __slots__")
p1.z = 3
try:
p2.z = 3
except AttributeError as e:
print("AttributeError:", e)
$ python slots.py
True False
344 bytes with __dict__
48 bytes with __slots__
AttributeError: 'PointSlots' object has no attribute 'z' and no __dict__ for setting new attributes
The slotted instance has no __dict__ at all, so it can’t grow new attributes — that’s the trade. Asking for p1.__dict__ forces CPython to materialise a full dict, which inflates the first figure; recent CPython versions store instance attributes more compactly until something asks for the dict. Measuring many instances, as below, gives a fairer picture.
Measuring with tracemalloc
tracemalloc records what Python actually allocates, including the objects that getsizeof ignores.
import tracemalloc
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
def measure(cls) -> int:
tracemalloc.start()
points = [cls(i, i) for i in range(100_000)]
current, _peak = tracemalloc.get_traced_memory()
tracemalloc.stop()
return current
for cls in (PointDict, PointSlots):
print(f"{cls.__name__:10} {measure(cls) / 1e6:.1f} MB for 100,000 points")
$ python measure_slots.py
PointDict 12.8 MB for 100,000 points
PointSlots 8.8 MB for 100,000 points
With the list and the integers included, slots saved roughly 30% here. The integers are the same in both versions, so the per-instance saving is larger than the percentage suggests. Numbers vary a little run to run and between Python versions.
Finding the line that allocates
A snapshot groups allocations by source line, which points straight at the hungry code.
import tracemalloc
tracemalloc.start()
names = [f"user-{i}" for i in range(50_000)]
lookup = {name: len(name) for name in names}
small = list(range(100))
snapshot = tracemalloc.take_snapshot()
for stat in snapshot.statistics("lineno")[:3]:
frame = stat.traceback[0]
print(f"line {frame.lineno}: {stat.size / 1024:.0f} KiB in {stat.count} blocks")
tracemalloc.stop()
$ python top_lines.py
line 4: 2914 KiB in 50002 blocks
line 5: 1877 KiB in 1 blocks
line 6: 1 KiB in 2 blocks
Line 4 made 50,000 separate string objects plus the list that holds them — about one block each; line 5 is one big dict table, a single large block. Exact sizes vary between runs and versions, but the ranking is what you act on.
Interned strings
CPython reuses (“interns”) some strings, so two equal strings may or may not be the same object.
import sys
a = "hello"
b = "hello"
print(a is b)
c = "".join(["hel", "lo"])
print(c == a, c is a)
d = sys.intern(c)
print(d is a)
$ python intern.py
True
True False
True
Identifier-like literals are interned at compile time, so a is b. A string built at runtime is a new object with equal contents. sys.intern returns the canonical copy — useful when the same string appears millions of times. This is also why you compare strings with ==, never is.
Practice
1. Predict and then check the reference count of a list after you put it into two different dicts.
Solution
import sys
data = [1, 2, 3]
d1 = {"a": data}
d2 = {"b": data}
print(sys.getrefcount(data))
del d1
print(sys.getrefcount(data))
$ python refcount_dicts.py
4
3
data, two dict values and the getrefcount argument make 4; deleting d1 drops its reference.
2. Break the cycle in cycles.py manually with a.other = None before del, and show the nodes are freed without gc.collect().
Solution
import gc
class Node:
def __init__(self, name):
self.name, self.other = name, None
def __del__(self):
print(f" freeing {self.name}")
gc.disable()
a, b = Node("a"), Node("b")
a.other, b.other = b, a
a.other = None # break the cycle
del a, b
print("after del")
gc.enable()
$ python break_cycle.py
freeing b
freeing a
after del
With the cycle broken, reference counting alone frees both nodes the moment the names go away — b first, because when del removed a’s name, b.other still pointed at a; freeing b released that last reference.
3. Use tracemalloc to compare the memory of [x * x for x in range(100_000)] with sum(x * x for x in range(100_000)).
Solution
import tracemalloc
def peak(fn) -> float:
tracemalloc.start()
fn()
_, peak_bytes = tracemalloc.get_traced_memory()
tracemalloc.stop()
return peak_bytes / 1024
print(f"list: {peak(lambda: sum([x * x for x in range(100_000)])):.0f} KiB peak")
print(f"gen: {peak(lambda: sum(x * x for x in range(100_000))):.0f} KiB peak")
$ python peak.py
list: 3907 KiB peak
gen: 0 KiB peak
The list version holds all 100,000 squares at once; the generator holds one at a time. Exact figures vary.
4. Build a tiny cache with weakref.WeakValueDictionary that returns the same Resource object for repeated lookups while someone holds it, and a new one after it was released.
Solution
import weakref
class Resource:
count = 0
def __init__(self, key):
Resource.count += 1
self.key = key
_cache = weakref.WeakValueDictionary()
def get(key):
obj = _cache.get(key)
if obj is None:
obj = _cache[key] = Resource(key)
return obj
r1 = get("x")
r2 = get("x")
print(r1 is r2, Resource.count)
del r1, r2
r3 = get("x")
print(Resource.count)
$ python weak_cache.py
True 1
2
While r1 lived, the cache handed out the same object; once both names were gone the entry vanished and the next lookup built a fresh one.