A decorator in Python is a function that wraps another function to add behavior around it — logging, timing, access control, retries — without changing the original function's code at all. You've likely already used decorators without writing one, since things like @staticmethod, @property, and Flask's @app.route() are all decorators. Understanding how they work under the hood demystifies a lot of "magic" you see in frameworks.

At its core, @my_decorator above a function definition is just shorthand for my_func = my_decorator(my_func) — a decorator is a function that takes a function and returns a new function (usually a wrapper that calls the original one somewhere inside it).

Python Program to Implement a Timing Decorator

import time
import functools


def timer(func):
    @functools.wraps(func)  # preserves func's name and docstring
    def wrapper(*args, **kwargs):
        start = time.perf_counter()
        result = func(*args, **kwargs)
        elapsed = time.perf_counter() - start
        print(f"{func.__name__} took {elapsed:.6f} seconds")
        return result
    return wrapper


@timer
def slow_sum(n):
    return sum(range(n))


total = slow_sum(1_000_000)
print(f"Result: {total}")

Output:

slow_sum took 0.012481 seconds
Result: 499999500000

(Exact timing will vary run to run.)

What's Actually Happening

When Python sees @timer above def slow_sum(n):, it replaces slow_sum with timer(slow_sum) — the name slow_sum now points at wrapper instead of the original function. Every call to slow_sum(...) actually calls wrapper(...), which starts the clock, calls the real slow_sum (captured in func via closure), stops the clock, prints the timing, and returns whatever the real function returned. The *args, **kwargs in wrapper's signature are what let this decorator work on any function regardless of how many arguments it takes, and @functools.wraps(func) matters because without it, slow_sum.__name__ would report "wrapper" instead of "slow_sum", which breaks introspection, debugging, and documentation tools that rely on a function's real name.

Decorators can also take their own arguments (like @lru_cache(maxsize=128)), which just means adding one more layer of function nesting — a function that takes the decorator's arguments and returns the actual decorator. Once you're comfortable with this pattern, it's worth comparing it to a related technique that also avoids repeating function calls: recursion with memoization, which is often itself implemented as a decorator.