Skip to content

Late Binding and Closure Capture

When a nested function references variables from its enclosing scope, Python creates a closure. Understanding how closures capture variables—and the "late binding" behavior—is essential for avoiding subtle bugs.


What is a Closure?

A closure is a function that remembers values from its enclosing scope, even after that scope has finished executing.

def make_multiplier(n):
    def multiplier(x):
        return x * n  # n is captured from enclosing scope
    return multiplier

double = make_multiplier(2)
triple = make_multiplier(3)

print(double(5))  # 10
print(triple(5))  # 15

The inner function multiplier "closes over" the variable n.


How Capture Works

Python captures variables by reference, not by value. The closure stores a reference to the variable, not a copy of its value.

def make_counter():
    count = 0

    def counter():
        nonlocal count
        count += 1
        return count

    return counter

c = make_counter()
print(c())  # 1
print(c())  # 2
print(c())  # 3

Each call modifies the same count variable.


Inspecting Closures

You can examine captured variables through __closure__:

def outer(x):
    def inner():
        return x
    return inner

f = outer(10)

print(f.__closure__)           # (<cell at 0x...>,)
print(f.__closure__[0].cell_contents)  # 10

Late Binding Explained

Python uses late binding for closures—the value is looked up when the function is called, not when it's defined.

def outer():
    x = 10
    def inner():
        return x  # x is looked up when inner() is called
    x = 20  # Change x after defining inner
    return inner

f = outer()
print(f())  # 20 (not 10!)

The closure sees the final value of x.


The Loop Variable Gotcha

The most common closure pitfall involves loops:

funcs = []
for i in range(3):
    funcs.append(lambda: i)

print([f() for f in funcs])  # [2, 2, 2] — NOT [0, 1, 2]!

All functions return 2! Why? All lambdas reference the same variable i, and when called, i is 2.

Visualizing the Problem

Loop iteration 0: lambda captures reference to i (i=0)
Loop iteration 1: lambda captures reference to i (i=1)
Loop iteration 2: lambda captures reference to i (i=2)

After loop: i = 2

Call all lambdas: all look up i → all get 2

With def Statement

def create_functions():
    functions = []
    for i in range(3):
        def f():
            return i
        functions.append(f)
    return functions

funcs = create_functions()
print(funcs[0]())  # 2 (expected 0)
print(funcs[1]())  # 2 (expected 1)
print(funcs[2]())  # 2 (expected 2)

List Comprehension Version

# Same problem
funcs = [lambda: i for i in range(3)]
print([f() for f in funcs])  # [2, 2, 2]

Solutions

Solution 1: Default Parameter (Most Common)

Capture the current value using a default parameter:

funcs = []
for i in range(3):
    funcs.append(lambda x=i: x)  # x=i evaluated NOW

print([f() for f in funcs])  # [0, 1, 2] ✓

Default parameters use early binding—the value is captured when the function is defined.

# List comprehension version
funcs = [lambda x=i: x for i in range(3)]
print([f() for f in funcs])  # [0, 1, 2] ✓

Solution 2: Factory Function

Create a separate scope for each iteration:

def make_func(val):
    return lambda: val  # val is local to each call

funcs = [make_func(i) for i in range(3)]
print([f() for f in funcs])  # [0, 1, 2] ✓

Each call to make_func creates a new scope with its own val.

Solution 3: functools.partial

Use partial to bind the current value:

from functools import partial

def return_val(x):
    return x

funcs = [partial(return_val, i) for i in range(3)]
print([f() for f in funcs])  # [0, 1, 2] ✓

Solution 4: Closure Factory (IIFE Pattern)

Immediately invoked function expression:

funcs = [(lambda x: lambda: x)(i) for i in range(3)]
print([f() for f in funcs])  # [0, 1, 2] ✓

Solution Comparison

Method Pros Cons
Default parameter x=i Simple, idiomatic Changes function signature
Factory function Clear intent More verbose
functools.partial No signature change Import required
IIFE (lambda x: ...)(i) Inline Less readable

When to Use Each

  • Simple cases: Default parameter x=i
  • Complex logic: Factory function
  • Existing functions: functools.partial

Early Binding with Defaults

Default parameters use early binding—the value is captured when the function is defined:

def outer():
    x = 10
    def inner(x=x):  # x captured NOW (value 10)
        return x
    x = 20  # Too late, inner already captured x=10
    return inner

f = outer()
print(f())  # 10

Common Pitfalls

Pitfall 1: Event Handlers

# Bug: All buttons do the same thing
buttons = []
for i in range(3):
    btn = Button(command=lambda: print(i))
    buttons.append(btn)
# All buttons print 2

# Fix
for i in range(3):
    btn = Button(command=lambda x=i: print(x))
    buttons.append(btn)

Pitfall 2: Callbacks

# Bug
callbacks = {}
for name in ['a', 'b', 'c']:
    callbacks[name] = lambda: print(name)

callbacks['a']()  # Prints 'c'!

# Fix
for name in ['a', 'b', 'c']:
    callbacks[name] = lambda n=name: print(n)

Pitfall 3: Threading

import threading

# Bug
for i in range(3):
    threading.Thread(target=lambda: print(i)).start()
# Output unpredictable, likely all same value

# Fix
for i in range(3):
    threading.Thread(target=lambda x=i: print(x)).start()

Pitfall 4: Nested Loops

The gotcha compounds with nested loops:

# Bug
matrix = []
for i in range(3):
    row = []
    for j in range(3):
        row.append(lambda: (i, j))
    matrix.append(row)

print(matrix[0][0]())  # (2, 2) - Wrong!

# Fix
matrix = []
for i in range(3):
    row = []
    for j in range(3):
        row.append(lambda i=i, j=j: (i, j))
    matrix.append(row)

print(matrix[0][0]())  # (0, 0) - Correct!

Memory Consideration

Default parameters capture references, not copies:

# Mutable object caution
data = [1, 2, 3]
f = lambda x=data: x

data.append(4)
print(f())  # [1, 2, 3, 4] — reference, not copy!

# If you need a copy:
f = lambda x=data.copy(): x
# or
f = lambda x=list(data): x

Summary

Binding Type When Value is Captured Syntax
Late binding When function is called def f(): return x
Early binding When function is defined def f(x=x): return x
Issue Cause Solution
All closures return same value Late binding Capture value at definition time
Loop variable captured Same variable shared Use x=i default parameter
Callback returns wrong value Reference to final loop value Factory function or partial

Key Takeaways:

  1. Closures capture variables by reference, not by value
  2. Loop variables change, but all closures share the same reference
  3. Use default parameters to capture the current value
  4. Or use a factory function to create separate scopes
  5. This applies to def, lambda, and comprehensions

Golden Rule: When creating closures in a loop, always capture the value at definition time.