Python Memory Management and Garbage Collection

How CPython decides when memory is freed: names and references, reference counting, the garbage collector that cleans up reference cycles, weak references, measuring memory with tracemalloc, trimming objects with __slots__, and the everyday patterns that make programs grow without limit.

GloryTecks Team

Training & Placement Faculty

6 May 2026 6 min read

Last updated: 24 September 2026

Share:

Python frees memory for you, so most of the time you never think about it. Then a long-running service creeps from 200 MB to 4 GB over a week, or a data job dies with a MemoryError halfway through. Understanding the few rules CPython follows makes those problems explicable, and usually easy to fix.

Names point at objects

A Python variable is a name bound to an object, not a box containing a value. Assigning one name to another adds a second reference to the same object; del removes a name, not the object. The object is freed when nothing refers to it any more.

Reference counting does most of the work

Every object carries a count of the references to it. When the count drops to zero, CPython frees the object immediately (Python docs: gc):

python
import gc
import sys
import weakref

data = []
alias = data
print(sys.getrefcount(data))
del alias
print(sys.getrefcount(data))
text
3
2

The counts are one higher than you might expect because passing data to getrefcount creates a temporary reference (Python docs: sys.getrefcount). The important part is the drop when alias is deleted. Reference counting is why a file object closes and a large list's memory is released the moment the last name referring to it goes away, not at some later time.

Cycles need the garbage collector

Reference counting has one blind spot: objects that refer to each other. Neither count reaches zero, even when nothing else can reach them. CPython's cycle collector periodically finds such groups and frees them. A weak reference, which watches an object without keeping it alive (Python docs: weakref), shows the difference:

python
class Node:
    def __init__(self, name):
        self.name = name
        self.partner = None

gc.collect()                       # start from a clean slate
a, b = Node("a"), Node("b")
a.partner, b.partner = b, a        # a cycle
watcher = weakref.ref(a)

del a, b
print("freed by reference counting:", watcher() is None)
gc.collect()
print("freed by the cycle collector:", watcher() is None)
text
freed by reference counting: False
freed by the cycle collector: True

After del, the two nodes are unreachable but still alive, each held by the other. The collector's pass freed them. The collector runs automatically as objects are allocated, so you rarely call gc.collect() yourself; it matters here only to make the moment visible. Cycles are normal (parent and child objects, doubly linked lists, objects registered as each other's callbacks), but objects in cycles live longer than necessary, until the next collection.

When one object needs to refer to another without owning it, such as a child pointing back to its parent or a cache mapping IDs to objects that other code owns, a weak reference avoids keeping the target alive. weakref.WeakValueDictionary is a cache whose entries disappear automatically when the objects are no longer used anywhere else, which prevents the most common kind of cache leak.

Measure before you optimise

sys.getsizeof reports the size of one object only, not the objects it contains, so a list's size says nothing about the strings in it. To see where memory actually goes, use tracemalloc, which records allocations made by Python code and can report the current and peak totals, or the lines of code responsible for the most memory. Take a snapshot before and after the suspicious operation, compare them, and the top entries point to the culprit.

Shrinking many small objects with __slots__

Each ordinary instance stores its attributes in a per-instance dictionary. For classes with millions of instances, declaring __slots__ stores attributes in fixed slots instead and removes that dictionary (Python reference: slots):

python
import tracemalloc

class Point:
    def __init__(self, x, y):
        self.x, self.y = x, y

class SlotPoint:
    __slots__ = ("x", "y")
    def __init__(self, x, y):
        self.x, self.y = x, y

def megabytes_for(cls, n=100_000):
    tracemalloc.start()
    points = [cls(i, i) for i in range(n)]
    current, _ = tracemalloc.get_traced_memory()
    tracemalloc.stop()
    return current / 1e6

print(f"regular: {megabytes_for(Point):.1f} MB, with __slots__: {megabytes_for(SlotPoint):.1f} MB")
text
regular: 12.8 MB, with __slots__: 8.8 MB

For 100,000 points the saving is about a third. The trade-off is flexibility: a slotted instance cannot gain attributes that are not listed. Use it for small, numerous record classes, and prefer NumPy arrays or pandas columns when the data is really a table of numbers.

Why the process does not shrink

CPython serves small objects from its own allocator, pymalloc, which carves memory into pools inside larger arenas (Python docs: memory management). Freed objects return to those pools for reuse, and an arena is handed back to the operating system only when everything in it has been freed. So after building and discarding a huge temporary structure, the process's memory often stays high even though Python can reuse the space. That is not a leak unless memory keeps growing across repeated identical work.

What actually causes memory growth

  • Loading everything at once. Reading a whole file or query result into a list when it could be processed row by row; Python generators and iterators shows the lazy alternative.
  • Unbounded caches. A dictionary used as a cache, or functools.lru_cache(maxsize=None), that keeps every key ever seen. Give caches a size limit or an expiry.
  • Growing module-level collections, such as a global list that every request appends to "for debugging".
  • References kept by accident: closures and callbacks that capture large objects, or exception tracebacks stored in variables, which keep every frame's local variables alive.
  • Too many small objects. Millions of dictionaries or instances where a DataFrame, an array or slotted classes would do; see pandas performance for the categorical trick.

A useful test for a suspected leak: run the same operation a thousand times and watch whether memory levels off, which is normal, or keeps climbing, which is a leak.

Should I call gc.collect() to free memory?

Rarely. Reference counting frees most objects immediately, and the collector runs automatically. An explicit call can help after tearing down a large structure full of cycles, but it will not fix a leak caused by references you still hold.

Does del free memory?

It removes a name. The object is freed only when no other names, containers or closures refer to it.

How do I find what is growing in a running service?

Start tracemalloc early, take a snapshot with tracemalloc.take_snapshot() after the service has warmed up, take another after it has handled more work, and call compare_to on the second with the first. The lines of code whose allocations grew the most are listed first.

Is Python's memory use high compared with other languages?

Per object, yes: every integer and string is a full object with a header. That is why bulk numeric data belongs in NumPy arrays or pandas columns, which store raw values compactly.

Lazy iteration is the most effective memory tool in everyday Python; iterators vs generators vs comprehensions measures the difference. Iterators, generators and the other advanced concepts that shape how programs use memory are covered in the Python Programming course.

Sources

GloryTecks Team

Training & Placement Faculty

The GloryTecks faculty team mentors learners across Data Science, AI and Data Engineering, with a focus on real-time projects and placement readiness in Hyderabad.

Want to master Python?

Join GloryTecks’ expert-led Python Programming training in Hyderabad with real-time projects and 100% placement support.

Explore course