Right, let’s talk about the garbage you’re not creating on purpose. You’ve probably got the hang of reference counting by now. It’s simple, it’s fast, it’s… tragically naive. It falls flat on its face the moment objects decide to get chummy and form a circle of mutual admiration. One object holds a reference to another, which holds a reference back to the first. Poof. Your reference counts never hit zero, even though this little clique is utterly unreachable from the outside world. This is a reference cycle, and it’s a memory leak waiting to happen.

This is where the Cyclic Garbage Collector (GC) comes in, like a patient janitor with a master key. Its sole job is to find these isolated groups of objects that are keeping each other on life support and, well, pull the plug. It doesn’t replace reference counting; it complements it. The main event, reference counting, handles probably 95% of your garbage instantly. The GC runs periodically to clean up the messy 5% that reference counting can’t.

How the GC Knows What to Look At

The GC can’t just wander through all of your RAM—that would be horrifically slow. Instead, it’s a bit smarter. It has a special list, often called gc.garbage in Python (though you should rarely need to touch it directly). When an object’s reference count is decremented but doesn’t hit zero, it might be part of a cycle. So, it gets put on a “maybe trash” list for the GC to investigate later. This is a genius move. It means the GC only spends its time looking at objects that have recently become candidates for collection, not your entire object graph.

The Secret Sauce: Generational Garbage Collection

Now, the real-world implementation is even cleverer. Most modern GCs, like Python’s, are generational. The observation is simple: most objects die young. An object you just created for a string operation is probably going to be garbage in a microsecond. An object that has been around for a few minutes is likely to stick around for a while longer.

So, the GC segregates objects into “generations” (typically three). New objects go into Generation 0. When the GC runs, it first checks the youngest generation. If an object survives a GC collection, it gets promoted to the next, older generation. This means the GC can run very frequent, fast, small collections on Generation 0, which collects the vast majority of short-lived garbage. It only runs the more expensive full collection on the older generations much less frequently. It’s a huge performance win.

When the GC Actually Runs

You don’t directly control this janitor’s schedule. The GC runs automatically when the number of objects allocated since the last collection exceeds the number of objects that survived the last collection. You can also nudge it manually from the gc module, which is useful for debugging and very specific performance-sensitive code.

import gc

# Create a simple reference cycle, because we're terrible people
class Node:
    def __init__(self, value):
        self.value = value
        self.next = None

# Create two nodes that point to each other
node_a = Node('A')
node_b = Node('B')
node_a.next = node_b
node_b.next = node_a # The cycle!

# Now, let's orphan the entire cycle
node_a = None
node_b = None

# At this point, our two Node objects are floating in memory,
# completely unreachable but with a reference count of 1 each.
# Reference counting alone is useless here.

# We can force the GC to do its thing
collected = gc.collect() # Force a full collection
print(f"Collected {collected} objects.")
# On most systems, this will output: Collected 2 objects.

The One Big Thing You Can Break: __del__

Here’s the landmine. If you define a __del__ method (a finalizer) in your class, you can accidentally make a cycle uncollectable. The GC can’t be sure what order to delete the objects in because of the mutual dependencies. It just throws its hands up, puts your cycle on the gc.garbage list, and leaves it there forever. It’s the GC’s equivalent of “I’m not paid enough to deal with this.”

import gc

class BadIdea:
    def __init__(self, name):
        self.name = name
        self.other = None
    def __del__(self):
        print(f"Deleting {self.name}")

a = BadIdea('A')
b = BadIdea('B')
a.other = b
b.other = a # Create the cycle

# Orphan them
a = None
b = None

# Run the GC
print("Collecting...")
gc.collect()
print("Garbage:", gc.garbage) # This list will now contain our two BadIdea objects
print("Done.")

The output will likely show Garbage: [<__main__.BadIdea object at ...>, <__main__.BadIdea object at ...>]. They were never deleted. The lesson? Avoid __del__ like the plague. If you need cleanup logic, use context managers (with statements) instead.

Best Practices: Work With the GC, Not Against It

  1. Don’t fear cycles. The GC exists to handle them. Sometimes a cyclic data structure is the most elegant solution. Just use it.
  2. Never call gc.collect() in production code unless you have measurably proven it solves a specific performance problem. You’ll almost certainly just make things slower by interrupting your program more than necessary.
  3. Avoid __del__. I said it already, but it’s worth repeating. It’s a foot-gun.
  4. Forget gc.garbage exists. It’s almost purely a debugging tool. If objects are ending up there, you’ve designed yourself into a corner.

The cyclic GC is a brilliant piece of engineering that lets you write intuitive code without having to constantly worry about the memory implications of every relationship between objects. It’s your silent partner, cleaning up the messy but inevitable consequences of flexible data structures. Trust it.