Skip to main content
Python · lesson 30 of 34
Python advanced Lesson 30 of 34

Python 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.

Frequently Asked Questions

Does Python have manual memory management?
No. CPython frees objects automatically, mostly through reference counting, with a cyclic garbage collector for groups of objects that refer to each other. You never free memory yourself, but knowing the model helps you avoid leaks and reduce memory use.
What is a memory leak in Python?
Usually it is memory that is still reachable but no longer needed: an unbounded cache or list at module level, entries piling up in a global registry, or listeners that are never unsubscribed. The garbage collector cannot free anything that is still referenced.
When do __slots__ actually help?
When you create very many small instances of one class. __slots__ removes the per-instance __dict__, which saves tens of bytes or more per object and speeds up attribute access a little, at the cost of not being able to add new attributes at runtime.
What is the difference between sys.getsizeof and real memory use?
sys.getsizeof reports only the size of the object itself, not the objects it refers to. A list's size counts its array of pointers, not the elements. Use tracemalloc to measure what a piece of code actually allocates.
When should I use weakref?
When you want to refer to an object without keeping it alive, such as a cache of objects that should disappear once nobody else uses them, or observers that should not prevent their owner from being freed. A weak reference returns None once the object is gone.