Skip to content
Laviyx Learn

Try

Python course menu

Python Decorators Explained Step by Step (with functools.wraps)

Learn Python decorators from the ground up: functions as objects, wrapper functions, the @ syntax, *args and **kwargs in wrappers, functools.wraps, decorators with arguments, and practical timing, logging, caching and validation decorators.

Advanced10 min readUpdated
On this page

A decorator wraps a function to add behaviour before or after it runs, without changing the function's own code. Timing, logging, caching, permission checks and input validation are all commonly done with decorators. Web frameworks like Flask (@app.route) and testing tools (@pytest.fixture) use them heavily.

Step 1: functions are objects#

Functions can be stored in variables and passed to other functions:

Python
def say(text):
    return text

f = say
print(f("functions are objects"), say.__name__, f.__name__)

def apply(fn, value):
    return fn(value)

print(apply(len, "hello"), apply(str.upper, "hi"))
Output
functions are objects say say
5 HI

Step 2: a function that wraps another#

A decorator is a function that takes a function and returns a new one, usually an inner wrapper that calls the original:

Python
def shout(func):
    def wrapper(*args, **kwargs):
        return func(*args, **kwargs).upper()
    return wrapper

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

greet = shout(greet)       # replace greet with the wrapped version
print(greet("Ravi"))
Output
HELLO, RAVI

wrapper accepts *args, **kwargs so it can pass along whatever arguments the original function takes (see Parameters). It remembers func thanks to a closure (see Scope).

Step 3: the @ syntax#

@decorator above a function definition is shorthand for func = decorator(func):

Python
def shout(func):
    def wrapper(*args, **kwargs):
        result = func(*args, **kwargs)
        return result.upper() + "!"
    return wrapper

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

print(greet("Asha"))
Output
HELLO, ASHA!

Always use functools.wraps#

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

Python
def no_wraps(func):
    def wrapper(*a, **k):
        return func(*a, **k)
    return wrapper

@no_wraps
def report():
    """Build the report."""

print(report.__name__, report.__doc__)
Output
wrapper None

@functools.wraps(func) copies them over, so debugging tools, error messages and documentation still show the real function. Use it in every decorator.

A timing decorator#

Python
import time
from functools import wraps

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

@timer
def slow_sum(n):
    time.sleep(0.1)
    return sum(range(n))

print(slow_sum(1000))
print(slow_sum.__name__)
Output
slow_sum took 0.101s
499500
slow_sum

(The exact time varies slightly on each run.) Note that the wrapper returns the result; forgetting that is a classic bug.

A logging decorator#

Python
def log_calls(func):
    @wraps(func)
    def wrapper(*args, **kwargs):
        print(f"calling {func.__name__}{args} {kwargs or ''}".rstrip())
        result = func(*args, **kwargs)
        print(f"{func.__name__} returned {result!r}")
        return result
    return wrapper

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

add(2, 3)
add(10, b=5)
Output
calling add(2, 3)
add returned 5
calling add(10,) {'b': 5}
add returned 15

Decorators with arguments#

To pass options to a decorator, add one more layer: a function that takes the options and returns a decorator:

Python
def repeat(times):
    def decorator(func):
        @wraps(func)
        def wrapper(*args, **kwargs):
            return [func(*args, **kwargs) for _ in range(times)]
        return wrapper
    return decorator

@repeat(3)
def roll():
    return "🎲"

print(roll())
Output
['🎲', '🎲', '🎲']

@repeat(3) first calls repeat(3), which returns the real decorator.

Built-in decorators you will meet#

  • @functools.lru_cache: remembers results of previous calls (see Recursion).
  • @property, @classmethod, @staticmethod: used in classes (see Encapsulation).
  • @dataclass: generates class boilerplate (see Constructors).
Python
from functools import lru_cache
import time

@lru_cache(maxsize=None)
def slow_square(n):
    time.sleep(0.2)
    return n * n

t = time.perf_counter(); slow_square(9); first = time.perf_counter() - t
t = time.perf_counter(); slow_square(9); second = time.perf_counter() - t
print(first > 0.15, second < 0.01, slow_square.cache_info().hits)
Output
True True 1

The second call returned instantly from the cache.

Common mistakes#

Practice#

Practice exercise

Write a decorator require_positive that raises ValueError("<function name>: arguments must be positive") if any positional argument is zero or negative. Apply it to area(w, h) and call it with (3, 4) and (3, -1).

Show hint

In the wrapper, check any(a <= 0 for a in args) before calling func. Use func.__name__ in the message.

Show solution
Python
from functools import wraps

def require_positive(func):
    @wraps(func)
    def wrapper(*args):
        if any(a <= 0 for a in args):
            raise ValueError(f"{func.__name__}: arguments must be positive")
        return func(*args)
    return wrapper

@require_positive
def area(w, h):
    return w * h

print(area(3, 4))
try:
    area(3, -1)
except ValueError as e:
    print(e)
Output
12
area: arguments must be positive

The validation is written once and can be reused on any function.

Key takeaway#

Finished this lesson?