Python Closures — Late Binding Loop Bug in Rate Limiters
All endpoints returned 429 after one hit its limit—closures captured loop variables by reference.
- A closure is a nested function that remembers variables from its enclosing scope even after that scope exits
- Three conditions: nested function, references outer variable, outer function returns inner function
- Python stores captured variables in cell objects accessible via
__closure__ - Performance: closure creation is cheap (one cell per captured variable); no class overhead
- Production failure: forgetting
nonlocalwhen mutating causesUnboundLocalError— silent misdirection - Biggest mistake: assuming closures capture values by value in loops — they capture by reference, leading to the loop closure bug
Imagine you work at a coffee shop and your manager gives you a secret discount code before you go out to serve customers. Even after you've left the back office, you still remember that code and can use it whenever you need it. A Python closure is exactly that — a function that 'carries' a piece of its birthplace with it, even after that birthplace no longer exists. The inner function remembers the variables from the outer function that created it, like a note tucked into its pocket.
Most Python developers hit a wall around the same time: they understand functions, they understand variables, and then they write slightly more complex code and something weird happens — a function 'remembers' a value it seemingly shouldn't have access to anymore. That moment of confusion is actually you bumping into one of Python's most powerful and elegant features: closures. They're not an advanced academic concept — they're built into how Python evaluates and stores functions, and once you get them, a whole class of real-world problems becomes easy to solve.
The problem closures solve is surprisingly common: how do you give a function some persistent, private state without creating a whole class? Before closures, the only clean answer was to write a class with an __init__ method and self.some_value sprinkled everywhere — a lot of ceremony for something conceptually simple. Closures let you attach state to a function directly, keeping that state private and scoped exactly to where it's needed. They're also the engine under the hood of Python decorators, which means understanding closures is the key to understanding one of Python's most widely-used patterns.
By the end of this article you'll know exactly what makes a closure a closure (it's a specific three-part contract Python enforces), you'll be able to write closures that solve real problems like function factories and stateful callbacks, and you'll know the two gotchas that trip up almost every developer the first time they use closures in a loop. Let's build it up from scratch.
What Python Actually Needs to Form a Closure
A closure isn't magic — it's the result of three specific conditions being true at the same time. First, there must be a nested function (a function defined inside another function). Second, the inner function must reference at least one variable from the outer function's scope — not a global variable, not one of its own local variables, but one that belongs to the enclosing function. Third, the outer function must return the inner function. When all three are true, Python doesn't just return a plain function object. It returns a function object bundled together with a snapshot of the variables it referenced from outside. That bundle is the closure.
You can actually inspect this bundle yourself. Every closure in Python has a __closure__ attribute, which is a tuple of 'cell' objects. Each cell holds one of those remembered variables. If a function has no closure, __closure__ is None. This isn't just trivia — it's confirmation that Python is doing real work to preserve that state for you.
The variable that gets captured is called a 'free variable'. It's free because it isn't local to the inner function and isn't global — it floats between those two worlds, owned by the enclosing scope. Understanding this scoping layer (called the Enclosing scope in Python's LEGB rule) is the key to predicting how closures behave.
__closure__ and co_freevars — they are always available at runtime.__closure__ is not None to confirm.__closure__ is your proof — use it to verify closure behaviour.LEGB Scope Resolution — Step-by-Step Visual
Python resolves variable names using the LEGB rule: Local, Enclosing, Global, Built-in. Closures rely on the Enclosing (E) scope. When Python executes a nested function, it first looks for a variable in the function's local scope (L). If not found, it inspects the enclosing function's local scope (E) — that's where free variables live. Next comes the global module scope (G), then the built-in namespace (B) for functions like len and print. This chain is evaluated at runtime, which is why closures can see updated values of variables until they are called.
Understanding LEGB is crucial for debugging closures. If a variable unexpectedly resolves to a global instead of an enclosing scope, Python skipped the E because either the variable wasn't captured (not referenced in the inner function) or the inner function wasn't truly a closure (not returned). The diagram below walks through a typical scope chain for a closure.
__code__.co_freevars to confirm which variables are captured. If a variable is not in co_freevars, it's not part of the closure.co_freevars to verify which variables are captured.The 'nonlocal' Keyword — Mutating Enclosed Variables
By default, Python treats any assignment inside a nested function as creating a new local variable. To modify a variable from an enclosing (non-global) scope, you must declare it nonlocal. This keyword tells Python: 'bind this name to the variable in the nearest enclosing scope that is not global.' Without nonlocal, writing count += 1 inside a nested function raises UnboundLocalError because Python sees the assignment and considers count local, but then can't find an initial value before the increment.
nonlocal is commonly used in closures that maintain mutable state: counters, accumulators, caches, and retry trackers. It is different from global: nonlocal climbs up only through function scopes, while global bypasses all function scopes and goes straight to the module level.
A key nuance: nonlocal is only valid at the beginning of a function (before any code that uses the variable), and the variable must already exist in an enclosing function scope. If the variable is not defined in any enclosing function, Python raises a SyntaxError at compile time. This is a safety net — it prevents accidental creation of a new global or outer scope variable.
Below is an example that demonstrates correct nonlocal usage for a closure that accumulates a sum.
nonlocal and try to assign to a variable, Python creates a new local variable instead of modifying the outer one. This can lead to hard-to-find bugs where the outer variable never changes. Always add nonlocal as the first line of the inner function when you intend to mutate an enclosing variable.nonlocal sparingly — it makes state harder to track. For production code with multiple mutations, prefer a class with explicit methods. nonlocal shines when you need a single, lightweight stateful callable and a class would be overkill.nonlocal is required to mutate variables from the enclosing scope. It prevents UnboundLocalError and ensures the outer variable is changed.Closures With Mutable State — Building a Counter Without a Class
So far the closure variables have been read-only inside the inner function. But what if you need the inner function to update that variable — to keep a running count, for example? This is where Python's nonlocal keyword comes in. Without it, any assignment inside the inner function creates a brand-new local variable and shadows the outer one rather than updating it. Python treats assignment as declaration by default.
nonlocal is your explicit instruction to Python: 'when I assign to this name, go up to the enclosing scope and update the variable there, don't create a new local one.' It's the closure equivalent of the global keyword, but scoped to the enclosing function rather than the module level. You should reach for nonlocal only when you genuinely need mutable state in a closure — if you're using it everywhere, a class is probably the cleaner choice.
The stateful counter pattern is a textbook example, but the real-world version is more interesting: rate limiters, request counters, retry trackers, and progress accumulators all use this exact shape. The closure gives each counter its own private state, completely isolated from any other counter you create from the same factory.
nonlocal and write call_count += 1 directly, Python raises UnboundLocalError: local variable 'call_count' referenced before assignment. Python sees the assignment and marks call_count as local to the inner function — then can't find a value for it before the +=. The fix is always just one word: nonlocal.nonlocal for complex state machines; it becomes hard to debug.nonlocal is mandatory for assignments to outer variables.nonlocal.The Classic Loop-Closure Gotcha (And Two Ways to Fix It)
Here's the trap that catches almost every developer writing closures inside a loop for the first time. You want to create a list of functions, each capturing a different loop variable. You write what looks like perfectly reasonable code, run it, and every single function in the list returns the same value — the last value the loop variable had. This isn't a bug in Python. It's the correct behaviour once you understand what closures actually capture.
A closure captures a variable by reference, not by value. That means all the inner functions point to the same index variable in the enclosing scope. By the time you call any of those functions, the loop has finished and index has settled at its final value. Every function looks up index right then, at call time, and sees the same thing.
There are two clean fixes. The first is to use a default argument in the inner function (def greet(name=name):), which forces the current value to be captured by value at definition time. The second is to wrap the inner function in another function call that immediately captures the current value in its own closure scope. Both work — the default argument approach is slightly more Pythonic for simple cases.
Closures and Decorators: The Hidden Relationship
Python decorators are the most widespread real-world use of closures. Every time you write @cache or @staticmethod, you're relying on the fact that a function can wrap another function and carry state. The decorator pattern is a closure: the outer function (the decorator) accepts a function, and the inner function (the wrapper) captures that function and adds behaviour before/after calling it.
Understanding this relationship demystifies decorators. When you see @decorator, Python calls decorator(func) and stores the returned closure in place of func. That returned closure references the original function and any configuration passed to the decorator factory. This means you can create parameterised decorators (like @retry(max_attempts=3)) by nesting three levels: outer factory returns a decorator, which returns a wrapper closure.
Be careful with decorator stacking: each decorator adds one layer of closure indirection. Too many layers can hurt readability and performance. Also, functools.wraps is essential to preserve the original function's metadata — without it, the wrapped closure will have the wrong __name__ and __doc__, breaking introspection tools and documentation generators.
- The outer function receives the decorated function as an argument.
- The inner wrapper function captures that function as a free variable.
functools.wrapsis critical to preserve the original function's identity.- Parameterised decorators require an additional outer factory level.
- Each decorator adds one closure cell — stay mindful of nesting depth.
functools.wraps, your decorated function loses its original name and docstring — breaking automated docs, debugging, and serialisation.@functools.wraps in decorator wrappers; it's a one-line insurance policy.functools.wraps preserves the original function's metadata.Closure vs Class: Memory and Syntax Comparison
When building stateful callables, developers often wonder whether to use a closure or a class. Both can hold state and expose behaviour, but they differ in syntax overhead, memory usage, and capabilities. This section breaks down the practical trade‑offs so you can make an informed decision.
Memory – A closure stores captured variables in cell objects (one cell per free variable). Each cell is approximately 56 bytes on 64‑bit Python, plus the captured object itself. A closure function object adds around 56 bytes of overhead. In contrast, a class instance has an overhead of about 64 bytes plus the instance dictionary (which can be substantial if many attributes are added dynamically). For a single stateful callable with one or two variables, closures are more memory‑efficient.
Syntax – Closures are written as nested functions; the state is implicitly captured. Classes require a class definition, an __init__ method, and explicit self references. For simple cases, closures are far less verbose. However, if you need multiple methods that share state, a closure becomes awkward (you must return a tuple of functions), while a class naturally supports any number of methods.
Introspection – Class attributes are directly accessible via instance.attribute. Closure state is hidden behind __closure__ cells, which makes debugging harder. If you need to inspect or log internal state regularly, a class is clearer.
Inheritance – Closures cannot be subclassed. Classes fully support inheritance, which is essential for polymorphic dispatch.
Pickling – Closures can be pickled only if the outer function is importable and all captured variables are picklable. Class instances are generally easier to pickle.
Below is a comparison table summarising the key differences:
Rate Limiter with Closures: When Late Binding Breaks Production
- Always force value capture when creating closures in a loop: use a default argument or a factory helper.
- Test closure behaviour with a small loop before rolling out to production.
- Add a quick assertion to verify that each returned function has different
__closure__cell contents.
func.__closure__[0].cell_contents on each generated function to confirm different values.UnboundLocalError: local variable 'x' referenced before assignment inside a nested functionnonlocal x at the top of the inner function. Python treats assignments as local by default; nonlocal explicitly lifts the assignment to the enclosing scope.func.__closure__ for large objects. If a closure captures a large data structure, it stays alive as long as the function exists. Consider using weak references or making a copy to avoid accidental retention.args, *kwargs and passes them through. The closure captures the original function, but the wrapper signature must match the expected call pattern.def f(val=loop_var): or wrap in a helper function.Key takeaways
__closure__, not just in a stack frame that disappears.nonlocal explicitly whenever an inner function needs to assign to (not just read) a variable from the enclosing scopeUnboundLocalError.functools.wraps is essential to preserve function metadata.__closure__ to debug closure issues in productionCommon mistakes to avoid
5 patternsForgetting `nonlocal` when mutating an enclosed variable
UnboundLocalError: local variable 'count' referenced before assignment even though 'count' clearly exists in the outer functionnonlocal count as the first line of the inner function. Python needs that explicit declaration to know you mean the outer variable, not a new local one.Assuming loop closures capture the value at the time of creation
def func(val=loop_var):) or a factory function to force value capture at definition time instead of reference capture.Overusing closures when a class is the right tool
Neglecting `functools.wraps` in decorator closures
@functools.wraps(func) to the wrapper function inside the decorator closure. It copies __name__, __doc__, __module__, and __dict__ from the original function.Capturing large objects in closures unnecessarily
weakref.ref) if the object can be regenerated. Avoid lambda: big_list[i] — compute the value inside the lambda.Interview Questions on This Topic
Can you explain what a closure is in Python and describe the three conditions required to form one? Then walk me through what `__closure__` and `__code__.co_freevars` actually contain.
__closure__ is a tuple of cell objects, each cell holding the runtime value of one captured free variable. __code__.co_freevars is a tuple of strings with the names of those free variables, determined at compile time.Frequently Asked Questions
That's Functions. Mark it forged?
8 min read · try the examples if you haven't