Skip to Content
Python Decorators Explained With One Real Example
Advanced
0 likes
Tutorials

Python Decorators Explained With One Real Example

A decorator is just a function that wraps another function. Build a real timing decorator step by step: functions as values, the wrapper, the @ syntax, and functools.wraps. After that, the syntax stops looking like magic.

A decorator is a function that wraps another function. That's all it is. The @ syntax makes it look like magic, but once you build one yourself, step by step, it stops being magic. We'll build one real decorator, a timer that tells you how long a function took, and you'll probably keep using it after this.

Skip ahead: If you already know that functions can be passed around and returned like any other value, skip to "Step 2: the wrapper".

First, do you need a decorator?

Use one when the same extra behavior has to go around many functions: timing, logging, checking permissions, retrying, caching. Don't use one for logic that belongs to a single function. Then it's just a function call hidden behind an @, and the next person has to go find it.

Step 1: functions are values

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


say = greet
print(say("Ana"))


def call_twice(func, value):
    return [func(value), func(value)]


print(call_twice(greet, "Ben"))
Hello, Ana
['Hello, Ben', 'Hello, Ben']

greet without parentheses is the function itself, not its result. You can store it in a variable and pass it to another function. Every decorator depends on this.

Step 2: the wrapper

We want to time any function without touching its code. So we write a function that takes a function, and returns a new function that does the timing and calls the original in the middle:

import time


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


def slow_report(month):
    time.sleep(0.2)
    return f"Report for {month}"


slow_report = timed(slow_report)
print(slow_report("October"))
slow_report took 0.2 s
Report for October

Line by line:

  • timed gets the original function as func.
  • Inside, it defines wrapper, which starts a timer, calls func, stops the timer, prints, and returns the result.
  • timed returns wrapper, not its result. No parentheses.
  • slow_report = timed(slow_report) replaces the name with the wrapped version. Everyone who calls slow_report now gets the timing.

*args, **kwargs means "accept any arguments and pass them on unchanged". That's what makes the decorator work on any function, whatever its arguments are.

time.perf_counter() is the right clock for measuring durations. time.time() is the wall clock, and it can jump if the computer adjusts its time.

⚠ Warning: Forget return result in the wrapper and every decorated function returns None. No error. The timing prints fine, so it looks like it works, and then something far away breaks because it got None. This is the most common decorator bug.

Step 3: the @ syntax

slow_report = timed(slow_report) works, but it's written after the function, where people miss it. The @ line does exactly the same thing, just above the function:

import time


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


@timed
def slow_report(month):
    time.sleep(0.2)
    return f"Report for {month}"


print(slow_report("October"))
print(slow_report.__name__)
slow_report took 0.2 s
Report for October
wrapper

It works. But look at the last line. The function now says its name is wrapper. Its docstring is gone too. Error messages, logs, and tools that read function names now see wrapper everywhere. With five decorated functions, that's five functions all called wrapper. Not helpful.

Step 4: fix it with functools.wraps

import functools
import time


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


@timed
def slow_report(month):
    """Build the monthly report."""
    time.sleep(0.2)
    return f"Report for {month}"


@timed
def broken_report():
    time.sleep(0.1)
    raise ValueError("No data for this month")


print(slow_report("October"))
print(slow_report.__name__, "-", slow_report.__doc__)

try:
    broken_report()
except ValueError as error:
    print("Failed:", error)
slow_report took 0.2 s
Report for October
slow_report - Build the monthly report.
broken_report took 0.1 s
Failed: No data for this month

Two changes from Step 3:

  1. @functools.wraps(func) copies the name, docstring and a few other details from the original onto the wrapper. Always add it. There's no good reason not to.
  2. try/finally. The time is now printed even when the function fails. Slow failures are often exactly the ones you want to measure. The error itself still goes up to the caller unchanged. The decorator measures. It doesn't decide what to do about errors.

That's the final version. Copy it into a small utils.py and put @timed on whatever you suspect is slow.

💡 Tip: Use @timed to find the slow part, then take it off. Printing on every call is fine while you investigate. In a real application, send the timing to your logging setup instead of print.

Stacking decorators

You can put more than one decorator on a function. They apply from the bottom up: the one closest to def wraps first. If the order matters, for example a cache and a timer, think about which one should see the call first. I would put the timer on the outside, so it measures everything, including the cache.

When not to use a decorator

  • When only one function needs it. Just write the code inside that function.
  • When it changes what the function returns in surprising ways. A decorator that turns errors into None hides bugs.
  • When it needs lots of configuration. Decorators that take arguments (@retry(times=3)) need one more layer of functions. They're useful, but they're the next step, not the first one. Get comfortable with this one first.

Common mistakes

  • Calling the function in the decorator line: @timed(). For this decorator, that's wrong. timed expects a function, and @timed() calls it with nothing.
  • Forgetting return result. Everything returns None.
  • Forgetting functools.wraps. Every function is called wrapper.
  • Fixed arguments in the wrapper. def wrapper(month) only works for functions with exactly that argument. Use *args, **kwargs.

What happens if you skip this

You'll still read decorators everywhere, like @property, @staticmethod and the routes in web frameworks, and treat them like spells. When one breaks, you won't know where to look. Build one yourself once, like this, and every other decorator is just a variation of the same four steps.

About

A timing decorator built in four steps, ending with a version that keeps the function's name and still reports the time when the function fails. After this, you can write your own decorators and read other people's.

Responsible
Jay P
Last Update
09/14/2026
Reading Time
6 min
Was this helpful?
​ 0 ​ 0

Comments (0)

Sign in to comment

No comments yet.