Senior 8 min · March 05, 2026

Python Closures — Late Binding Loop Bug in Rate Limiters

All endpoints returned 429 after one hit its limit—closures captured loop variables by reference.

N
Naren · Founder
Plain-English first. Then code. Then the interview question.
About
 ● Production Incident 🔎 Debug Guide
Quick Answer
  • 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 nonlocal when mutating causes UnboundLocalError — silent misdirection
  • Biggest mistake: assuming closures capture values by value in loops — they capture by reference, leading to the loop closure bug
Plain-English First

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_anatomy.pyPYTHON
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
# Demonstrates the three conditions that form a closure
# and how to inspect the captured state Python stores.

def make_multiplier(factor):           # Outer function defines 'factor'
    """Returns a function that multiplies any number by 'factor'."""

    def multiply(number):              # Inner function — condition 1: nested function
        return number * factor         # References 'factor' from outer scope — condition 2

    return multiply                    # Returns the inner function — condition 3


# Creating two separate multiplier functions from the same template
double = make_multiplier(2)
triple = make_multiplier(3)

print(double(10))   # 'factor' remembered as 2
print(triple(10))   # 'factor' remembered as 3
print(double(7))    # Each closure has its OWN independent 'factor'

# Inspecting the closure internals Python stores under the hood
print(type(double))                         # It's a regular function object
print(double.__closure__)                   # Tuple of cell objects holding captured vars
print(double.__closure__[0].cell_contents)  # The actual value of 'factor' stored inside
print(double.__code__.co_freevars)          # Name of every free variable this closure captured
Output
20
30
14
<class 'function'>
(<cell at 0x...>: int object at 0x...>,)
2
('factor',)
LEGB Rule Quick Reminder:
Python looks up variable names in this exact order: Local → Enclosing → Global → Built-in. The 'Enclosing' layer is closures' home. If you ever wonder why Python finds a variable you didn't define locally, that's the E in LEGB doing its job.
Production Insight
If you ever wonder why a function still works after the outer function call ended, it's because the cell objects keep the free variables alive.
Debug by printing __closure__ and co_freevars — they are always available at runtime.
Never assume a closure is formed — check __closure__ is not None to confirm.
Key Takeaway
Three conditions make a closure: nested function, outer variable reference, returned.
Free variables live in cell objects, not in stack frames.
__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.

Production Insight
Scope bugs are often silent: a variable you expect the closure to see may actually come from a global instead of the enclosing scope. Always check with __code__.co_freevars to confirm which variables are captured. If a variable is not in co_freevars, it's not part of the closure.
Key Takeaway
LEGB order: Local → Enclosing → Global → Built-in. Closures use the Enclosing scope. Use 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_example.pyPYTHON
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
# Demonstrates 'nonlocal' for mutating an enclosed variable.

def make_accumulator(initial=0):
    total = initial

    def add(x):
        nonlocal total
        total += x
        return total

    return add

acc = make_accumulator(10)
print(acc(5))   # 15
print(acc(3))   # 18
print(acc(-2))  # 16

# Without nonlocal, this would raise UnboundLocalError

# Another pattern: closure with reset capability
def make_mutable_counter():
    count = 0
    def increment():
        nonlocal count
        count += 1
        return count
    def reset():
        nonlocal count
        count = 0
    return increment, reset

inc, rst = make_mutable_counter()
print(inc())  # 1
print(inc())  # 2
rst()
print(inc())  # 1
Output
15
18
16
1
2
1
Forgetting nonlocal: the silent bug
If you omit 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.
Production Insight
Use 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.
Key Takeaway
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.

api_request_counter.pyPYTHON
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
# Simulates an API endpoint call counter with a reset capability.
# Uses 'nonlocal' to mutate state stored in the closure.

def make_request_counter(endpoint_name):
    """Returns a counter function tied to a specific API endpoint."""
    call_count = 0   # This is the state we want to mutate from inside the inner function

    def record_call():
        nonlocal call_count              # Tell Python: update the OUTER 'call_count', don't create a new local one
        call_count += 1
        print(f"[{endpoint_name}] Call #{call_count}")

    def get_count():
        return call_count                # Read-only — no 'nonlocal' needed for reads

    def reset_count():
        nonlocal call_count
        call_count = 0
        print(f"[{endpoint_name}] Counter reset.")

    # Return multiple inner functions as a named tuple for clean access
    # All three share the SAME 'call_count' cell — they're all part of one closure
    return record_call, get_count, reset_count


# Create independent counters for two different endpoints
track_login,  get_login_count,  reset_login  = make_request_counter("/api/login")
track_search, get_search_count, reset_search = make_request_counter("/api/search")

track_login()
track_login()
track_search()
track_login()

print(f"Total login calls:  {get_login_count()}")
print(f"Total search calls: {get_search_count()}")

reset_login()
print(f"Login count after reset: {get_login_count()}")
Output
[/api/login] Call #1
[/api/login] Call #2
[/api/search] Call #1
[/api/login] Call #3
Total login calls: 3
Total search calls: 1
[/api/login] Counter reset.
Login count after reset: 0
Watch Out:
If you forget 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.
Production Insight
Mutable state in closures is fine until you need multiple methods sharing state — then a class is cleaner.
In production, avoid using nonlocal for complex state machines; it becomes hard to debug.
Rule: one closure, one mutation point. If you need more, write a class.
Key Takeaway
nonlocal is mandatory for assignments to outer variables.
Read-only accesses don't need nonlocal.
If your closure returns more than 2 inner functions, consider refactoring to a class.

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.

loop_closure_fix.pyPYTHON
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
# Demonstrates the classic loop-closure bug and TWO correct fixes.

user_names = ["alice", "bob", "carol"]

# ---- THE BUG ------------------------------------------------
buggy_greeters = []
for name in user_names:
    def greet_buggy():
        return f"Hello, {name}!"   # Captures 'name' BY REFERENCE — looks it up at call time
    buggy_greeters.append(greet_buggy)

# Loop is done, 'name' is now "carol" for everyone
print("Buggy output:")
for greeter in buggy_greeters:
    print(greeter())   # Every function sees the LAST value of 'name'

# ---- FIX 1: Default Argument (captures value at definition time) ----
fixed_greeters_v1 = []
for name in user_names:
    def greet_with_default(captured_name=name):  # Default arg evaluated NOW, not at call time
        return f"Hello, {captured_name}!"
    fixed_greeters_v1.append(greet_with_default)

print("\nFix 1 — Default argument:")
for greeter in fixed_greeters_v1:
    print(greeter())

# ---- FIX 2: Wrapping factory (creates a fresh enclosing scope each iteration) ----
def make_greeter(person_name):          # Each call to make_greeter creates a NEW scope
    def greet():                        # 'person_name' is bound fresh in each scope
        return f"Hello, {person_name}!"
    return greet

fixed_greeters_v2 = [make_greeter(name) for name in user_names]

print("\nFix 2 — Factory function:")
for greeter in fixed_greeters_v2:
    print(greeter())
Output
Buggy output:
Hello, carol!
Hello, carol!
Hello, carol!
Fix 1 — Default argument:
Hello, alice!
Hello, bob!
Hello, carol!
Fix 2 — Factory function:
Hello, alice!
Hello, bob!
Hello, carol!
Interview Gold:
This loop-closure bug is one of the most commonly asked Python interview questions. Interviewers don't just want you to say 'use a default argument' — they want you to explain WHY it works: default argument values are evaluated at function definition time, not call time. That explanation is what separates candidates who've memorised the fix from those who truly understand closures.
Production Insight
This bug is responsible for countless subtle production errors in event-driven systems, callback registration, and factory patterns.
Always test with a simple identity check before deploying any code that generates callables inside a loop.
Rule: when building a list of closures in a loop, always force value capture — your future self will thank you.
Key Takeaway
Closures capture by reference — all loop-created closures share the same final loop variable.
Default arguments evaluate at definition time — use them to force value capture.
Factory function pattern creates a fresh scope per iteration — works without default args.

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.

closure_decorator_example.pyPYTHON
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
# Demonstrates how decorators are closures wrapping the original function.
import functools
import time

def timer_decorator(func):
    """Outer function receives the decorated function. Returns a closure."""
    @functools.wraps(func)  # This copies __name__, __doc__, etc. from func to wrapper
    def wrapper(*args, **kwargs):
        start = time.perf_counter()
        result = func(*args, **kwargs)      # 'func' is a free variable captured by the closure
        end = time.perf_counter()
        print(f"{func.__name__} took {end - start:.4f}s")
        return result
    return wrapper

@timer_decorator
def slow_add(a, b):
    """Add two numbers after a nap."""
    time.sleep(0.1)
    return a + b

print(slow_add(3, 4))            # Output shows time, then 7
print(f"Name: {slow_add.__name__}")  # Without @wraps, this would be 'wrapper'
print(f"Doc:  {slow_add.__doc__}")   # Without @wraps, this would be None

# To verify this is a closure, inspect:
print("Closure?", slow_add.__closure__ is not None)
print("Free vars:", slow_add.__code__.co_freevars)
Output
slow_add took 0.1001s
7
Name: slow_add
Doc: Add two numbers after a nap.
Closure? True
Free vars: ('func',)
Decorator as Closure Factory
  • The outer function receives the decorated function as an argument.
  • The inner wrapper function captures that function as a free variable.
  • functools.wraps is 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.
Production Insight
Without functools.wraps, your decorated function loses its original name and docstring — breaking automated docs, debugging, and serialisation.
In production, avoid stacking more than 3 decorators on one function; each adds a closure layer that makes stack traces longer and debugging harder.
Rule: always apply @functools.wraps in decorator wrappers; it's a one-line insurance policy.
Key Takeaway
Decorators are closures that wrap callables.
functools.wraps preserves the original function's metadata.
Parameterised decorators need a two-level closure (factory → decorator → wrapper).

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.

closure_vs_class.pyPYTHON
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
# Example: same functionality — a counter with increment and reset.

# Closure approach
def make_counter_closure(initial=0):
    count = initial
    def increment():
        nonlocal count
        count += 1
        return count
    def reset():
        nonlocal count
        count = initial
    return increment, reset

inc1, reset1 = make_counter_closure(10)
print(inc1())  # 11

# Class approach
class CounterClass:
    def __init__(self, initial=0):
        self.count = initial
    def increment(self):
        self.count += 1
        return self.count
    def reset(self):
        self.count = self.initial  # wait, need to store initial

# But we need to store initial separately:
class CounterClassFixed:
    def __init__(self, initial=0):
        self.initial = initial
        self.count = initial
    def increment(self):
        self.count += 1
        return self.count
    def reset(self):
        self.count = self.initial

c = CounterClassFixed(10)
print(c.increment())  # 11
Output
11
11
When to Use Each
Use closures for lightweight, single-purpose stateful callables (e.g., a one‑button callback). Use classes when you need multiple methods, complex state, or inheritance. The break‑even point is roughly three methods: beyond that, a class is almost always cleaner.
Production Insight
In microservices and cloud functions, where memory is billed per execution, closures can save significant overhead compared to creating full class instances. However, don't sacrifice readability for tiny savings. Profile your hot paths before optimising.
Key Takeaway
Closures win on memory and syntax simplicity for simple stateful callables. Classes win on methods, introspection, and inheritance. Choose based on how many methods you need.
● Production incidentPOST-MORTEMseverity: high

Rate Limiter with Closures: When Late Binding Breaks Production

Symptom
All API endpoints (login, search, checkout) started returning 429 Too Many Requests after the first one hit its limit, even though each had a separate limit.
Assumption
The team assumed that each call to the factory would capture the current loop variable by value at creation time.
Root cause
The factory was called inside a loop that iterated over endpoint configs. The closure captured the loop variable by reference, so all instances shared the final value of that variable after the loop finished.
Fix
Replace the loop with a list comprehension that calls a helper function to create each closure, ensuring each gets its own scope with the current value captured by default argument.
Key lesson
  • 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.
Production debug guideSymptom-based guide to diagnosing closure issues in Python4 entries
Symptom · 01
Function 'remembers' the wrong value – every instance returns the same result
Fix
Check the loop structure. Verify that each closure captures a unique scope. Use func.__closure__[0].cell_contents on each generated function to confirm different values.
Symptom · 02
UnboundLocalError: local variable 'x' referenced before assignment inside a nested function
Fix
Add nonlocal x at the top of the inner function. Python treats assignments as local by default; nonlocal explicitly lifts the assignment to the enclosing scope.
Symptom · 03
Closure appears to leak memory – object references persist unexpectedly
Fix
Inspect 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.
Symptom · 04
Decorator returns a function that doesn't behave as expected (e.g., missing arguments)
Fix
Ensure the wrapper function accepts args, *kwargs and passes them through. The closure captures the original function, but the wrapper signature must match the expected call pattern.
★ Quick Closure Debug Cheat SheetUse these commands and checks when closures misbehave in development or production.
All functions from a loop return the same value
Immediate action
Check for a loop variable being captured by reference.
Commands
print([f.__closure__[0].cell_contents for f in list_of_funcs])
Check if all cell_contents are identical.
Fix now
Refactor: use a default argument def f(val=loop_var): or wrap in a helper function.
UnboundLocalError with a variable that clearly exists+
Immediate action
Identify the variable being assigned inside a nested function.
Commands
Check if the variable name appears in `f.__code__.co_freevars`
If not, you haven't declared `nonlocal`.
Fix now
Add nonlocal variable_name as the first line of the inner function.
Memory growing unexpectedly, suspect closure retention+
Immediate action
Identify which closure is holding the reference.
Commands
import gc; print([(obj, sys.getsizeof(obj)) for obj in gc.get_objects() if isinstance(obj, type(lambda: None))])
Inspect closures on hot functions: `func.__closure__`
Fix now
Use weakref for captured objects that are not needed after the creating scope exits, or clear the closure by deleting the function object.
Closure vs Class for Stateful Callables
Feature / AspectClosureClass with __init__
State storageFree variables in closure cellInstance attributes via self
Syntax overheadLow — just nested functionsHigh — class, __init__, self everywhere
Multiple methods sharing statePossible but awkward (return tuple of funcs)Natural — all methods share self
Introspection / debuggingRequires __closure__ inspectionStandard attribute access on instance
Best used whenOne primary callable with light stateMultiple methods or complex state
Supports inheritanceNoYes
Callable directlyYes — it's just a functionOnly if __call__ is defined
Memory overhead~56 bytes per closure + 8 bytes per captured variable~64 bytes per instance + instance dict overhead
Pickle supportOnly if the outer function is importable and captured variables are picklableStandard class instances are easy to pickle

Key takeaways

1
A closure is a function bundled with its free variables
Python stores those variables in cell objects accessible via __closure__, not just in a stack frame that disappears.
2
Closures capture variables by reference, not by value
this means loop closures all see the final loop value unless you force value capture with a default argument or a factory function.
3
Use nonlocal explicitly whenever an inner function needs to assign to (not just read) a variable from the enclosing scope
omitting it silently creates a new local variable and causes UnboundLocalError.
4
Closures are the right tool for lightweight stateful callables and function factories; when state grows complex or you need multiple methods, reach for a class instead.
5
Decorators are closures
functools.wraps is essential to preserve function metadata.
6
Inspect __closure__ to debug closure issues in production
it's your window into what Python actually captured.

Common mistakes to avoid

5 patterns
×

Forgetting `nonlocal` when mutating an enclosed variable

Symptom
UnboundLocalError: local variable 'count' referenced before assignment even though 'count' clearly exists in the outer function
Fix
Add nonlocal 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

Symptom
Every function in a list created inside a loop returns the same result (the last loop value) when called
Fix
Use a default argument (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

Symptom
You end up returning 4+ inner functions from one outer function and the code becomes harder to read than a simple class would be
Fix
If your closure needs more than 2-3 inner functions or the state grows complex, refactor to a class. Closures shine for lightweight, single-callable patterns — don't stretch them beyond that.
×

Neglecting `functools.wraps` in decorator closures

Symptom
Decorated functions lose their original name and docstring, breaking documentation generation and debugging
Fix
Always apply @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

Symptom
Memory grows unexpectedly because each closure holds a reference to a large data structure, preventing garbage collection
Fix
Capture only the specific values needed, or use a weak reference (weakref.ref) if the object can be regenerated. Avoid lambda: big_list[i] — compute the value inside the lambda.
INTERVIEW PREP · PRACTICE MODE

Interview Questions on This Topic

Q01SENIOR
Can you explain what a closure is in Python and describe the three condi...
Q02SENIOR
What will this code print and why? `funcs = []; [funcs.append(lambda: i)...
Q03SENIOR
What is the difference between using `global` and `nonlocal` in Python? ...
Q04SENIOR
How can a closure cause a memory leak in a Python application? Provide a...
Q01 of 04SENIOR

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.

ANSWER
A closure is a function object that retains access to variables from its enclosing lexical scope even after that scope has finished executing. Three conditions: (1) a nested function defined inside another function, (2) the inner function references at least one variable from the outer function's scope (a free variable), and (3) the outer function returns the inner function. __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.
FAQ · 5 QUESTIONS

Frequently Asked Questions

01
What is a closure in Python and how is it different from a regular nested function?
02
When should I use a closure instead of a class in Python?
03
Does Python's garbage collector clean up closure variables if the closure is no longer referenced?
04
How can I see which variables a closure captured at runtime?
05
Why do closures inside a loop all return the same value?
🔥

That's Functions. Mark it forged?

8 min read · try the examples if you haven't

Previous
Generators in Python
6 / 11 · Functions
Next
Recursion in Python