PythonIntermediate

Python Decorators Explained Step by Step

Decorators look like magic, but they're just functions that wrap functions. Build your own timing and retry decorators from scratch.

A function wrapped inside two decorator layers, @retry and @timed
On this page

You've probably seen code like this in Flask, FastAPI or Django:

Python
@app.route("/")
def home():
    return "Hello!"

That @ line is a decorator. Decorators let you add behaviour to a function (logging, timing, caching, access control) without changing the function's own code. They can look like magic, but they're built from three simple ideas. Let's take them one at a time.

Idea 1: functions are objects#

In Python, functions are values like any other. You can assign them to variables and pass them to other functions:

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

say_hello = greet          # no parentheses: we're not calling it
print(say_hello("Ada"))    # Hello, Ada!

Idea 2: functions can be defined inside functions#

Python
def outer():
    def inner():
        return "I'm inside!"
    return inner           # return the function itself

fn = outer()
print(fn())                # I'm inside!

Idea 3: inner functions remember their surroundings#

An inner function can use variables from the function that created it, even after that outer function has finished. This is called a closure:

Python
def make_multiplier(factor):
    def multiply(x):
        return x * factor  # "remembers" factor
    return multiply

double = make_multiplier(2)
triple = make_multiplier(3)
print(double(5), triple(5))  # 10 15

Putting it together: your first decorator#

A decorator is a function that takes a function and returns a new function, usually one that wraps the original with extra behaviour:

Python
def shout(func):
    def wrapper(*args, **kwargs):
        result = func(*args, **kwargs)
        return result.upper()
    return wrapper

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

greet = shout(greet)       # wrap it manually
print(greet("Ada"))        # HELLO, ADA!

The @ syntax is simply a shortcut for that greet = shout(greet) line:

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

print(greet("Ada"))        # HELLO, ADA!
@shout is shorthand for greet = shout(greet) greetoriginal function shout(func)the decorator wrappercalls greet(),then .upper() passed inreturns The name greet now points to wrapper, so greet("Ada") runs wrapper → original greet → .upper()
What the @ line actually does: the decorator receives your function and returns a replacement

*args and **kwargs let the wrapper accept any arguments and pass them straight through, so the decorator works on functions with any signature.

Don't forget functools.wraps#

Our decorator has a subtle problem: the wrapped function has lost its identity.

Python
print(greet.__name__)      # wrapper

That breaks debugging, documentation tools and some frameworks. Fix it with functools.wraps, which copies the original function's name, docstring and other metadata onto the wrapper:

Python
from functools import wraps

def shout(func):
    @wraps(func)
    def wrapper(*args, **kwargs):
        return func(*args, **kwargs).upper()
    return wrapper

Make it a habit

Add @wraps(func) to every decorator you write. It costs one line and prevents confusing bugs later.

A practical decorator: timing#

Let's build something useful: a decorator that reports how long a function takes.

Python
import time
from functools import wraps

def timed(func):
    @wraps(func)
    def wrapper(*args, **kwargs):
        start = time.perf_counter()
        try:
            return func(*args, **kwargs)
        finally:
            elapsed = time.perf_counter() - start
            print(f"{func.__name__} took {elapsed:.3f}s")
    return wrapper

@timed
def build_report(rows):
    return sum(i * i for i in range(rows))

build_report(5_000_000)    # build_report took 0.312s

The try/finally makes sure the time is printed even if the function raises an exception.

Decorators that take arguments#

What if you want to write @retry(times=3)? Then you need one more layer: a function that takes the arguments and returns a decorator.

Python
import time
from functools import wraps

def retry(times=3, delay=1.0, exceptions=(Exception,)):
    def decorator(func):
        @wraps(func)
        def wrapper(*args, **kwargs):
            for attempt in range(1, times + 1):
                try:
                    return func(*args, **kwargs)
                except exceptions as error:
                    if attempt == times:
                        raise
                    print(f"Attempt {attempt} failed: {error}. Retrying in {delay}s…")
                    time.sleep(delay)
        return wrapper
    return decorator

@retry(times=5, delay=0.5, exceptions=(ConnectionError,))
def fetch_prices():
    ...

@retry(times=5) first calls retry(...), which returns decorator, which is then applied to fetch_prices. Three layers sounds complicated, but each one has a single job:

Layer Receives Returns
retry The options (times, delay…) decorator
decorator The function to wrap wrapper
wrapper The arguments of each call The function's result

Built-in decorators you should know#

Python ships with several excellent decorators. For example, you don't need to write your own caching, because functools.cache (Python 3.9+) does it for you:

Python
from functools import cache

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

print(fibonacci(100))   # 354224848179261915075, instantly

Without @cache, this naive recursion would make more than 10²⁰ function calls. With it, each value is computed exactly once. Use functools.lru_cache(maxsize=…) if you need to cap memory use.

Other built-in decorators worth knowing:

  • @property exposes a method as a read-only attribute.
  • @staticmethod and @classmethod define methods that don't need an instance.
  • @dataclass generates __init__, __repr__ and __eq__ for you.
  • @contextlib.contextmanager turns a generator into a with-statement context manager.

Recap#

  • Functions are objects: they can be passed around, returned and defined inside other functions.
  • A decorator takes a function and returns a wrapped version; @deco is shorthand for func = deco(func).
  • Use *args, **kwargs to support any signature, and always use @functools.wraps.
  • Decorators with arguments need one extra layer: a function that returns the decorator.
  • Reach for built-ins like @cache, @property and @dataclass before writing your own.

Challenge: write a @log_calls decorator that prints the function's name and arguments every time it's called. Then stack it with @timed and observe the order in which they run.