Python - Nested Functions

Nested functions are functions defined inside other functions. The inner function has access to variables in the enclosing function's scope, even after the outer function has finished executing. This...

Key Insights

  • Nested functions in Python create closures that capture variables from their enclosing scope, enabling powerful patterns like decorators, factory functions, and data encapsulation without classes
  • Inner functions have access to the enclosing function’s local variables through the LEGB rule (Local, Enclosing, Global, Built-in), with nonlocal keyword allowing modification of enclosing scope variables
  • Nested functions execute faster than equivalent class-based solutions for simple encapsulation needs and provide a lightweight alternative to full object-oriented patterns

Understanding Nested Functions and Scope

Nested functions are functions defined inside other functions. The inner function has access to variables in the enclosing function’s scope, even after the outer function has finished executing. This mechanism creates closures—a fundamental concept in functional programming.

def outer_function(message):
    def inner_function():
        print(message)
    return inner_function

# Create a closure
greeting = outer_function("Hello, World!")
greeting()  # Output: Hello, World!

The inner function captures message from its enclosing scope. When outer_function returns inner_function, the inner function retains access to message even though outer_function has completed execution.

The LEGB Rule in Action

Python resolves variable names using the LEGB rule: Local, Enclosing, Global, Built-in. Understanding this hierarchy is critical when working with nested functions.

x = "global"

def outer():
    x = "enclosing"
    
    def inner():
        x = "local"
        print(f"Local: {x}")
    
    inner()
    print(f"Enclosing: {x}")

outer()
print(f"Global: {x}")

# Output:
# Local: local
# Enclosing: enclosing
# Global: global

Each function creates its own local scope. The inner function’s x doesn’t affect the enclosing function’s x, which doesn’t affect the global x.

Modifying Enclosing Scope with nonlocal

By default, assigning to a variable in an inner function creates a new local variable. Use nonlocal to modify variables in the enclosing scope.

def counter():
    count = 0
    
    def increment():
        nonlocal count
        count += 1
        return count
    
    def decrement():
        nonlocal count
        count -= 1
        return count
    
    def get_count():
        return count
    
    return increment, decrement, get_count

inc, dec, get = counter()
print(inc())  # 1
print(inc())  # 2
print(dec())  # 1
print(get())  # 1

Without nonlocal, attempting count += 1 would raise an UnboundLocalError because Python would treat count as a local variable that hasn’t been assigned yet.

Factory Functions Pattern

Nested functions excel at creating specialized functions with pre-configured behavior.

def create_multiplier(factor):
    def multiply(number):
        return number * factor
    return multiply

double = create_multiplier(2)
triple = create_multiplier(3)

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

# More complex factory
def create_validator(min_value, max_value):
    def validate(value):
        if not isinstance(value, (int, float)):
            return False, "Value must be numeric"
        if value < min_value:
            return False, f"Value must be >= {min_value}"
        if value > max_value:
            return False, f"Value must be <= {max_value}"
        return True, "Valid"
    return validate

age_validator = create_validator(0, 120)
print(age_validator(25))    # (True, 'Valid')
print(age_validator(-5))    # (False, 'Value must be >= 0')
print(age_validator(150))   # (False, 'Value must be <= 120')

Decorators Using Nested Functions

Decorators are perhaps the most common use case for nested functions in production code. They wrap functions to add behavior without modifying the original function.

import time
from functools import wraps

def timing_decorator(func):
    @wraps(func)
    def wrapper(*args, **kwargs):
        start = time.time()
        result = func(*args, **kwargs)
        end = time.time()
        print(f"{func.__name__} took {end - start:.4f} seconds")
        return result
    return wrapper

@timing_decorator
def slow_function(n):
    total = 0
    for i in range(n):
        total += i
    return total

result = slow_function(1000000)
# Output: slow_function took 0.0523 seconds

The @wraps(func) decorator preserves the original function’s metadata. Without it, slow_function.__name__ would be wrapper instead of slow_function.

Parameterized Decorators

Decorators that accept arguments require an additional level of nesting.

def retry(max_attempts=3, delay=1):
    def decorator(func):
        @wraps(func)
        def wrapper(*args, **kwargs):
            attempts = 0
            while attempts < max_attempts:
                try:
                    return func(*args, **kwargs)
                except Exception as e:
                    attempts += 1
                    if attempts >= max_attempts:
                        raise
                    print(f"Attempt {attempts} failed: {e}. Retrying...")
                    time.sleep(delay)
        return wrapper
    return decorator

@retry(max_attempts=3, delay=0.5)
def unreliable_api_call(success_rate=0.3):
    import random
    if random.random() > success_rate:
        raise ConnectionError("API call failed")
    return "Success"

# This will retry up to 3 times before raising the exception
try:
    result = unreliable_api_call(success_rate=0.1)
    print(result)
except ConnectionError as e:
    print(f"Failed after retries: {e}")

Data Encapsulation Without Classes

Nested functions provide lightweight encapsulation when full classes are overkill.

def create_bank_account(initial_balance):
    balance = initial_balance
    transaction_history = []
    
    def deposit(amount):
        nonlocal balance
        if amount <= 0:
            raise ValueError("Deposit amount must be positive")
        balance += amount
        transaction_history.append(("deposit", amount))
        return balance
    
    def withdraw(amount):
        nonlocal balance
        if amount <= 0:
            raise ValueError("Withdrawal amount must be positive")
        if amount > balance:
            raise ValueError("Insufficient funds")
        balance -= amount
        transaction_history.append(("withdrawal", amount))
        return balance
    
    def get_balance():
        return balance
    
    def get_history():
        return transaction_history.copy()
    
    return {
        'deposit': deposit,
        'withdraw': withdraw,
        'balance': get_balance,
        'history': get_history
    }

account = create_bank_account(1000)
account['deposit'](500)
account['withdraw'](200)
print(account['balance']())  # 1300
print(account['history']())  # [('deposit', 500), ('withdrawal', 200)]

The balance and transaction_history variables are completely private—there’s no way to access them except through the provided functions.

Memoization with Nested Functions

Nested functions enable elegant memoization implementations for caching expensive function results.

def memoize(func):
    cache = {}
    
    @wraps(func)
    def wrapper(*args):
        if args not in cache:
            cache[args] = func(*args)
        return cache[args]
    
    def clear_cache():
        cache.clear()
    
    wrapper.clear_cache = clear_cache
    wrapper.cache_info = lambda: {'size': len(cache), 'keys': list(cache.keys())}
    
    return wrapper

@memoize
def fibonacci(n):
    if n < 2:
        return n
    return fibonacci(n-1) + fibonacci(n-2)

print(fibonacci(100))  # Executes quickly due to memoization
print(fibonacci.cache_info())  # {'size': 101, 'keys': [...]}
fibonacci.clear_cache()

Performance Considerations

Nested functions have minimal overhead compared to equivalent class-based solutions for simple encapsulation needs.

# Nested function approach
def create_adder_func(x):
    def add(y):
        return x + y
    return add

# Class-based approach
class Adder:
    def __init__(self, x):
        self.x = x
    
    def add(self, y):
        return self.x + y

# Both work identically
func_adder = create_adder_func(10)
class_adder = Adder(10)

print(func_adder(5))      # 15
print(class_adder.add(5)) # 15

For simple cases like this, the nested function approach is more concise and performs slightly better due to reduced overhead. Use classes when you need inheritance, multiple methods with shared state, or more complex object behavior.

Liked this? There's more.

Every week: one practical technique, explained simply, with code you can use immediately.