Mastering Python Decorators: From Closures to Production-Ready Patterns

Learn how Python decorators really work — from closures and the @ syntax to functools.wraps, decorators that take arguments, stacking, stateful and class-based decorators, plus practical retry and caching patterns you can use today.

Sooner or later every Python developer hits the same wall: you have a dozen functions that all need the same extra behavior — timing, logging, caching, input validation, or access checks — and copy-pasting that logic into each one is tedious and fragile. Decorators are Python's elegant answer. They let you wrap a function in another function to add behavior without touching the original code. They power familiar tools like @property, @staticmethod, @functools.lru_cache, and the route decorators in Flask and FastAPI.

This guide builds decorators from the ground up. We start with the two ideas that make them possible, then work up to decorators that take arguments, stack on top of each other, hold state, and solve real problems like retrying flaky network calls.

The foundation: functions are objects

In Python, functions are first-class objects. You can assign them to variables, pass them as arguments, and return them from other functions — just like integers or lists.

def greet(name):
    return f"Hello, {name}!"

say_hello = greet            # assign the function to another name
print(say_hello("Ada"))      # Hello, Ada!

def shout(func, name):
    return func(name).upper()

print(shout(greet, "Ada"))   # HELLO, ADA!

The second ingredient is the closure: an inner function that remembers values from the scope where it was created, even after that scope has returned.

def make_multiplier(factor):
    def multiply(n):
        return n * factor    # 'factor' is captured from the enclosing scope
    return multiply

double = make_multiplier(2)
triple = make_multiplier(3)
print(double(10))            # 20
print(triple(10))            # 30

A decorator is simply a function that takes a function and returns a new function — a closure that wraps the original. That is the whole idea; everything else is convenience.

Your first decorator and the @ syntax

Here is a decorator that measures how long a function takes to run.

import time

def timer(func):
    def wrapper(*args, **kwargs):
        start = time.perf_counter()
        result = func(*args, **kwargs)
        elapsed = time.perf_counter() - start
        print(f"{func.__name__} took {elapsed:.6f}s")
        return result
    return wrapper

@timer
def slow_square(n):
    time.sleep(0.1)
    return n * n

print(slow_square(5))
# slow_square took 0.100xxxs
# 25

The @timer line is pure syntactic sugar. It means exactly the same thing as writing:

slow_square = timer(slow_square)

Notice the *args, **kwargs in wrapper. They let the wrapper accept any arguments and forward them untouched to the original function, so a single decorator works on functions of any signature. Also notice that wrapper returns the result — forget that and every decorated function silently returns None.

Don't lose your identity: functools.wraps

There is a subtle bug in the version above. When you wrap a function, the returned wrapper replaces it — including its name and docstring.

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

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

print(add.__name__)   # wrapper   <- not what we want
print(add.__doc__)    # None

This breaks documentation tools, debuggers, and anything that inspects __name__. The fix is one line: decorate wrapper with functools.wraps, which copies the original function's metadata across.

import functools

def timer(func):
    @functools.wraps(func)
    def wrapper(*args, **kwargs):
        return func(*args, **kwargs)
    return wrapper

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

print(add.__name__)   # add
print(add.__doc__)    # Add two numbers.

Tip: always use functools.wraps in your decorators. There is essentially no reason not to.

Decorators that take arguments

What if you want to configure the decorator itself — say, repeat a function a given number of times? You need one more layer: a function that takes the arguments and returns a decorator.

import functools

def repeat(times):
    def decorator(func):
        @functools.wraps(func)
        def wrapper(*args, **kwargs):
            result = None
            for _ in range(times):
                result = func(*args, **kwargs)
            return result
        return wrapper
    return decorator

@repeat(times=3)
def ping():
    print("ping")

ping()
# ping
# ping
# ping

The three nested levels trip up many people, so it helps to read them as answers to three questions: repeat(times) captures the configuration, decorator(func) captures the function being decorated, and wrapper(*args, **kwargs) captures the call-time arguments. The line @repeat(times=3) calls repeat first, and the decorator it returns is then applied to ping.

A practical example: retrying flaky calls

Configurable decorators shine for cross-cutting concerns like retrying an operation that occasionally fails — a common need when talking to networks or databases.

import functools, time

def retry(times=3, delay=0.5, exceptions=(Exception,)):
    def decorator(func):
        @functools.wraps(func)
        def wrapper(*args, **kwargs):
            for attempt in range(1, times + 1):
                try:
                    return func(*args, **kwargs)
                except exceptions as exc:
                    print(f"Attempt {attempt} failed: {exc}")
                    if attempt == times:
                        raise          # give up after the last attempt
                    time.sleep(delay)
        return wrapper
    return decorator

calls = 0

@retry(times=3, delay=0)
def flaky():
    global calls
    calls += 1
    if calls < 3:
        raise ValueError("temporary glitch")
    return "success"

print(flaky())
# Attempt 1 failed: temporary glitch
# Attempt 2 failed: temporary glitch
# success

The same wrapper now adds resilience to any function, and the caller's code stays clean. Restricting exceptions to the errors you actually expect is important — you don't want to silently retry a TypeError caused by a real bug.

Stacking decorators

You can apply more than one decorator to a function. They are applied bottom-up — the one closest to the function wraps it first.

import functools

def bold(func):
    @functools.wraps(func)
    def wrapper(*args, **kwargs):
        return "<b>" + func(*args, **kwargs) + "</b>"
    return wrapper

def italic(func):
    @functools.wraps(func)
    def wrapper(*args, **kwargs):
        return "<i>" + func(*args, **kwargs) + "</i>"
    return wrapper

@bold
@italic
def greet(name):
    return f"Hi {name}"

print(greet("Sam"))   # <b><i>Hi Sam</i></b>

Reading top to bottom, @bold then @italic is equivalent to greet = bold(italic(greet)). The italic layer runs first and is wrapped by bold, which is why the bold tags end up on the outside.

Decorators with state

Because the wrapper is just an object, you can hang attributes on it to keep state across calls — for example, to count how often a function is invoked.

import functools

def count_calls(func):
    @functools.wraps(func)
    def wrapper(*args, **kwargs):
        wrapper.calls += 1
        print(f"Call #{wrapper.calls} of {func.__name__}")
        return func(*args, **kwargs)
    wrapper.calls = 0
    return wrapper

@count_calls
def hello():
    return "hi"

hello()
hello()
print(hello.calls)   # 2

When state grows beyond a counter, a class-based decorator is often cleaner. Any object with a __call__ method can act as a decorator.

import functools

class CountCalls:
    def __init__(self, func):
        functools.update_wrapper(self, func)
        self.func = func
        self.calls = 0

    def __call__(self, *args, **kwargs):
        self.calls += 1
        print(f"Call #{self.calls}")
        return self.func(*args, **kwargs)

@CountCalls
def hello():
    return "hi"

hello()
hello()
print(hello.calls)   # 2

Note functools.update_wrapper — it is the class-based equivalent of functools.wraps, copying the wrapped function's name and docstring onto the instance.

You don't always have to write your own

The standard library ships with battle-tested decorators. The most useful for everyday performance work is functools.lru_cache, which memoizes results so repeated calls with the same arguments are instant.

import functools

@functools.lru_cache(maxsize=None)
def fib(n):
    if n < 2:
        return n
    return fib(n - 1) + fib(n - 2)

print(fib(50))            # 12586269025, computed almost instantly
print(fib.cache_info())   # CacheInfo(hits=48, misses=51, maxsize=None, currsize=51)

Without the cache, fib(50) would trigger billions of redundant calls. With it, each value is computed once. On Python 3.9+ you can also use the simpler @functools.cache, which is just shorthand for lru_cache(maxsize=None).

Common pitfalls

A few mistakes account for most decorator bugs. Keep them in mind:

  • Forgetting functools.wraps — decorated functions lose their names and docstrings, breaking introspection and documentation tools.
  • Not forwarding *args, **kwargs — a wrapper that hard-codes its parameters only works on functions with that exact signature.
  • Forgetting to return the result — if wrapper does not return func(...), every decorated function silently returns None.
  • Confusing the layers — a plain decorator takes func directly; a decorator with arguments needs an extra outer function. Mixing these up is the number-one decorator bug.
  • Caching the wrong thingslru_cache requires hashable arguments and holds references to its results, so avoid it on functions with unhashable inputs or an unbounded variety of arguments unless you set a sensible maxsize.

Wrap-up and next steps

Decorators rest on two simple ideas — functions are objects, and closures remember their enclosing scope — and scale up to powerful, reusable tools. Start by recognizing the pattern: a decorator takes a function and returns a wrapped version of it. Add functools.wraps, forward *args/**kwargs, return the result, and you have a clean, general-purpose decorator. Reach for the three-layer form when you need configuration, and a class when you need real state.

From here, explore the decorators you already use every day: @property for managed attributes, @functools.cached_property for lazy computed values, @dataclasses.dataclass for boilerplate-free classes, and the route decorators in Flask and FastAPI. Once the pattern clicks, you will start spotting places in your own code where a small wrapper removes a lot of duplication.