Fundamentals 12 min read

Understanding Python Closures and Decorators with Practical Examples

This article explains the concepts of closures and decorators in Python, demonstrates how closures can capture and preserve state, and shows multiple decorator patterns—including simple, parameterized, and thread‑safe decorators—through clear code examples and explanations.

Test Development Learning Exchange
Test Development Learning Exchange
Test Development Learning Exchange
Understanding Python Closures and Decorators with Practical Examples

Python closures and decorators are essential functional‑programming tools that enable state encapsulation and dynamic modification of function behavior.

Closure

A closure is a function that can access variables defined in its enclosing scope, allowing it to remember and manipulate those variables even after the outer function has finished.

Simple closure example

def outer_function(x):
    def inner_function(y):
        return x + y
    return inner_function

closure = outer_function(10)
print(closure(5))  # Output: 15

This example shows outer_function returning inner_function , which uses the variable x from the outer scope.

Using a closure to create a counter

def create_counter():
    count = 0
    def increment():
        nonlocal count
        count += 1
        return count
    return increment

counter = create_counter()
print(counter())  # Output: 1
print(counter())  # Output: 2
print(counter())  # Output: 3

Here the inner increment function retains the count variable across calls.

Decorator

A decorator is a special kind of closure that takes a function as an argument and returns a new function, typically used to augment or modify the original function without changing its source code.

Simple decorator

def simple_decorator(func):
    def wrapper():
        print("Before the function is called.")
        func()
        print("After the function is called.")
    return wrapper

@simple_decorator
def say_hello():
    print("Hello!")

say_hello()
# Output:
# Before the function is called.
# Hello!
# After the function is called.

The @simple_decorator syntax is equivalent to say_hello = simple_decorator(say_hello) .

Decorator with parameters

def repeat(n_times):
    def decorator(func):
        def wrapper(*args, **kwargs):
            for _ in range(n_times):
                result = func(*args, **kwargs)
            return result
        return wrapper
    return decorator

@repeat(n_times=3)
def greet(name):
    print(f"Hello {name}")

greet("Alice")
# Output:
# Hello Alice
# Hello Alice
# Hello Alice

The repeat decorator accepts a parameter n_times and repeats the wrapped function that many times.

Decorator handling optional arguments

def log(function=None, *, prefix=""):
    def decorator(func):
        def wrapper(*args, **kwargs):
            print(prefix + "Calling function", func.__name__)
            result = func(*args, **kwargs)
            print(prefix + "Function finished")
            return result
        return wrapper
    if function is not None:
        return decorator(function)
    return decorator

@log(prefix=">>> ")
def add(a, b):
    return a + b

print(add(1, 2))
# Output:
# >>> Calling function add
# >>> Function finished
# 3

This decorator can be used with or without explicit parameters.

Using a closure to implement caching

def cache(maxsize=128):
    cache_dict = {}
    def decorator(func):
        def wrapper(*args):
            if args in cache_dict:
                print(f"Returning cached result for {args}")
                return cache_dict[args]
            if len(cache_dict) >= maxsize:
                cache_dict.popitem(last=False)
            result = func(*args)
            cache_dict[args] = result
            return result
        return wrapper
    return decorator

@cache(maxsize=10)
def fibonacci(n):
    if n <= 1:
        return n
    return fibonacci(n-1) + fibonacci(n-2)

print(fibonacci(10))
# Output:
# Returning cached result for (10,)
# 55

The cache decorator stores previously computed results to avoid redundant calculations.

Advanced closure example – Logger with state

def create_logger(log_level):
    logs = []
    def logger(message):
        nonlocal logs
        logs.append((log_level, message))
        print(f"[{log_level}] {message}")
    def get_logs():
        return logs
    return logger, get_logs

info_logger, info_get_logs = create_logger("INFO")
error_logger, error_get_logs = create_logger("ERROR")
info_logger("This is an info message.")
error_logger("This is an error message.")
print(info_get_logs())   # [('INFO', 'This is an info message.')]
print(error_get_logs())  # [('ERROR', 'This is an error message.')]

This demonstrates a closure that maintains a mutable log list.

Dynamic class creation with a property

def create_class_with_property(property_name):
    class DynamicClass:
        def __init__(self):
            self._property = None
        @property
        def prop(self):
            return getattr(self, f"_{property_name}")
        @prop.setter
        def prop(self, value):
            setattr(self, f"_{property_name}", value)
    return DynamicClass

DynamicClassWithProperty = create_class_with_property("value")
instance = DynamicClassWithProperty()
instance.prop = 10
print(instance.prop)  # Output: 10

The function returns a class whose attribute name is defined at runtime.

Decorator chain (debug + retry)

def debug(func):
    def wrapper(*args, **kwargs):
        print(f"Calling {func.__name__}")
        result = func(*args, **kwargs)
        print(f"Result: {result}")
        return result
    return wrapper

def retry(times=3):
    def decorator(func):
        def wrapper(*args, **kwargs):
            for _ in range(times):
                try:
                    return func(*args, **kwargs)
                except Exception as e:
                    print(f"Error: {e}")
            raise Exception("Retry limit exceeded")
        return wrapper
    return decorator

@debug
@retry(times=2)
def risky_operation():
    import random
    if random.choice([True, False]):
        return "Success"
    else:
        raise ValueError("Operation failed")

print(risky_operation())

The combined decorators provide debugging output and automatic retries.

Parameterised decorator example

def parametrized_decorator(param):
    def decorator(func):
        def wrapper(*args, **kwargs):
            print(f"Parameter: {param}")
            return func(*args, **kwargs)
        return wrapper
    return decorator

@parametrized_decorator("hello")
def greet(name):
    return f"Hello, {name}"

print(greet("Alice"))
# Output:
# Parameter: hello
# Hello, Alice

This shows how to pass arguments to a decorator factory.

Preserving function signatures with functools.wraps

from functools import wraps

def trace(func):
    @wraps(func)
    def wrapper(*args, **kwargs):
        print(f"Calling {func.__name__}")
        result = func(*args, **kwargs)
        print(f"Result: {result}")
        return result
    return wrapper

@trace
def add(a, b):
    """Add two numbers"""
    return a + b

print(add(1, 2))
print(add.__doc__)  # Output: Add two numbers

functools.wraps keeps the original function’s name and docstring.

Thread‑safe decorator

import threading
from functools import wraps

def thread_safe(func):
    lock = threading.Lock()
    @wraps(func)
    def wrapper(*args, **kwargs):
        with lock:
            return func(*args, **kwargs)
    return wrapper

@thread_safe
def safe_increment(counter):
    counter[0] += 1
    return counter[0]

counter = [0]
print(safe_increment(counter))  # 1
print(safe_increment(counter))  # 2

The decorator ensures that the wrapped function executes safely in multi‑threaded contexts.

Decorator factory (memoize)

def memoize(maxsize=128):
    cache = {}
    def decorator(func):
        @wraps(func)
        def wrapper(*args):
            if args in cache:
                return cache[args]
            result = func(*args)
            if len(cache) >= maxsize:
                cache.popitem(last=False)
            cache[args] = result
            return result
        return wrapper
    return decorator

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

print(fibonacci(10))  # Output: 55

This factory creates a memoization decorator with a configurable cache size.

Conclusion

Closures and decorators are powerful Python tools for encapsulating state, extending functionality, and writing cleaner code; experimenting with the patterns above will deepen understanding and improve practical programming skills.

PythonFunctional Programmingprogramming fundamentalsdecoratorClosure
Test Development Learning Exchange
Written by

Test Development Learning Exchange

Test Development Learning Exchange

0 followers
Reader feedback

How this landed with the community

login Sign in to like

Rate this article

Was this worth your time?

Sign in to rate
Discussion

0 Comments

Thoughtful readers leave field notes, pushback, and hard-won operational detail here.