Chapter 23 — Memory, References, Garbage Collection, and Object Lifecycle
1. Opening problem
A service processes many requests. Memory usage grows from 200 MB to 3 GB and never returns to its original level.
A developer says:
Python has garbage collection, so this cannot be a memory leak.
That conclusion is wrong.
A Python program can retain memory because of:
- objects still reachable from caches or globals;
- listener or callback registrations;
- reference cycles;
- unbounded queues;
- task collections;
- thread-local or context-local state;
- native extension allocations;
- allocator behavior;
- fragmentation;
- delayed cleanup;
- large temporary peaks;
- free-threaded runtime behavior;
- resource leaks unrelated to object memory.
Garbage collection only reclaims objects that the runtime can identify as unreachable and eligible for collection.
2. Reachability
An object remains alive while it is reachable from roots.
Roots include:
- module globals;
- active stack frames;
- class attributes;
- running tasks;
- thread state;
- C extension references;
- containers reachable from other roots.
Example:
CACHE: dict[
str,
bytes,
] = {}
def store(
key: str,
value: bytes,
) -> None:
CACHE[key] = value
Nothing is “leaking” from the collector's perspective. The global cache intentionally retains every value.
Memory ownership must be designed.
3. References and bindings
first = [
1,
2,
3,
]
second = first
Two names refer to one list.
del first
del removes one binding. It does not directly destroy the object.
The object remains reachable through second.
This is why del variable is not a universal memory-release command.
4. CPython reference counting
Traditional CPython uses reference counting as a primary memory-management mechanism, supplemented by cyclic garbage collection.
Conceptually:
- each strong reference contributes to an object's reference count;
- when the count reaches zero, many objects can be deallocated promptly;
- cycles require additional detection;
- implementation details can differ across interpreters and builds.
Do not write correctness logic that depends on immediate deallocation.
Code should remain valid on other Python implementations and under evolving CPython runtime modes.
5. Reference cycles
class Node:
def __init__(
self,
name: str,
) -> None:
self.name = name
self.other: Node | None = None
first = Node("first")
second = Node("second")
first.other = second
second.other = first
Deleting external names:
del first
del second
does not reduce each object's reference count to zero because they reference one another.
The cyclic collector can detect many such unreachable cycles.
Cycles are not automatically bugs, but they complicate lifetime and finalization.
6. The gc module
import gc
Useful operations include:
gc.collect()
gc.get_stats()
gc.get_count()
gc.get_referrers(
value
)
Use these carefully.
gc.get_referrers can expose internal objects, frames, or temporary references and is mainly a debugging tool.
Forcing collection in every request is usually a performance smell, not a fix.
Find the retention cause.
7. Generational collection
Cyclic collectors use generations or age-related strategies to avoid scanning every tracked object on every cycle.
Exact generation behavior and thresholds can change across Python versions.
Do not tune collector thresholds from folklore.
Measure:
- allocation rate;
- pause behavior;
- retained cycles;
- workload latency;
- interpreter version;
- free-threaded versus default build.
Treat collector tuning as an evidence-based optimization.
8. __del__
class Resource:
def __del__(
self,
) -> None:
self.close()
This looks like automatic cleanup.
Problems:
- execution timing is not a portable guarantee;
- interpreter shutdown may have partially destroyed globals;
- finalization may occur on an unexpected thread or context;
- exceptions from
__del__cannot be handled normally; - cycles and resurrection complicate behavior;
- resource cleanup may require awaiting;
- process termination can bypass cleanup.
Use context managers for deterministic resource lifetime.
9. Object resurrection
A finalizer can make an object reachable again:
RESURRECTED = None
class Example:
def __del__(
self,
) -> None:
global RESURRECTED
RESURRECTED = self
This is object resurrection.
It creates subtle lifecycle behavior and should almost never be part of application design.
Finalizers should not reintroduce objects into live global state.
10. weakref.finalize
import weakref
class Resource:
pass
resource = Resource()
finalizer = weakref.finalize(
resource,
release_external_handle,
handle_id,
)
weakref.finalize provides a cleaner finalization mechanism than directly implementing many __del__ patterns.
It still does not replace explicit ownership for critical resources.
Use it as a safety net, not the primary lifecycle mechanism.
11. Weak references
A weak reference points to an object without keeping it alive.
import weakref
class User:
pass
user = User()
reference = weakref.ref(
user
)
print(
reference()
)
After the object is collected:
print(
reference()
)
returns None.
Use cases:
- caches that should not own values;
- observer lists;
- metadata associated with live objects;
- avoiding some cycles.
Not every built-in type supports weak references directly.
12. Weak dictionaries
from weakref import (
WeakKeyDictionary,
WeakValueDictionary,
)
Weak-value cache:
cache = WeakValueDictionary()
cache["user-42"] = user
The cache entry disappears when no strong references to the user remain.
This is appropriate only when cache presence is optional.
Do not use weak storage when the cache must guarantee object lifetime.
13. Callback and listener leaks
class EventBus:
def __init__(self) -> None:
self._listeners = []
def subscribe(
self,
listener,
) -> None:
self._listeners.append(
listener
)
A bound method holds a reference to its instance.
If the bus lives globally, every subscribed instance may remain alive.
Solutions:
- explicit unsubscribe;
- scoped subscription context manager;
- weak method references;
- bounded bus lifetime;
- ownership documentation.
Weak references are not always correct because a listener disappearing silently may hide bugs.
14. Closure retention
def create_handler(
large_dataset,
):
def handler(
event,
):
return (
large_dataset.lookup(
event.key
)
)
return handler
The closure keeps large_dataset alive.
If the handler is registered globally, the dataset remains reachable.
Inspect closures:
handler.__closure__
Use closure capture intentionally.
Sometimes capture only the needed lightweight object or ID.
15. Task retention
TASKS: set[
asyncio.Task[object]
] = set()
If completed tasks are never removed, the set retains:
- task objects;
- results;
- exceptions;
- traceback frames;
- referenced locals.
Cleanup pattern:
task = asyncio.create_task(
work()
)
TASKS.add(task)
task.add_done_callback(
TASKS.discard
)
You must still observe exceptions appropriately.
Structured concurrency often avoids global task registries.
16. Tracebacks retain frames
Caught exceptions can keep tracebacks and frames alive.
ERRORS = []
try:
operation()
except Exception as error:
ERRORS.append(error)
The stored exception may retain local objects through its traceback.
For long-lived error storage:
- store a summarized record;
- format the traceback;
- remove sensitive data;
- avoid retaining the exception object indefinitely.
Logging systems should not keep raw exception graphs forever.
17. Caches
Unbounded cache:
CACHE[key] = result
Ask:
- maximum entries?
- maximum bytes?
- expiration?
- eviction policy?
- tenant isolation?
- cache invalidation?
- weak or strong ownership?
- metrics?
- thread safety?
- serialization cost?
functools.lru_cache bounds by entry count, not necessarily by byte size.
One entry may contain a huge object.
18. Memory is not always returned to the OS
An object can be freed for reuse by Python's allocator without process resident memory immediately shrinking.
Reasons can include:
- allocator arenas;
- fragmentation;
- native library behavior;
- retained pools;
- free-threaded allocator behavior;
- operating-system allocation strategies.
Therefore:
Stable high RSS does not by itself prove live Python objects are still retained.
Measure both:
- Python allocation snapshots;
- process-level memory;
- object counts;
- workload behavior over repeated cycles.
19. tracemalloc
Start tracing:
import tracemalloc
tracemalloc.start(
10
)
Take a snapshot:
snapshot = (
tracemalloc.take_snapshot()
)
Inspect top lines:
for statistic in (
snapshot.statistics(
"lineno"
)[:10]
):
print(statistic)
Compare snapshots:
before = (
tracemalloc.take_snapshot()
)
run_workload()
after = (
tracemalloc.take_snapshot()
)
for difference in (
after.compare_to(
before,
"lineno",
)[:10]
):
print(difference)
tracemalloc traces Python memory allocations it can observe. It may not account for every native allocation.
20. Snapshot methodology
A useful experiment:
- warm up imports and caches;
- force or wait for normal cleanup if appropriate;
- take baseline snapshot;
- run a repeatable workload many times;
- take another snapshot;
- compare by traceback or filename;
- inspect retained object ownership;
- repeat after a proposed fix.
Avoid comparing only one request before and after. Startup effects dominate.
21. Object size
import sys
sys.getsizeof(value)
This reports the immediate object size, not recursively owned objects.
A list size does not include the full size of every referenced element.
Deep-size calculations require graph traversal and careful treatment of shared references.
Do not sum naïvely and double-count shared objects.
22. Slots and memory
class Point:
__slots__ = (
"x",
"y",
)
Slots can reduce per-instance overhead by avoiding a normal per-instance dictionary.
Use slots when:
- many objects exist;
- shape is fixed;
- framework compatibility is verified;
- memory measurements justify it.
Slots do not fix retained objects or unbounded caches.
Optimize ownership first.
23. Copying and peak memory
copy = data[:]
payload = json.loads(
json.dumps(data)
)
converted = list(
generator
)
These operations may temporarily hold multiple large representations.
Peak memory can be more important than steady-state memory.
Prefer streaming, chunking, and ownership transfer where appropriate.
24. Native resources are not ordinary memory
Examples:
- file descriptors;
- sockets;
- database connections;
- GPU buffers;
- native library allocations;
- subprocess handles.
A program may have no Python-object leak but still exhaust resources.
Use:
- context managers;
- explicit close;
- pool limits;
- process metrics;
- library-specific diagnostics;
- operating-system tools.
Do not wait for garbage collection to release scarce external resources.
25. Free-threaded CPython considerations
Optional free-threaded CPython builds can disable the GIL.
Memory-management implementation details differ from traditional builds, including forms of reference-counting optimization and allocator behavior.
Consequences include:
- objects may be deallocated at different times;
- memory-use profiles can differ;
- some objects can be treated specially by the runtime;
- concurrent access requires explicit synchronization;
- extensions may re-enable the GIL or lack support.
Do not build lifecycle correctness around exact reference-count timing.
26. Leak investigation checklist
- Is growth reproducible?
- Does it plateau?
- Is growth Python-traced or native?
- Which allocation sites grow?
- Which objects remain reachable?
- Which roots retain them?
- Are tasks, callbacks, caches, queues, or tracebacks unbounded?
- Are resources explicitly closed?
- Does growth occur only under concurrency?
- Does behavior differ by interpreter build?
- Is process RSS being confused with live-object memory?
- Can a minimal workload reproduce the issue?
27. TypeScript comparison
Node.js uses garbage collection rather than CPython-style reference counting as its primary model.
Both ecosystems can leak through reachability:
- global maps;
- event listeners;
- closures;
- timers;
- pending promises or tasks;
- unbounded queues;
- native resources.
Python-specific concerns include:
- reference cycles;
__del__;- weak references;
- CPython allocator behavior;
- traceback retention;
- optional free-threaded builds.
The core mental model is shared:
Reachable objects remain alive.
28. Common mistakes
“The collector will close it”
Use deterministic resource cleanup.
Calling gc.collect() as a fix
Find the owner.
Storing raw exceptions indefinitely
Tracebacks retain frames.
Global callback registries
Bound methods retain instances.
Unbounded task sets
Remove completed tasks and observe results.
getsizeof as deep size
It is shallow.
Assuming RSS equals live objects
Allocator behavior matters.
Weak references everywhere
Ownership may become nondeterministic.
Relying on CPython timing
Write portable lifecycle logic.
29. English vocabulary
| Term | Meaning |
|---|---|
| reachability | ability to access an object from a live root |
| strong reference | reference keeping an object alive |
| weak reference | reference not controlling lifetime |
| reference cycle | objects retaining one another |
| finalization | last-chance cleanup associated with object death |
| allocation | reserving memory for an object |
| retention | keeping an object reachable |
| resident memory | process memory currently resident in RAM |
| fragmentation | free memory split into unusable or unreleased regions |
| snapshot | recorded view of tracked allocations |
Useful sentences:
- “The global callback registry retains every bound instance.”
- “The exception object keeps its traceback frames alive.”
- “The allocator may reuse memory without returning it to the operating system.”
- “This is an ownership leak rather than a garbage-collector defect.”
- “The weak cache does not guarantee value lifetime.”
- “Correctness must not depend on immediate reference-count deallocation.”
30. Speaking task
Explain for twelve minutes:
Why garbage collection does not prevent memory leaks.
Include reachability, caches, listeners, closures, tasks, tracebacks, allocators, and external resources.
31. Writing task
Write a 500-word investigation plan for a web service whose memory grows only under concurrent load.
32. Exercises
Exercise 1
Create and collect a reference cycle.
Exercise 2
Implement a scoped event subscription that unsubscribes automatically.
Exercise 3
Use a weak-value cache and explain when entries disappear.
Exercise 4
Compare tracemalloc snapshots around a repeatable workload.
Exercise 5
Fix a global task registry that retains completed tasks.
Exercise 6
Explain why process RSS may remain high after objects are freed.
33. Complete solutions
Solution 1
import gc
import weakref
class Node:
def __init__(
self,
name: str,
) -> None:
self.name = name
self.other: Node | None = None
first = Node("first")
second = Node("second")
first.other = second
second.other = first
reference = weakref.ref(
first
)
del first
del second
gc.collect()
assert reference() is None
Solution 2
from contextlib import (
contextmanager,
)
class EventBus:
def __init__(self) -> None:
self._listeners: list[
object
] = []
def subscribe(
self,
listener,
) -> None:
self._listeners.append(
listener
)
def unsubscribe(
self,
listener,
) -> None:
self._listeners.remove(
listener
)
@contextmanager
def subscribed(
bus: EventBus,
listener,
):
bus.subscribe(listener)
try:
yield
finally:
bus.unsubscribe(
listener
)
Solution 3
from weakref import (
WeakValueDictionary,
)
cache = WeakValueDictionary()
class User:
pass
user = User()
cache["user"] = user
assert "user" in cache
del user
# After collection, the entry can disappear
# because the cache does not strongly own it.
Solution 4
import tracemalloc
tracemalloc.start(
10
)
before = (
tracemalloc.take_snapshot()
)
for _ in range(100):
run_workload()
after = (
tracemalloc.take_snapshot()
)
for difference in (
after.compare_to(
before,
"traceback",
)[:10]
):
print(difference)
Warm up the application before the baseline and make the workload repeatable.
Solution 5
BACKGROUND_TASKS: set[
asyncio.Task[object]
] = set()
def start_background(
coroutine,
) -> None:
task = asyncio.create_task(
coroutine
)
BACKGROUND_TASKS.add(
task
)
task.add_done_callback(
BACKGROUND_TASKS.discard
)
A production design must also observe failures. Prefer a task group when task lifetime belongs to a request or service scope.
Solution 6
Python may release objects to its allocator, which keeps memory available for future Python allocations. Fragmentation and native allocators can also prevent immediate return to the operating system.
Therefore, unchanged RSS does not prove that the original objects remain live.
34. Chapter checkpoint
You should now be able to explain:
- reachability;
- references and bindings;
- reference counting;
- cycles;
- cyclic collection;
- finalizer risks;
- weak references;
- callback retention;
- closure retention;
- task and traceback retention;
- cache design;
tracemalloc;- shallow size measurement;
- allocator versus RSS;
- external-resource leaks;
- free-threaded lifecycle differences.