Article

Python Decorators Explained: From Basics to Advanced Patterns

by Gary Worthington, More Than Monkeys

Decorators are one of Python’s most elegant features. They allow you to wrap functions or classes with additional behaviour without changing their core logic.

Whe used well, decorators keep your code clean, modular, and DRY. Used badly, they can turn your codebase into a rabbit warren of hidden behaviour.

In this article we’ll work from the basics through to more complex examples, covering:

  • How decorators work and what they actually are
  • The call flow when a decorated function runs
  • Writing your own simple decorators
  • Decorators with arguments
  • Chaining multiple decorators
  • Class-based decorators
  • Async decorators
  • Context-manager-style decorators
  • Real-world examples

1. What is a decorator?

At its core, a decorator is just a function that takes another function (or class) as an argument and returns a new function (or class) that adds extra behaviour.

In Python, functions are first-class citizens, meaning you can pass them around like variables.

A minimal decorator looks like this:

def my_decorator(func):
def wrapper(*args, **kwargs):
print("Before the function runs")
result = func(*args, **kwargs)
print("After the function runs")
return result
return wrapper

To use it, you apply it with the @ syntax:

@my_decorator
def greet(name):
print(f"Hello, {name}!")

greet("Gary")

Output:

Before the function runs
Hello, Gary!
After the function runs

2. Call flow explained

It’s important to understand what actually happens when Python sees @my_decorator.

@my_decorator
def greet(name):
...

…is equivalent to:

def greet(name):
...
greet = my_decorator(greet)

So the original greet function is passed into my_decorator, which returns the wrapper function. That wrapper becomes the new greet.

The actual call flow is:

  1. Python defines greet normally.
  2. The decorator function is called, with greet passed in.
  3. The decorator returns a new function (the wrapper).
  4. That wrapper is now what gets called whenever you run greet(...).

3. Preserving function metadata

One gotcha with basic decorators is that they replace the original function’s __name__, __doc__, and other metadata.

print(greet.__name__)  # wrapper

To fix that, Python provides functools.wraps:

from functools import wraps

def my_decorator(func):
@wraps(func)
def wrapper(*args, **kwargs):
print("Before the function runs")
result = func(*args, **kwargs)
print("After the function runs")
return result
return wrapper

print(greet.__name__) # greet

4. Decorators with arguments

So far, our decorators have taken no parameters. They simply wrapped the target function.
But sometimes you want your decorator to accept its own arguments.

To make this work, you add one more layer of function nesting.

def repeat(times):
def decorator(func):
def wrapper(*args, **kwargs):
for _ in range(times):
func(*args, **kwargs)
return wrapper
return decorator

Here’s how we’d use it:

@repeat(3)
def say_hello():
print("Hello!")

say_hello()

Output:

Hello!
Hello!
Hello!

What’s going on

  1. @repeat(3) is run immediately when Python sees it.
    It returns the decorator function, with times=3 stored in a closure.
  2. The decorator function then takes the target function (say_hello) and returns the wrapper.
  3. Every time you call say_hello(), you’re actually calling the wrapper, which runs the original function times times.

(Tip: If you want to preserve the original function’s name and docstring, use functools.wraps as shown in section 3.)

5. Chaining multiple decorators

You can stack decorators by applying more than one @:

from functools import wraps

def uppercase(func):
@wraps(func)
def wrapper(*args, **kwargs):
result = func(*args, **kwargs)
return result.upper()
return wrapper

@repeat(2)
@uppercase
def greet(name):
return f"Hello, {name}"

print(greet("Gary"))

Order matters:

  • @uppercase wraps greet.
  • @repeat(2) wraps the result of that.

6. Logging and timing decorators

Here’s a decorator for measuring execution time:

import time
from functools import wraps

def timing(func):
@wraps(func)
def wrapper(*args, **kwargs):
start = time.perf_counter()
result = func(*args, **kwargs)
end = time.perf_counter()
print(f"{func.__name__} took {end - start:.4f} seconds")
return result
return wrapper

7. Class-based decorators

Up to this point, our decorators have been functions.
However, decorators can also be classes, and that’s useful when you need to maintain state across multiple calls.

The key idea is that the class’s __init__ method runs when the function is decorated, and its __call__ method runs whenever the function is called.

If the class implements __call__, Python treats the instance like a function.

Here’s an example that counts how many times the decorated function has been called:

from functools import wraps

class CallCounter:
def __init__(self, func):
# Store the function we're wrapping
wraps(func)(self)
self.func = func
self.count = 0

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

@CallCounter
def greet(name):
print(f"Hello, {name}!")

greet("Gary")
greet("Alex")
greet("Sam")

Output:

Call 1 to greet
Hello, Gary!
Call 2 to greet
Hello, Alex!
Call 3 to greet
Hello, Sam!

Why use a class-based decorator?

A function-based decorator is great for most cases.
But if you want to store and reuse data between calls, such as counters, cached results, open connections, or expensive initialisation, then a class makes it cleaner and avoids using mutable defaults or global variables.

You can also give your decorator extra methods or properties:

print(greet.count)  # 3

This opens the door to decorators that behave like objects and like functions, giving you flexibility you can’t easily get with plain functions.

8. Async decorators

When working with asynchronous code, your decorator must also be async to avoid blocking.

import asyncio
from functools import wraps

def async_timing(func):
@wraps(func)
async def wrapper(*args, **kwargs):
start = asyncio.get_event_loop().time()
result = await func(*args, **kwargs)
end = asyncio.get_event_loop().time()
print(f"{func.__name__} took {end - start:.4f} seconds")
return result
return wrapper

@async_timing
async def slow_async_function():
await asyncio.sleep(1)

asyncio.run(slow_async_function())

9. Context-manager-style decorators

Sometimes you want behaviour that begins before the function runs and cleans up afterwards, even if an exception occurs. This is where contextlib.ContextDecorator comes in.

from contextlib import ContextDecorator
import time

class timed(ContextDecorator):
def __enter__(self):
self.start = time.perf_counter()
return self
def __exit__(self, *exc):
end = time.perf_counter()
print(f"Elapsed: {end - self.start:.4f} seconds")
return False

@timed()
def do_work():
time.sleep(1)

# Works as both a decorator and a context manager

do_work()
with timed():
time.sleep(0.5)

10. Real-world advanced example: authentication

from functools import wraps

def require_auth(func):
@wraps(func)
def wrapper(user, *args, **kwargs):
if not user.get("is_authenticated"):
raise PermissionError("User not authenticated")
return func(user, *args, **kwargs)
return wrapper

@require_auth
def view_profile(user):
return f"Profile for {user['name']}"

11. When not to use decorators

Decorators can be overused. If the logic is highly specific to one function, adding a decorator may obscure what’s going on. They work best when you have a pattern that applies across many functions (eg logging, caching, authorisation, retries), not for one-off behaviour.

Conclusion

Decorators let you wrap functionality in a reusable, declarative way. From simple wrappers to async-safe implementations, class-based decorators, and context-managed behaviour, they can keep your code neat and DRY.

The key is understanding the call flow. Once you see that a decorator just takes a function and returns a new one, the magic fades and you can use them with confidence.

Gary Worthington is a software engineer, delivery consultant, and agile coach who helps teams move fast, learn faster, and scale when it matters. He writes about modern engineering, product thinking, and helping teams ship things that matter.

Through his consultancy, More Than Monkeys, Gary helps startups and scaleups improve how they build software — from tech strategy and agile delivery to product validation and team development.

Visit morethanmonkeys.co.uk to learn how we can help you build better, faster.

Follow Gary on LinkedIn for practical insights into engineering leadership, agile delivery, and team performance