Python memory management is the quiet engine running under every program you write. You never call malloc() and you never call free(), yet objects appear when you need them and vanish when you do not. This post opens the hood: reference counting, generational garbage collection, circular references, the Global Interpreter Lock (GIL), weak references, and how to measure real memory use with tracemalloc and sys.getsizeof().
“Simplicity does not precede complexity, but follows it.”
Alan Perlis, Epigrams on Programming
Last Updated: July 2026 | Tested on: Python 3.14.6 | Difficulty: Advanced | Reading Time: 18 minutes
Here is the simple version first. Every object in Python keeps a little counter called its reference count. That number says, “how many names are pointing at me right now?” The moment that count drops to zero, the object is thrown out and its memory is handed back, instantly. No waiting, no sweep, no pause.
Think of a hotel room with a key counter at the front desk. Every time someone takes a key to room 204, the counter goes up. Every time a key comes back, it goes down. When the counter hits zero, housekeeping cleans the room right away and it is free for the next guest. That counter is reference counting, and it does about 95 percent of Python’s memory work on its own.
The other 5 percent is the tricky part. Sometimes two objects hold keys to each other, so neither counter ever reaches zero even though nobody else cares about them. For that, Python runs a separate garbage collector that hunts down these little loops and clears them out. And sitting on top of all this is the GIL, a lock that makes sure two threads never touch the same reference count at the same time. People love to blame the GIL for being slow. The truth is the opposite: it is what keeps your memory from getting corrupted. Let us see all three pieces in action.
Tip: click the diagram to open it full screen, then use the zoom and pan controls for a closer look.
The diagram shows Python memory management as three layers working together. Reference counting (the primary mechanism) frees an object the instant its count hits zero. Cyclic garbage collection (the backup) detects and breaks reference cycles that counting alone cannot. The Global Interpreter Lock (GIL) serializes access to objects so two threads never corrupt a reference count. That combination is why Python is memory-safe yet runs only one thread of Python bytecode at a time. Once you see how these layers fit, choosing between threading, multiprocessing, and asyncio stops being guesswork.
Table of Contents
Reference Counting: The Primary Mechanism
Every object carries a number: how many names currently point at it. Bind a new name and the number goes up. Drop a name and it goes down. You can watch this happen live with sys.getrefcount(). One small catch: the act of passing the object into getrefcount() creates one extra temporary reference, so the number you see is always one higher than you might expect.
📄 refcount.py: watch reference counts change in real time
import sys
a = [1, 2, 3]
print(f"After creation: {sys.getrefcount(a)}") # 2 (a + getrefcount's arg)
b = a # b now points at the same list
print(f"After b = a: {sys.getrefcount(a)}") # 3
my_list = [a, a, a] # three more references
print(f"In a list 3x: {sys.getrefcount(a)}") # 6
del b
print(f"After del b: {sys.getrefcount(a)}") # 5
del my_list
print(f"After del list: {sys.getrefcount(a)}") # 2
▶ Output
After creation: 2 After b = a: 3 In a list 3x: 6 After del b: 5 After del list: 2
What happened here: Each name that points at the list bumps its reference count up by one. b = a adds a second pointer, so the count climbs. Putting the list inside [a, a, a] adds three more pointers in one go. When you del a name, that pointer is removed and the count falls back down. The starting and ending value of 2 is the real count of 1 (the name a) plus the temporary reference that getrefcount() itself holds while it runs. The key idea: the moment a count would reach zero, the object is freed on the spot. There is no delay and no garbage collection cycle to wait for.
Circular References and the Garbage Collector
Reference counting is fast and simple, but it has one blind spot. Picture two friends, say Rahul and Anvi, who each hold the other’s only house key. Even after both move out of town and nobody else has a key, each key is still “in use” by the other person. The count never drops to zero, so plain reference counting would never clean them up. This is a circular reference, and it is exactly why Python ships a second mechanism: a cyclic garbage collector that finds these loops and frees them.
📄 circular.py: reference counting alone cannot handle cycles
import gc
class Node:
def __init__(self, name):
self.name = name
self.partner = None
def __del__(self):
print(f" Freeing {self.name}")
# Build a cycle: a points to b, b points back to a
a = Node("Rahul")
b = Node("Anvi")
a.partner = b
b.partner = a
# Drop both names, but each object still holds a key to the other,
# so neither reference count reaches zero and __del__ does not run yet.
del a
del b
print("Before gc.collect():")
gc.collect() # the collector spots the loop and frees both objects
print("After gc.collect():")
▶ Output
Before gc.collect(): Freeing Rahul Freeing Anvi After gc.collect():
What happened here: Notice the timing in the output. Both objects stayed alive after del a and del b, because they still pointed at each other. Their __del__ messages only printed when gc.collect() ran and broke the cycle. That is the cyclic garbage collector doing the job reference counting could not. In normal code you almost never call gc.collect() by hand. Python runs it automatically in the background. We call it here only so you can see the exact moment the cycle gets cleaned up.
Generational Garbage Collection
The collector does not scan every object on every run. That would be slow. Instead it sorts objects into three groups, called generations, based on a simple bet: an object that has already survived a few rounds of collection is probably long-lived, so checking it often is a waste of time. Think of your fridge. You inspect the leftovers from last night constantly, but the jar of mustard that has survived six months gets a glance maybe once a season. Gen 0 is last night’s leftovers. Gen 2 is the mustard.
📄 gc_generations.py: three generations of objects
import gc # Gen 0: new objects, collected most often # Gen 1: survived one collection, checked less often # Gen 2: long-lived, checked rarely print(gc.get_count()) # live counts per generation right now print(gc.get_threshold()) # the trigger points for each generation
▶ Output
(43, 3, 0) (2000, 10, 10)
What happened here: gc.get_threshold() returns (2000, 10, 10) on Python 3.14.6. The first number is the one most people get wrong: a Gen 0 collection kicks in once there are about 2000 more new objects than freed ones since the last sweep. (Older write-ups say 700, which was the default before Python 3.13 raised it to 2000 to cut down on needless collections.) The two 10s mean Gen 1 is collected after every 10 Gen 0 sweeps, and Gen 2 after every 10 Gen 1 sweeps.
The first tuple, gc.get_count(), is just a live snapshot of how many objects are sitting in each generation at that instant, so your numbers will differ from the ones shown here and even change between runs. Most objects die young in Gen 0 and never bother the higher generations at all.
The GIL: Why It Exists
The Global Interpreter Lock is a single lock that lets only one thread run Python bytecode at any given moment. Here is why it has to exist. Reference counting is not thread-safe on its own. If two threads tried to bump the same count at the same instant, one update could clobber the other, the count would drift, and objects would either leak forever or get freed while still in use. The GIL is the bouncer that lets exactly one thread touch Python objects at a time, which keeps every reference count honest.
The trade-off is that two threads cannot run Python-level CPU work in parallel. But here is the part people miss: the GIL is released whenever a thread waits on input or output, like reading a file, sleeping, or waiting on the network. So threads still give you real concurrency for I/O work. They just cannot speed up pure number-crunching.
📄 gil_demo.py: the GIL releases for I/O but holds for CPU work
import threading
import time
def cpu_bound():
"""Pure CPU work. The GIL is held, so two of these cannot run in parallel."""
total = sum(i * i for i in range(10_000_000))
return total
def io_bound():
"""Waiting work. The GIL is released during sleep, so these overlap."""
time.sleep(1)
# CPU-bound: two threads, but the GIL serializes them (no real speedup)
start = time.time()
t1 = threading.Thread(target=cpu_bound)
t2 = threading.Thread(target=cpu_bound)
t1.start(); t2.start()
t1.join(); t2.join()
print(f"CPU-bound threaded: {time.time() - start:.2f}s")
# I/O-bound: two 1-second sleeps overlap because the GIL is released
start = time.time()
t1 = threading.Thread(target=io_bound)
t2 = threading.Thread(target=io_bound)
t1.start(); t2.start()
t1.join(); t2.join()
print(f"I/O-bound threaded: {time.time() - start:.2f}s")
▶ Output (CPU time varies by machine; the I/O number is the point)
CPU-bound threaded: 2.73s I/O-bound threaded: 1.00s
What happened here: The two CPU-bound threads did not run in parallel. They took turns, so the total time is roughly the cost of running the work twice in a row (your exact seconds will differ, the shape is what matters). The two I/O-bound threads, though, both slept at the same time, so two 1-second sleeps finished in about 1 second total, not 2. That is the whole rule in one experiment: for CPU-bound work, reach for multiprocessing, which sidesteps the GIL by using separate processes. For I/O-bound work, threading or asyncio is perfect, because the GIL steps aside while you wait.
import sys; sys._is_gil_enabled(), which returns True. We look at the free-threaded build in detail in the Multithreading post.Weak References
Sometimes you want to point at an object without keeping it alive. A normal reference is like grabbing someone’s arm: as long as you hold on, they cannot leave. A weak reference is like writing their name in your notebook. You can look them up later, but your note does nothing to stop them from walking out the door. The moment the last real reference is gone, the object is freed and your weak reference quietly turns into None.
This is exactly what you want for caches and lookup tables. You can keep a weak pointer to a cached object so that if the rest of the program stops using it, the cache does not keep it alive forever and waste memory.
📄 weakref_demo.py: a reference that does not keep the object alive
import weakref
class Sensor:
def __init__(self, name):
self.name = name
# A normal reference keeps the object alive.
device = Sensor("temp-1")
# A weak reference does NOT keep it alive.
ref = weakref.ref(device)
print("Before delete:", ref()) # the live Sensor object
del device # drop the only strong reference
print("After delete: ", ref()) # None, the object is gone
▶ Output (the memory address will differ on your machine)
Before delete: <__main__.Sensor object at 0x0000022A7DAB9010> After delete: None
What happened here: Calling ref() while the object was alive handed back the real Sensor. The weak reference did not bump its reference count, so when del device removed the only strong reference, the object was freed at once. After that, calling ref() returns None, which is the weak reference telling you, “that object is gone now.” The hex address in the first line is just where the object happened to live in memory, so yours will look different. The None in the second line is the part that always holds true.
Measuring Object Size with sys.getsizeof()
When you want to know how heavy an object is, sys.getsizeof() reports its size in bytes. There is one trap worth knowing up front: it measures only the container itself, not the things the container points at. It is like weighing an address book: the book stays light no matter how big the houses listed inside it are. A list of a million items reports almost the same size whether those items are tiny integers or huge strings, because the list only stores pointers to them, not the items inline.
📄 getsizeof.py: how many bytes does each object take
import sys
print("Empty list: ", sys.getsizeof([]))
print("List of 3: ", sys.getsizeof([1, 2, 3]))
print("Empty dict: ", sys.getsizeof({}))
print("Short str: ", sys.getsizeof("hi"))
print("Small int: ", sys.getsizeof(0))
print("Big int: ", sys.getsizeof(10**100))
# getsizeof is shallow: it counts the container, not what it points to.
inner = [0] * 1000
outer = [inner, inner, inner]
print("Outer list (shallow):", sys.getsizeof(outer))
▶ Output
Empty list: 56 List of 3: 88 Empty dict: 64 Short str: 43 Small int: 28 Big int: 72 Outer list (shallow): 80
What happened here: An empty list already costs 56 bytes because of the object header and bookkeeping CPython keeps. Even the integer 0 takes 28 bytes, since every Python int is a full object, not a raw machine number. The last line is the lesson to remember: outer holds three 1000-element lists, but getsizeof(outer) reports only 80 bytes. It counted the three pointers, not the 3000 zeros they lead to. So when you size up a nested structure, walk it yourself or use tracemalloc for the true total, which is exactly what we do next.
Memory Profiling with tracemalloc
When a program slowly eats more and more memory, guessing where it goes is painful. tracemalloc is the standard-library tool that records every allocation and tells you which exact line of code is responsible. Think of it as an itemized credit card statement: instead of just seeing that money left your account, you see exactly which purchase cost what. Turn it on, do some work, take a snapshot, and ask it for the biggest spenders.
📄 profile_memory.py: find the memory-hungry line of code
import tracemalloc
tracemalloc.start()
# Do some work that allocates a lot of objects
data = [{"name": f"user_{i}", "score": i * 1.5} for i in range(100_000)]
snapshot = tracemalloc.take_snapshot()
top = snapshot.statistics("lineno")
print("Top memory allocations:")
for stat in top[:3]:
print(f" {stat}")
current, peak = tracemalloc.get_traced_memory()
print(f"\nCurrent: {current / 1024:.1f} KB")
print(f"Peak: {peak / 1024:.1f} KB")
tracemalloc.stop()
▶ Output (file path and exact byte counts vary by machine)
Top memory allocations: profile_memory.py:6: size=25.4 MiB, count=399951, average=67 B Current: 26061.2 KB Peak: 26061.5 KB
What happened here: tracemalloc pinned the cost straight to line 6, the list comprehension that built 100,000 dictionaries. It reports about 25 MiB across roughly 400,000 small allocations (each dict plus its keys and values), at an average of 67 bytes apiece. Only one line shows up under “Top” here because that single comprehension is where essentially all the memory went; in a larger program you would see several lines ranked from biggest to smallest. The current and peak figures are the live total and the high-water mark. The exact file path and byte counts depend on your machine, but the ranked-by-line view is what makes a real leak easy to find.
Common Mistakes
Mistake 1: Believing the GIL is what makes Python slow
🚫 The wrong mental model
# Myth: "Python is slow because of the GIL." # The GIL does not slow down single-threaded code at all. # What actually makes Python slower than C is being # interpreted and dynamically typed, not the GIL. # The GIL only stops THREADS from running Python bytecode in parallel.
✅ The right way to think about it
# Pick the tool that matches the work: # CPU-bound -> multiprocessing (separate processes, no shared GIL) # I/O-bound -> threading or asyncio (GIL is released while waiting) # Heavy math -> NumPy and friends (they release the GIL during array ops)
Why: The GIL is a concurrency limit, not a speed limit. A single thread never waits on it. Once you know your bottleneck is CPU or I/O, the fix is obvious: separate processes for CPU work, threads or asyncio for waiting work.
Mistake 2: Calling gc.collect() to “fix” memory
🚫 Wrong
for item in big_batch:
process(item)
gc.collect() # forcing a full sweep every loop, painfully slow
✅ Correct
for item in big_batch:
process(item)
# Let Python manage it. Reference counting frees most objects
# instantly, and the collector runs on its own when needed.
Why: Reference counting already frees the vast majority of objects the instant they go out of scope. Calling gc.collect() in a hot loop forces an expensive scan over and over for almost no benefit. Reach for it only in rare cases, such as right after deleting a large structure full of cycles, when you want the memory back at a precise moment.
Practice Exercises
- Refcount detective: Create a string, bind three more names to it, and print
sys.getrefcount()after each binding. Thendelthem one at a time and confirm the count falls back. Predict each number before you run it. - Break the cycle: Build two objects that reference each other (like the
Nodeexample), give each a__del__that prints, delete both names, and verify nothing is freed until you callgc.collect(). Then rewrite it using aweakreffor the back-pointer so the cycle never forms and the objects free immediately. - Find the hog: Use
tracemallocto compare the memory cost of building one million numbers as alistversus a generator expression. Take a snapshot of each and explain why one peaks far higher than the other.
Conclusion
You now know how Python memory management treats every object you create. Reference counting frees it the instant the last name lets go, the generational garbage collector sweeps up the circular leftovers, and the GIL keeps those counts safe across threads. You also picked up the two measuring tools that matter: sys.getsizeof() for a single object and tracemalloc for finding the exact line that eats your memory. That is enough to debug most real leaks without guessing.
Next we move from how Python manages memory to how it stores data on disk, in Python SQLite: Database and CRUD Operations. And if you want to jump around or revisit earlier topics, browse the full Python + AI/ML tutorial series home.
Frequently Asked Questions
How does Python memory management work?
Python memory management uses two mechanisms working together. Reference counting is the primary one: every object tracks how many names point to it, and the object is freed instantly when that count reaches zero. Generational garbage collection is the backup: it detects and breaks circular references that reference counting cannot clean up. Together they handle all memory automatically, so you never call malloc or free.
What is the GIL in Python?
The Global Interpreter Lock is a single lock that lets only one thread run Python bytecode at a time. It exists to protect reference counts from race conditions between threads. The GIL is released during input and output operations, so threading still helps for I/O-bound work. For CPU-bound work, use multiprocessing instead.
What is a circular reference in Python?
A circular reference happens when two objects point at each other (A points to B, B points to A). Their reference counts never reach zero even when no outside name refers to them, so plain reference counting cannot free them. Python’s cyclic garbage collector detects these loops and frees the objects.
How do I profile memory usage in Python?
Use tracemalloc from the standard library to trace allocations and rank them by the line of code that made them. Use sys.getsizeof() to measure a single object, remembering it counts only the container, not what it points to. For line-by-line analysis of a running function, the third-party memory_profiler package is also popular.
Has Python removed the GIL?
Python 3.14.6 officially supports a free-threaded build with no GIL, building on PEP 703 and made a supported (still optional) build by PEP 779. It runs as a separate build from the standard one, and parts of the C extension ecosystem are still catching up. The default build still uses the GIL, which you can confirm with sys._is_gil_enabled().
Interview Questions on Python Memory Management
The same ideas as they show up in real interviews, framed as scenarios you can practice out loud.
Q: What exactly does del x do? Does it free the object?
del x removes the name x from the current namespace and decrements the object’s reference count by one. The object itself is freed only if that count reaches zero. If other names, containers, or function arguments still refer to it, the object stays alive. In short, del is about names, not objects.
Q: Your long-running Application Programming Interface (API) worker’s memory keeps climbing even though each request finishes cleanly. What do you check first?
Start with tracemalloc: take a snapshot early, another after the growth, and use snapshot.compare_to() to see which lines keep allocating. The usual culprits are module-level caches or lists that only ever grow, references held by closures or mutable default arguments, and stored exceptions or tracebacks that pin entire call frames alive. It is almost always a lingering strong reference, not a garbage collector failure. If the offender is a cache, switching it to weakref.WeakValueDictionary often fixes it.
Q: You split a CPU-heavy image processing job across four threads and it runs slightly slower than the single-threaded version. Why, and what is the fix?
The GIL lets only one thread execute Python bytecode at a time, so the four threads take turns instead of running in parallel, and the constant lock handoffs add overhead on top. The fix is multiprocessing, which gives each worker its own interpreter and its own GIL, or a library like NumPy that releases the GIL inside its C-level array operations. On Python 3.14.6 you can also evaluate the free-threaded build, which removes the GIL entirely.
Q: sys.getsizeof() reports your data structure is only a few hundred bytes, yet the process is using two gigabytes. How is that possible?
sys.getsizeof() is shallow: it measures only the container object itself, essentially the header plus the pointer slots, not the objects those pointers lead to. A list holding a million large strings reports only the list’s own footprint. To get the real total, walk the structure recursively and add up every element, or use tracemalloc, which records actual allocations instead of one object’s header.
Q: If Python already has a garbage collector, why does it still need the GIL?
They solve different problems. The garbage collector only handles reference cycles; everyday freeing is done by reference counting, and bumping a count up or down is not an atomic operation. Without a lock, two threads updating the same count at the same instant could lose an update, which would leak the object or free it while still in use. The free-threaded build removes the GIL by making reference counting itself thread-safe, which is why it required a redesign of the interpreter rather than simply deleting the lock.
Q: When is it actually reasonable to call gc.collect() by hand?
Rarely, and never inside a hot loop. Legitimate cases: right after tearing down a huge structure full of cycles when you need the memory back at a precise moment, just before taking a memory measurement so pending garbage does not distort the numbers, or in long-idle services where you want to pay the collection cost during a quiet window. In normal application code, the automatic thresholds do the right thing on their own.
Go deeper: when you outgrow this post, the official Python documentation is the next stop.
Related Posts
Previous: Python: Walrus Operator & Modern Features (3.8-3.14)
Next: SQL Basics for Python Developers: SELECT, WHERE, and JOINs
Series Home: Python + AI/ML Tutorial Series

No comment