Decorators

Python · Lesson 41 of 56 6 min read Updated

You have already used decorators: @property, @classmethod and @dataclass all sit on the line above a function or class and change how it behaves. A decorator is a function that takes another function and returns an improved version of it, without you editing the original code.

Decorators are perfect for jobs that many functions need: logging, timing, checking permissions, retrying or caching results. In this lesson you will build your own, one step at a time.

Functions are values

Decorators rely on two facts you met in earlier lessons. First, a function is an object: you can store it in a variable or pass it to another function. Second, a function can define and return an inner function that remembers its surroundings, which is a closure (see Scope & Closures).

PythonRun it yourself
def shout(text):
    return text.upper() + "!"

speak = shout                 # no brackets: the function itself
print(speak("jai hind"))

def apply_twice(func, value):
    return func(func(value))

print(apply_twice(shout, "chalo"))
Output
JAI HIND!
CHALO!!

Writing a decorator by hand

A decorator takes a function, defines a wrapper function that does something extra around the original, and returns the wrapper.

PythonRun it yourself
def announce(func):
    def wrapper():
        print("Before the call")
        func()
        print("After the call")
    return wrapper

def open_shop():
    print("Shop is open")

open_shop = announce(open_shop)
open_shop()
Output
Before the call
Shop is open
After the call

The line open_shop = announce(open_shop) replaces the original function with the wrapper. When you call open_shop(), you are really calling wrapper(), which calls the original in the middle.

The @ syntax

Writing the function name three times is clumsy, so Python offers a shortcut. Putting @announce above the definition means exactly the same as open_shop = announce(open_shop) after it.

A real decorator should also work with any function, whatever arguments it takes, and should pass back its return value. Use *args and **kwargs (see Function Arguments) to accept and forward everything:

PythonRun it yourself
import functools

def log_calls(func):
    @functools.wraps(func)
    def wrapper(*args, **kwargs):
        print(f"Calling {func.__name__} with {args} {kwargs}")
        result = func(*args, **kwargs)
        print(f"{func.__name__} returned {result!r}")
        return result
    return wrapper

@log_calls
def add_gst(price, rate=18):
    """Return the price including GST."""
    return round(price * (100 + rate) / 100, 2)

total = add_gst(1299, rate=5)
print(total)
print(add_gst.__name__, "-", add_gst.__doc__)
Output
Calling add_gst with (1299,) {'rate': 5}
add_gst returned 1363.95
1363.95
add_gst - Return the price including GST.

@functools.wraps(func) copies the original function's name, docstring and other details onto the wrapper. Without it, add_gst.__name__ would say wrapper, which makes error messages and documentation confusing. Always use it.

A practical example: checking arguments

Decorators keep repeated checks out of your main logic. Here one decorator protects several money functions from negative or zero amounts:

PythonRun it yourself
import functools

def positive_amount(func):
    @functools.wraps(func)
    def wrapper(amount, *args, **kwargs):
        if amount <= 0:
            raise ValueError(f"{func.__name__}: amount must be positive, got {amount}")
        return func(amount, *args, **kwargs)
    return wrapper

@positive_amount
def deposit(amount):
    return f"Deposited ₹{amount}"

@positive_amount
def send_upi(amount, to):
    return f"Sent ₹{amount} to {to}"

print(deposit(500))
print(send_upi(250, "meera@okbank"))
try:
    send_upi(-40, "rohan@okbank")
except ValueError as error:
    print("Error:", error)
Output
Deposited ₹500
Sent ₹250 to meera@okbank
Error: send_upi: amount must be positive, got -40

Decorators with arguments

Sometimes you want to configure the decorator, as in @retry(times=3). That needs one more level: retry(times=3) is called first and must return the actual decorator. So you write a function that returns a decorator that returns a wrapper.

PythonRun it yourself
import functools

def retry(times):
    def decorator(func):
        @functools.wraps(func)
        def wrapper(*args, **kwargs):
            for attempt in range(1, times + 1):
                try:
                    return func(*args, **kwargs)
                except ConnectionError as error:
                    print(f"Attempt {attempt} failed: {error}")
            raise ConnectionError(f"Gave up after {times} attempts")
        return wrapper
    return decorator

replies = iter(["timeout", "timeout", "OK"])

@retry(times=3)
def fetch_result():
    reply = next(replies)
    if reply != "OK":
        raise ConnectionError(reply)
    return "Result: Pass, 78%"

print(fetch_result())
Output
Attempt 1 failed: timeout
Attempt 2 failed: timeout
Result: Pass, 78%

The fake replies iterator makes the first two calls fail, the way a busy exam-results server might, and the third succeeds. The decorator hides all the retry logic, so fetch_result() stays short and clear.

Built-in decorators: caching with functools.cache

The standard library includes many ready-made decorators. @functools.cache remembers the result for each set of arguments, so repeated calls with the same arguments return instantly instead of recalculating. This is called memoisation.

PythonRun it yourself
import functools

calls = 0

@functools.cache
def ways_to_climb(steps):
    """Number of ways to climb stairs taking 1 or 2 steps at a time."""
    global calls
    calls += 1
    if steps <= 1:
        return 1
    return ways_to_climb(steps - 1) + ways_to_climb(steps - 2)

print(ways_to_climb(40))
print("Function body ran", calls, "times")
print(ways_to_climb.cache_info())
Output
165580141
Function body ran 41 times
CacheInfo(hits=38, misses=41, maxsize=None, currsize=41)

Without the cache, this recursive function would run its body hundreds of millions of times for 40 steps. With it, each step count is calculated once. Use @functools.lru_cache(maxsize=256) when you want to limit how many results are stored. Only cache functions whose result depends purely on their arguments.

Common mistakes

  • Forgetting to return the wrapper. If the decorator has no return wrapper, the decorated name becomes None, and calling it raises TypeError: 'NoneType' object is not callable.
  • Forgetting to return the result. A wrapper that calls func(*args, **kwargs) without return makes every decorated function return None.
  • Leaving out functools.wraps. The function loses its name and docstring, which confuses debugging and help tools.
  • Writing @retry instead of @retry(times=3). A decorator that takes arguments must be called, with brackets.

Key points

  • A decorator takes a function and returns a new function, usually a wrapper that adds behaviour around the original.
  • @decorator above a def means func = decorator(func).
  • Wrappers should accept *args, **kwargs, pass them on, and return the original result.
  • Always decorate the wrapper with @functools.wraps(func).
  • Decorators with arguments need three levels: a factory, the decorator and the wrapper.
  • The standard library includes @functools.cache, @functools.lru_cache, @property, @classmethod and @dataclass.

Exercise

Write a decorator count_calls that counts how many times a function has been called and stores the count in an attribute wrapper.calls. Use functools.wraps. Decorate a function book_seat(name) that returns "Seat booked for <name>", call it for Aarav, Priya and Kabir, and then print the count and the function's __name__.

Show solution
Python
import functools

def count_calls(func):
    @functools.wraps(func)
    def wrapper(*args, **kwargs):
        wrapper.calls += 1
        return func(*args, **kwargs)
    wrapper.calls = 0
    return wrapper

@count_calls
def book_seat(name):
    return f"Seat booked for {name}"

for person in ["Aarav", "Priya", "Kabir"]:
    print(book_seat(person))

print(book_seat.calls, book_seat.__name__)
Output
Seat booked for Aarav
Seat booked for Priya
Seat booked for Kabir
3 book_seat

Functions are objects, so the wrapper can carry its own attribute, calls. Thanks to functools.wraps, the decorated function still reports its real name.

Quick Quiz

1. What does @timer above def report(): mean?

2. Why do wrappers use *args, **kwargs?

3. What does functools.wraps(func) do?

4. What does @functools.cache do?

Learn Python with a mentor

Free lessons get you started. Our Python Programming training adds real projects and code reviews from working developers.

  • Project-based learning
  • Mentor code reviews
  • Free counselling call