Why can a class-decorator registry leak memory in a long-running service, and how do you bound it?
answer
- Registration happens where the class is made
- Import-time is bounded, runtime is not
- A module global holds it forever
- A class drags its whole module with it
- Weak values let dead entries disappear
basics
~20 sThe registry dict holds a strong reference to every class it records. That is harmless when classes are defined once at import, but a service that builds classes at runtime pins each one, plus everything reachable from it, forever. Bound it by not generating classes per request, or by using a weak-valued mapping.
solid answer
~50 sA registry class decorator is a function that stores `cls` in a module-level dict and returns it. For module-level classes this is bounded: the decorator runs once per class while the module is imported. The leak appears when classes are created dynamically -- a factory that builds a handler class per language pair or per batch, called on a request path -- because every generated class object is now strongly referenced by the registry and can never be collected, and a class keeps alive its `__dict__`, its methods, their defaults and closures, and its module globals. At a 1,200-request-per-minute peak this shows as steadily climbing RSS with a registry whose length grows monotonically. The fixes, in order of preference: build one parameterised class and vary instances instead; failing that, key the registry with `weakref.WeakValueDictionary` so entries disappear with the class; or deregister explicitly.
code
python · 24 linesimport gc
import weakref
REGISTRY = weakref.WeakValueDictionary()
def register(cls):
REGISTRY[cls.language_pair] = cls
return cls
def make_updater(pair):
@register
class Updater:
language_pair = pair
return Updater
u = make_updater("en-fr")
print(len(REGISTRY)) # 1
del u
gc.collect()
print(len(REGISTRY)) # 0go deeper
Recall that a class decorator runs once per class statement and that whatever it stores in a module-level dict stays reachable for as long as the process runs. That is enough to see why a registry is normally harmless.
Explain the mechanics: the registry holds a strong reference, a class transitively keeps its methods and module globals alive, and classes are cyclic so refcounting alone never frees them.
Demonstrate the diagnosis and the fix under load: watch the registry's length against traffic, diff allocation snapshots, then decide between eliminating runtime class creation and switching to a weak-valued mapping.
Own the framing that a registry decorator is a lifetime decision disguised as a one-line annotation, and set the norm that dynamic class creation happens at configuration time rather than on a request path.
### The pattern The registry is the most common real use of a class decorator, and one of the strongest arguments for the shape: ```python HANDLERS = {} def register(cls): HANDLERS[cls.language_pair] = cls return cls ``` Each decorated class adds itself to a lookup table as its class statement executes, so a dispatcher can find implementations by key without importing each one by name. Because decoration happens once, while the defining module is imported, the registry is populated as a side effect of import and is complete before any request is served. ### Why it is normally safe For classes written at module level, the number of registrations equals the number of `class` statements in the source. That is a fixed, small number. The registry holds strong references forever, but "forever" here means "for the life of the process", and those classes were going to live that long anyway -- their module holds a reference too. ### Where the leak comes from The failure mode appears when classes stop being source-level constants. Consider a translation-memory updater that builds a small handler class per language pair, per tenant, or per document batch, inside a factory function called on the request path. Each call now creates a *new class object*, and the registry pins it. At a 1,200-request-per-minute peak that is up to 72,000 new class objects per hour that can never be freed. A class is an unusually expensive thing to leak. Holding a class alive also holds its `__dict__`, every function object it contains, each function's default arguments and closure cells, its annotations, its docstrings, and -- through the functions' `__globals__` -- the entire module namespace it was defined in. A leaked class is rarely a few hundred bytes; it is a subgraph. Classes are also cyclic by construction: the class references its methods, the methods reference the class through their globals, and the type's own attributes point back at it. So a class is never freed by refcounting alone; it requires the cycle collector. That is why the leak looks *gradual and jagged* rather than instant -- collections keep running and keep failing to free anything, because the registry is a live root. ### Confirming the diagnosis The registry itself is the cheapest instrument: log `len(HANDLERS)` periodically. If it climbs with traffic rather than settling after startup, the case is closed without any profiling. Beyond that, `tracemalloc` snapshots taken minutes apart and diffed will point at the factory's line, and counting class objects in `gc.get_objects()` distinguishes "leaking classes" from "leaking instances", which is a completely different bug with a completely different fix. ### The fixes, in order **1. Do not generate classes per request.** This is almost always the real answer. A class generated per language pair is a class whose only difference from its siblings is data; make it one class with the pair as an instance attribute, or as a key into a table. Dynamic class creation belongs at configuration-load time, not on a request path. **2. Make the registry hold weak references.** When classes genuinely must be created at runtime, `weakref.WeakValueDictionary` lets the entry vanish once nothing else refers to the class: ```python import gc import weakref REGISTRY = weakref.WeakValueDictionary() def register(cls): REGISTRY[cls.language_pair] = cls return cls ``` Because classes participate in reference cycles, an entry does not disappear the instant the last strong reference goes away -- it disappears at the next cycle-collection pass. That is fine for bounding memory and confusing during a five-line experiment, which is why an explicit `gc.collect()` belongs in the test that proves it works. **3. Deregister explicitly.** If the lifetime is genuinely owned by something -- a tenant teardown, a config reload -- delete the key when that owner goes away. This is the most predictable option and the easiest to get wrong, because every early-return and exception path has to honour it. ### The design lesson A registry decorator quietly converts *"this class exists"* into *"this class is reachable from a module global for the life of the process"*. That promotion is invisible at the call site; nothing in `@register` above a class statement suggests a lifetime decision. When the decorated thing is a source-level class the promotion is free, and when it is a runtime-generated class it is a leak. Deciding which case you are in is the whole question.
- Why does the weak-valued registry entry survive until a collection runs?A class object is part of a reference cycle by construction -- its methods reach it again through their `__globals__`, and its own attributes point back at it -- so its reference count never drops to zero when the last external name is deleted. It is freed by the cycle collector instead, and the weak reference is cleared at that moment. Calling `gc.collect()` makes the effect immediate in a test.
- How would you distinguish leaking classes from leaking instances?Count by type. Scanning `gc.get_objects()` and tallying how many entries are themselves classes, versus instances of one class, separates the two immediately. Leaking classes points at dynamic class creation or a registry; leaking instances points at a cache, a container that is never pruned, or a callback list. The remedies share nothing.
- Is there a case where pinning every registered class is the right behaviour?Yes -- the ordinary one. For classes written at module level, the defining module already keeps them alive for the process lifetime, so the registry's strong reference costs nothing and a weak mapping would only add surprise. Reach for weak values specifically when class creation is driven by runtime data.
saying these in an interview costs you the question
- Blames the garbage collector for not breaking cycles
- Thinks the decorator re-runs on each instantiation
- Assumes a leaked class costs only a few bytes
- Reaches for a weak registry before questioning runtime class creation
- Believes deleting the name frees the class immediately
- Cannot name any way to confirm the growth