Learn Python / Decorators

Decorators

You've already seen a few lines that start with @, like @lru_cache and @dataclass. These are decorators: a way to add extra behaviour to a function without changing the function's own code. Timing, logging, caching and checking permissions are all commonly done with decorators.

Decorators build on two ideas from Part 3: functions are values you can pass around, and inner functions can remember variables from the function around them.

Step 1: functions can be passed around

A function name without () is the function itself, and you can pass it to another function:

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

def call_twice(func, value):
    print(func(value))
    print(func(value))

call_twice(shout, "hello")
Output

Step 2: functions can create functions

A function can define a new function inside itself and return it:

def make_greeter(greeting):
    def greet(name):
        return f"{greeting}, {name}!"
    return greet

say_hi = make_greeter("Hi")
say_hello = make_greeter("Hello")

print(say_hi("Ada"))
print(say_hello("Grace"))
Output

Step 3: wrapping a function

Put those together: a function that takes a function, builds a new function around it, and returns the new one. The new function, usually called wrapper, does something extra and calls the original:

def announce(func):
    def wrapper():
        print("About to run...")
        func()
        print("Done!")
    return wrapper

def say_hello():
    print("Hello!")

say_hello = announce(say_hello)
say_hello()
Output

announce is a decorator. The line say_hello = announce(say_hello) replaces say_hello with the wrapped version.

The @ syntax

Writing say_hello = announce(say_hello) is clumsy, so Python has a shortcut. Put @announce on the line above the function:

def announce(func):
    def wrapper():
        print("About to run...")
        func()
        print("Done!")
    return wrapper

@announce
def say_hello():
    print("Hello!")

say_hello()
Output

@announce above def say_hello means exactly the same as say_hello = announce(say_hello) after it.

Decorating functions with arguments

Our wrapper takes no arguments, so it only works on functions without any. To wrap any function, let the wrapper accept any arguments with *args and **kwargs (from the Function Arguments lesson) and pass them on. Also return the original function's result:

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

@log_calls
def add(a, b):
    return a + b

@log_calls
def greet(name, punctuation="!"):
    return "Hi " + name + punctuation

add(2, 3)
greet("Ada", punctuation="?")
Output

This shape works as a template for almost every decorator you'll write:

def my_decorator(func):
    def wrapper(*args, **kwargs):
        # do something before
        result = func(*args, **kwargs)
        # do something after
        return result
    return wrapper

A practical decorator: timing

Here's a decorator that measures how long a function takes:

import time

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

@timed
def total_of_squares(n):
    return sum(i * i for i in range(n))

print(total_of_squares(1_000_000))
Output

Once written, you can add @timed to any function you want to measure, without touching its code.

Keeping the function's name with functools.wraps

A plain wrapper hides the original function's name and docstring:

def announce(func):
    def wrapper(*args, **kwargs):
        return func(*args, **kwargs)
    return wrapper

@announce
def say_hello():
    """Print a greeting."""
    print("Hello!")

print(say_hello.__name__)
print(say_hello.__doc__)
Output

Add @functools.wraps(func) to the wrapper to copy them across. It's good practice in every decorator:

import functools

def announce(func):
    @functools.wraps(func)
    def wrapper(*args, **kwargs):
        return func(*args, **kwargs)
    return wrapper

@announce
def say_hello():
    """Print a greeting."""
    print("Hello!")

print(say_hello.__name__)
print(say_hello.__doc__)
Output

Decorators you'll meet

Python and its libraries come with many ready-made decorators:

Decorator What it does
@functools.lru_cache remembers results so repeated calls are instant
@dataclasses.dataclass writes __init__, __repr__ and __eq__ for a class
@staticmethod / @classmethod methods that don't need an object
@property lets a method be read like an attribute

@property is a nice one to know:

class Circle:
    def __init__(self, radius):
        self.radius = radius

    @property
    def area(self):
        return round(3.14159 * self.radius ** 2, 2)

c = Circle(3)
print(c.area)      # no () needed
Output
Going deeper: Decorators that take arguments optional

Sometimes you want to configure a decorator, as in @repeat(3). That needs one more layer: a function that takes the settings and returns a decorator:

import functools

def repeat(times):
    def decorator(func):
        @functools.wraps(func)
        def wrapper(*args, **kwargs):
            for _ in range(times):
                result = func(*args, **kwargs)
            return result
        return wrapper
    return decorator

@repeat(3)
def cheer():
    print("Hip hip hooray!")

cheer()
Output

@repeat(3) first calls repeat(3), which returns decorator. Then decorator wraps cheer.

Going deeper: Stacking decorators optional

You can put several decorators on one function. They're applied from the bottom up, so the one closest to the function wraps it first:

def bold(func):
    def wrapper():
        return "<b>" + func() + "</b>"
    return wrapper

def italic(func):
    def wrapper():
        return "<i>" + func() + "</i>"
    return wrapper

@bold
@italic
def hello():
    return "hello"

print(hello())
Output

Exercises

Exercise 1: Before and after

Write a decorator sandwich that prints --- before and after calling the function. Calling say_hi() should print three lines: ---, Hi! and ---.

def sandwich(func):
    pass

@sandwich
def say_hi():
    print("Hi!")

say_hi()
Output

Exercise 2: Shout the result

Write a decorator uppercase that calls the function and returns its result in capitals. It should work with any arguments.

def uppercase(func):
    pass

@uppercase
def greet(name):
    return f"hello, {name}"

print(greet("ada"))   # HELLO, ADA
Output

Exercise 3: Count the calls

Write a decorator count_calls that counts how many times the function has been called and prints Call number N each time. You can store the count as an attribute on the wrapper, like wrapper.calls = 0.

def count_calls(func):
    pass

@count_calls
def ping():
    print("ping")

ping()
ping()
ping()
Output

Exercise 4: Only positive numbers

Write a decorator positive_only that raises a ValueError if any argument is a negative number, and otherwise calls the function normally.

def positive_only(func):
    pass

@positive_only
def area(width, height):
    return width * height

print(area(3, 4))

try:
    area(3, -4)
except ValueError as e:
    print("Error:", e)
Output

Summary

  • A decorator is a function that takes a function and returns a new, wrapped function.
  • @decorator above a def is a shortcut for func = decorator(func).
  • Use *args and **kwargs in the wrapper so it works with any arguments, and return the result.
  • Add @functools.wraps(func) to keep the original name and docstring.
  • Decorators add behaviour like timing, logging or caching without changing the function itself.
  • Built-in decorators include @property, @staticmethod, @classmethod and @functools.lru_cache.