Python Decorators

Tutorial 38 of 65 · pythondeck.com Python course

A decorator is a callable that takes a function (or class) and returns a replacement. They are applied with @deco just above the definition. Use functools.wraps to preserve metadata.

Decorators wrap callables to add behaviour—logging, timing, access control, registration—without duplicating boilerplate in every function. Syntax @decorator applies the decorator to the function defined below. Decorators are functions (or classes with __call__) that take a callable and return a callable.

Understanding closures and functools.wraps keeps metadata (__name__, docstrings) intact. Parametrised decorators are decorator factories: a function that returns a decorator.

Functions as first-class objects passed to other functions.

Simple decorator pattern: def deco(fn): ... return wrapper; @deco.

functools.wraps to preserve wrapped function metadata.

Decorator factories: @retry(times=3) via nested functions.

Class-based decorators implementing __init__ and __call__.

Built-ins: @property, @staticmethod, @classmethod, @dataclass.

Stacked decorators apply bottom-up: the nearest function is passed to the innermost decorator first. Order matters for registration decorators (Flask routes) and caching.

Use functools.lru_cache for memoisation; write custom decorators when cross-cutting concerns (auth, metrics) need shared logic. Avoid heavy work at import time inside decorators.

contextlib.contextmanager is related: decorator-like setup/teardown for resources. For async, async def wrappers must await the inner coroutine correctly.

Class decorators such as @dataclass transform the class object itself at definition time—distinct from function wrappers but governed by the same "callable that returns callable" idea.

Forgetting @functools.wraps, breaking introspection and docs.

Decorator factories missing an extra call layer (@log vs @log()).

Mutating function arguments in wrappers without documenting side effects.

Applying caching decorators to functions with unhashable or mutable arguments.

Recursive decoration changing behaviour in ways stack traces do not reveal.

Always use wraps on manual decorator wrappers.

Keep decorators thin; push heavy logic into named helper functions.

Type-annotate decorator factories for mypy with ParamSpec and TypeVar when needed.

Test decorated functions as well as the wrapper's edge cases.

Re-read the examples below with these ideas in mind; change variable names and inputs to match your own project.

The program below demonstrates timing decorator. Read the comments on each line, run the code, then change names or values to see how the output shifts.

# Example: Timing decorator
# Run in the REPL or save as a .py file and execute with python.
import time, functools
def timeit(fn):
    @functools.wraps(fn)
    def wrapper(*a, **kw):
        t0 = time.perf_counter()
        result = fn(*a, **kw)
        print(f"{fn.__name__} took {time.perf_counter()-t0:.4f}s")
        return result
    return wrapper

@timeit
def work(n):
    return sum(i*i for i in range(n))

work(200_000)

This sample walks through with arguments in a small, runnable script. Paste it into the REPL or save it as a .py file before you continue to the next block.

# Example: With arguments
# Run in the REPL or save as a .py file and execute with python.
def repeat(times):
    def deco(fn):
        def wrap(*a, **kw):
            for _ in range(times):
                fn(*a, **kw)
        return wrap
    return deco

@repeat(3)
def hi():
    print("hi")

hi()

Here is a hands-on illustration of built-ins. Follow the inline comments first; only then execute the snippet and compare the result with what you expected.

# Example: Built-ins
# Run in the REPL or save as a .py file and execute with python.
from functools import lru_cache
@lru_cache(maxsize=None)
def fib(n):
    return n if n < 2 else fib(n-1) + fib(n-2)

print(fib(100))

The program below demonstrates timing decorator. Read the comments on each line, run the code, then change names or values to see how the output shifts.

# Decorators wrap functions to add cross-cutting behavior
import time, functools  # time + preserves metadata

def timer(fn):  # simple decorator
    @functools.wraps(fn)  # keep __name__/docstring
    def wrap(*a, **kw):  # replacement callable
        t0 = time.perf_counter()  # high-resolution clock
        out = fn(*a, **kw)  # call original
        print(f"{fn.__name__} took {time.perf_counter()-t0:.4f}s")  # log
        return out  # forward result
    return wrap  # return wrapper

@timer  # apply decorator
def work():  # sample function
    sum(range(100_000))  # CPU work

work()  # prints timing

This sample walks through repeat decorator in a small, runnable script. Paste it into the REPL or save it as a .py file before you continue to the next block.

# Parameterized decorators need an extra factory layer
import functools  # wraps helper

def repeat(times):  # decorator factory
    def deco(fn):  # actual decorator
        @functools.wraps(fn)
        def wrap(*a, **kw):
            last = None  # store final return
            for _ in range(times):  # call multiple times
                last = fn(*a, **kw)  # capture result
            return last  # return last run
        return wrap
    return deco

@repeat(3)  # configure factory
def hello():  # no-op body
    print("hi")  # side effect

hello()  # prints hi three times

« Python Delete Files All tutorials Python Generators »