Skip to main content
Python · lesson 22 of 34
Python intermediate Lesson 22 of 34

Python Decorators and Closures

Build closures, then decorators: functools.wraps, decorator factories, stacking order, stateful class decorators and lru_cache, each with real output.

By SkillByExample · Updated

A decorator is a function that takes a function and returns a replacement for it, letting you add behaviour — logging, timing, retries, caching — without editing the original. Decorators are built on closures, so that’s where we start. Output is from Python 3.13.

Closures

An inner function can keep using variables from the function that created it, even after that function has returned.

def make_multiplier(factor: int):
    def multiplier(x: int) -> int:
        return x * factor            # factor comes from the enclosing call
    return multiplier

double = make_multiplier(2)
triple = make_multiplier(3)
print(double(5), triple(5))
print(double.__closure__[0].cell_contents)

def make_counter():
    count = 0
    def increment() -> int:
        nonlocal count               # rebind the enclosing variable
        count += 1
        return count
    return increment

tick = make_counter()
print(tick(), tick(), tick())
$ python closures.py
10 15
2
1 2 3

make_multiplier(2) had returned long before double(5) ran, yet factor was still 2 — the closure cell kept it alive. Each call to make_multiplier creates a separate cell, so double and triple don’t interfere. To reassign a captured variable, the inner function needs nonlocal; without it, count += 1 raises UnboundLocalError.

Your first decorator — and why wraps matters

The @ line is shorthand for slow_sum = timer(slow_sum).

import functools
import time

def timer(func):
    @functools.wraps(func)
    def wrapper(*args, **kwargs):
        start = time.perf_counter()
        result = func(*args, **kwargs)
        print(f"[timer] {func.__name__} took {time.perf_counter() - start:.2f}s")
        return result
    return wrapper

def no_wraps(func):
    def wrapper(*args, **kwargs):
        return func(*args, **kwargs)
    return wrapper

@timer
def slow_sum(n: int) -> int:
    """Sum 0..n-1."""
    time.sleep(0.2)
    return sum(range(n))

@no_wraps
def plain(): """Docstring that will vanish."""

print(slow_sum(1_000))
print(slow_sum.__name__, slow_sum.__doc__)
print(plain.__name__, plain.__doc__)
$ python timer.py
[timer] slow_sum took 0.20s
499500
slow_sum Sum 0..n-1.
wrapper None

The wrapper ran the timing code around the real call and passed the return value through — forget return result and every decorated function silently returns None. Without functools.wraps, plain now calls itself wrapper and has lost its docstring, which is what tracebacks, help() and logging would show. (The timing varies by a few milliseconds per run.)

Decorators with arguments

@retry(max_attempts=3) calls a factory that returns the decorator — one extra level of nesting.

import functools

def retry(max_attempts: int = 3, exceptions: tuple = (Exception,)):
    def decorator(func):
        @functools.wraps(func)
        def wrapper(*args, **kwargs):
            for attempt in range(1, max_attempts + 1):
                try:
                    return func(*args, **kwargs)
                except exceptions as e:
                    print(f"attempt {attempt} failed: {e}")
                    if attempt == max_attempts:
                        raise
        return wrapper
    return decorator

calls = 0

@retry(max_attempts=3, exceptions=(ConnectionError,))
def fetch() -> str:
    global calls
    calls += 1
    if calls < 3:
        raise ConnectionError("network unstable")
    return "ok"

print(fetch())

@retry(max_attempts=2, exceptions=(ConnectionError,))
def broken() -> None:
    raise ValueError("bad input")

try:
    broken()
except ValueError as e:
    print("not retried:", e)
$ python retry.py
attempt 1 failed: network unstable
attempt 2 failed: network unstable
ok
not retried: bad input

fetch failed twice and succeeded on the third call, so the caller saw only "ok". broken raised a ValueError, which isn’t in exceptions, so it went straight through on the first attempt — retrying is limited to the failures you named.

Stacking order

Decorators apply bottom-up at definition time, and their wrappers run top-down at call time.

import functools

def tag(name: str):
    def decorator(func):
        print(f"applying {name}")
        @functools.wraps(func)
        def wrapper(*args):
            print(f"enter {name}")
            return f"<{name}>{func(*args)}</{name}>"
        return wrapper
    return decorator

@tag("b")
@tag("i")
def greet(who: str) -> str:
    return f"Hello, {who}"

print("--- calling")
print(greet("Alice"))
$ python stacking.py
applying i
applying b
--- calling
enter b
enter i
<b><i>Hello, Alice</i></b>

“applying” printed before any call — decorators run once, when def executes. @tag("i") is nearest the function so it wrapped first; @tag("b") wrapped the result. Calling greet enters the outermost wrapper, b, first. It is exactly tag("b")(tag("i")(greet)).

Stateful decorators with a class

A class whose __call__ returns a wrapper can keep state across calls on self.

import functools

class RateLimit:
    def __init__(self, max_calls: int):
        self.max_calls = max_calls
        self.calls = 0

    def __call__(self, func):
        @functools.wraps(func)
        def wrapper(*args, **kwargs):
            if self.calls >= self.max_calls:
                raise RuntimeError(f"limit of {self.max_calls} calls reached")
            self.calls += 1
            return func(*args, **kwargs)
        return wrapper

@RateLimit(max_calls=2)
def send(message: str) -> None:
    print("sent:", message)

for msg in ["a", "b", "c"]:
    try:
        send(msg)
    except RuntimeError as e:
        print("blocked:", e)
$ python ratelimit.py
sent: a
sent: b
blocked: limit of 2 calls reached

RateLimit(max_calls=2) creates one instance, which then decorates send; its calls counter lives as long as the decorated function does. A real limiter would also reset the count per time window.

Built-in decorators: property, classmethod, staticmethod

These three shape how a method is accessed.

import math

class Circle:
    def __init__(self, radius: float):
        self.radius = radius

    @property
    def area(self) -> float:
        return math.pi * self.radius ** 2

    @classmethod
    def unit(cls) -> "Circle":
        return cls(1.0)

    @staticmethod
    def is_valid_radius(r: float) -> bool:
        return r > 0

c = Circle.unit()
print(round(c.area, 5), type(c).__name__)
print(Circle.is_valid_radius(-1))
try:
    c.area = 10
except AttributeError as e:
    print("AttributeError:", e)
$ python builtins.py
3.14159 Circle
False
AttributeError: property 'area' of 'Circle' object has no setter

c.area has no parentheses — @property runs the method on attribute access, and without a setter the attribute is read-only. @classmethod receives the class, so unit() would build a subclass instance if called on one; @staticmethod receives neither class nor instance.

Memoising with functools.lru_cache

lru_cache stores results keyed by the arguments, so repeated calls skip the work.

import functools

calls = 0

@functools.lru_cache(maxsize=None)
def fibonacci(n: int) -> int:
    global calls
    calls += 1
    if n < 2:
        return n
    return fibonacci(n - 1) + fibonacci(n - 2)

print(fibonacci(50))
print("function bodies run:", calls)
print(fibonacci.cache_info())
$ python fib.py
12586269025
function bodies run: 51
CacheInfo(hits=48, misses=51, maxsize=None, currsize=51)

Uncached, fibonacci(50) would run its body about 40 billion times. Cached, each n from 0 to 50 is computed once (51 misses) and every repeat is a hit. @functools.cache is the same thing with no size limit.

DecoratorPurpose
@functools.wraps(func)Preserve the wrapped function’s metadata
@functools.lru_cache(maxsize=128)Memoise up to N results
@functools.cacheUnbounded memoisation (3.9+)
@functools.cached_propertyCompute an attribute once per instance

Practice

1. Write a @log_calls decorator that prints the function name, arguments and return value of each call.

Solution
import functools

def log_calls(func):
    @functools.wraps(func)
    def wrapper(*args, **kwargs):
        result = func(*args, **kwargs)
        print(f"{func.__name__}{args} {kwargs} -> {result!r}")
        return result
    return wrapper

@log_calls
def add(a: int, b: int = 0) -> int:
    return a + b

add(2, 3)
add(5, b=10)
$ python log_calls.py
add(2, 3) {} -> 5
add(5,) {'b': 10} -> 15

Positional and keyword arguments arrive separately in *args and **kwargs, which is why b=10 shows up in the dict.

2. Write a decorator factory @repeat(n) that calls the function n times and returns a list of the results.

Solution
import functools

def repeat(n: int):
    def decorator(func):
        @functools.wraps(func)
        def wrapper(*args, **kwargs):
            return [func(*args, **kwargs) for _ in range(n)]
        return wrapper
    return decorator

counter = iter(range(100))

@repeat(3)
def next_id() -> int:
    return next(counter)

print(next_id())
print(next_id())
$ python repeat.py
[0, 1, 2]
[3, 4, 5]

3. Swap the order of the two @tag decorators in stacking.py and predict both the “applying” lines and the final HTML.

Solution
import functools

def tag(name: str):
    def decorator(func):
        print(f"applying {name}")
        @functools.wraps(func)
        def wrapper(*args):
            return f"<{name}>{func(*args)}</{name}>"
        return wrapper
    return decorator

@tag("i")
@tag("b")
def greet(who: str) -> str:
    return f"Hello, {who}"

print(greet("Alice"))
$ python stacking_swapped.py
applying b
applying i
<i><b>Hello, Alice</b></i>

The decorator nearest def is still applied first, and it becomes the innermost tag.

4. Use functools.wraps’s __wrapped__ attribute to call the original, undecorated slow_sum and skip the timer.

Solution
import functools

def timer(func):
    @functools.wraps(func)
    def wrapper(*args, **kwargs):
        print("[timer] timing...")
        return func(*args, **kwargs)
    return wrapper

@timer
def slow_sum(n: int) -> int:
    return sum(range(n))

print(slow_sum(10))
print(slow_sum.__wrapped__(10))
$ python unwrapped.py
[timer] timing...
45
45

wraps sets __wrapped__ to the original function — handy in tests that want to bypass a decorator.

Frequently Asked Questions

What is a closure in Python?
A closure is an inner function that keeps access to variables from the function that created it, even after that outer function has returned. Decorators depend on closures: the wrapper remembers the original function through one.
Why should I use functools.wraps?
Without @functools.wraps, a decorated function reports the wrapper's __name__ and __doc__ instead of its own, which confuses tracebacks, logging, help() and test tools. wraps copies that metadata across and sets __wrapped__ to the original function.
What is the difference between a function decorator and a class decorator?
A function decorator receives a function and returns a callable to use in its place. A class decorator receives a class when it is defined and returns a modified or replacement class, as @dataclass does. A class can also act as a decorator by implementing __call__.
In what order are stacked decorators applied?
Bottom-up at definition time: the decorator nearest the def wraps the function first, and the topmost one wraps the result. At call time the order reverses, so the topmost wrapper's code runs first.
When does a decorator's code run?
The decorator function itself runs once, when the def statement executes, typically at import. Only the wrapper it returns runs on every call. Code placed in the decorator body rather than the wrapper therefore runs exactly once per decorated function.