DEV Community

Bhuwan Bhatt
Bhuwan Bhatt

Posted on Originally published at bhuwanbhatt.com

Python Decorators: A complete guide to decorators in Python

If you've seen something like @yo_i_am_decorating_this_func above a Python function and wondered what's going on, you're in the right place. Python decorators look like magic at first, but they're built from a few simple ideas. In this guide we'll take one small example and grow it step by step until you understand every part of how decorators work.

Our Running Example: A Slow Weather Fetcher

Here's the function we'll use for the whole post. It pretends to call a slow weather API:

import time

def fetch_weather(city):
    time.sleep(0.5)  # pretend this is a slow network call
    return f"Sunny in {city}"
Enter fullscreen mode Exit fullscreen mode

Our goal is to measure how long fetch_weather takes without changing its code. That's exactly what decorators are for.

Step 1: Functions Are Objects in Python

In Python, functions are first-class objects. You can store them in variables and pass them to other functions, just like numbers or strings:

get_forecast = fetch_weather       # no parentheses, so no call
print(get_forecast("Paris"))       # Sunny in Paris

def run(func, city):
    return func(city)

print(run(fetch_weather, "Tokyo")) # Sunny in Tokyo
Enter fullscreen mode Exit fullscreen mode

Since a function can receive another function, it can also add behavior around it. That's the core idea behind every decorator.

Step 2: Wrapping a Function with a Closure

Let's write a function that takes fetch_weather, wraps it with timing code, and returns the new version:

def timer(func):
    def wrapper(city):
        start = time.perf_counter()
        result = func(city)
        print(f"{func.__name__} took {time.perf_counter() - start:.2f}s")
        return result
    return wrapper

fetch_weather = timer(fetch_weather)
fetch_weather("Paris")   # fetch_weather took 0.50s
Enter fullscreen mode Exit fullscreen mode

Notice that wrapper uses func even after timer has finished running. That's called a closure: an inner function that remembers variables from the function that created it. Closures are what let decorators hold on to the original function.

Step 3: The @ Decorator Syntax

Writing fetch_weather = timer(fetch_weather) works, but Python gives us a cleaner shortcut:

@timer
def fetch_weather(city):
    time.sleep(0.5)
    return f"Sunny in {city}"
Enter fullscreen mode Exit fullscreen mode

@timer means exactly the same thing: "define this function, then pass it through timer." One thing to remember is that this happens when the function is defined, not when it's called.

Step 4: Handling Any Arguments with *args and **kwargs

What if we add a units parameter to our fetcher? Our wrapper only accepts city, so it would break. The fix is to accept and pass along any arguments:

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

@timer
def fetch_weather(city, units="metric"):
    time.sleep(0.5)
    return f"Sunny in {city} ({units})"
Enter fullscreen mode Exit fullscreen mode

Now timer works on any function. Always remember the return result line, or your decorated function will quietly return None.

Step 5: Keep Function Metadata with functools.wraps

There's a sneaky problem. Try this:

print(fetch_weather.__name__)   # wrapper  (not what we want)
Enter fullscreen mode Exit fullscreen mode

Our function lost its name and docstring, which makes debugging and documentation harder. The standard library fixes this with functools.wraps:

import functools

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

Now fetch_weather.__name__ is fetch_weather again. Make @functools.wraps a habit in every decorator you write.

Step 6: Python Decorators with Arguments

What if we want to choose the time unit, like @timer(unit="ms")? Since timer(unit="ms") runs first, it must return a decorator. That means adding one more layer, often called a decorator factory:

def timer(unit="s"):
    def decorator(func):
        @functools.wraps(func)
        def wrapper(*args, **kwargs):
            start = time.perf_counter()
            result = func(*args, **kwargs)
            elapsed = time.perf_counter() - start
            if unit == "ms":
                elapsed *= 1000
            print(f"{func.__name__} took {elapsed:.2f}{unit}")
            return result
        return wrapper
    return decorator

@timer(unit="ms")
def fetch_weather(city, units="metric"):
    time.sleep(0.5)
    return f"Sunny in {city} ({units})"

fetch_weather("Paris")   # fetch_weather took 500.12ms
Enter fullscreen mode Exit fullscreen mode

Think of it as three layers: the outer one takes the settings, the middle one takes the function, and the inner one takes the call's arguments. Since timer is now a factory, you'll write @timer() with parentheses even when using the default unit.

Step 7: Stacking Multiple Decorators

Let's add a second decorator that logs each call:

def log_calls(func):
    @functools.wraps(func)
    def wrapper(*args, **kwargs):
        print(f"Calling {func.__name__}{args}")
        return func(*args, **kwargs)
    return wrapper

@log_calls
@timer(unit="ms")
def fetch_weather(city, units="metric"):
    time.sleep(0.5)
    return f"Sunny in {city} ({units})"
Enter fullscreen mode Exit fullscreen mode

Decorators apply bottom-up, so this equals log_calls(timer(unit="ms")(fetch_weather)). When you call the function, the outermost one (log_calls) runs first. Picture layers of an onion: the call goes in through each layer, and the result comes back out. Order matters, so think about which behavior should happen first.

Step 8: Class-Based Decorators for Keeping State

Any callable can be a decorator, including a class with a __call__ method. This is handy when you want to keep state, like counting how many times we've fetched the weather:

class CountCalls:
    def __init__(self, func):
        functools.update_wrapper(self, func)  # class version of @wraps
        self.func = func
        self.calls = 0

    def __call__(self, *args, **kwargs):
        self.calls += 1
        return self.func(*args, **kwargs)

@CountCalls
def fetch_weather(city):
    return f"Sunny in {city}"

fetch_weather("Paris")
fetch_weather("Rome")
print(fetch_weather.calls)   # 2
Enter fullscreen mode Exit fullscreen mode

Step 9: Decorating Methods and Classes

Our function-based timer works on methods too, because self simply arrives inside *args:

class WeatherClient:
    @timer(unit="ms")
    def fetch(self, city):
        time.sleep(0.5)
        return f"Sunny in {city}"
Enter fullscreen mode Exit fullscreen mode

Python also ships with built-in decorators you'll see all the time. @property makes a method behave like an attribute, @staticmethod and @classmethod change what the method receives, and @functools.lru_cache caches results, which is perfect for repeated weather lookups:

@functools.lru_cache(maxsize=100)
def fetch_weather(city):
    time.sleep(0.5)
    return f"Sunny in {city}"
Enter fullscreen mode Exit fullscreen mode

The second call for the same city returns instantly.

Decorators can even wrap classes. They receive the class instead of a function and usually return it after a small change:

def add_source(cls):
    cls.source = "Demo Weather API"
    return cls

@add_source
class WeatherClient: ...

print(WeatherClient.source)   # Demo Weather API
Enter fullscreen mode Exit fullscreen mode

Step 10: Decorating Async Functions

If your weather fetcher is async, the wrapper must be async too and must await the original:

import asyncio

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

@async_timer
async def fetch_weather(city):
    await asyncio.sleep(0.5)
    return f"Sunny in {city}"

asyncio.run(fetch_weather("Paris"))
Enter fullscreen mode Exit fullscreen mode

Common Decorator Mistakes to Avoid

Most decorator bugs come from a handful of slips: forgetting return result in the wrapper, forgetting return wrapper in the decorator, skipping functools.wraps, writing @timer when a factory needs @timer(), and putting work in the decorator body that should live inside the wrapper (it would run once at definition time instead of on every call).

Wrapping Up

Here's the one idea to remember: @decorator above a function means func = decorator(func), and @decorator(args) means func = decorator(args)(func). Everything else builds on functions being objects and closures remembering their surroundings. Starting from a plain fetch_weather, we added timing, flexible arguments, preserved metadata, configurable units, logging, call counting, caching, and async support, all without touching the function's own code. That's the power of Python decorators.

FAQ

What is a decorator in Python?

A decorator is a function that takes another function and returns a new one with extra behavior, applied using the @ syntax.

Why use functools.wraps in a decorator?

It copies the original function's name, docstring, and other metadata onto the wrapper, so debugging and documentation still work correctly.

How do I pass arguments to a Python decorator?

Add an outer function that accepts the arguments and returns the actual decorator, which is known as a decorator factory.

Top comments (0)