CoursePython · Functions, Composition, and Prompts as Code · part 16 of 79
Part 16 · Functions, Composition, and Prompts as Code

Lesson 3: Decorators

7 min read·9 Sept 2026

What a decorator is

decorator is a function that takes a function and returns a replacement for it. Everything in Lesson 2 was building to this: functions as values, closures, and *args/**kwargs are the three ingredients.

The syntax is a shorthand. These two are identical:

python
@timed
def clean_text(raw: str) -> str: ...

# means exactly:
def clean_text(raw: str) -> str: ...
clean_text = timed(clean_text)

The @ line runs timed on your function and rebinds the name to whatever comes back. That is the whole mechanism. Understanding this one line removes most of the mystery.

Building @timed from scratch

Start with what you want: a way to measure how long a function takes without editing the function.

python
import time
from collections.abc import Callable
from typing import Any


def timed(func: Callable[..., Any]) -> Callable[..., Any]:
    def wrapper(*args: Any, **kwargs: Any) -> Any:
        start = time.perf_counter()
        result = func(*args, **kwargs)
        duration_ms = (time.perf_counter() - start) * 1000
        print(f"{func.__name__} took {duration_ms:.1f}ms")
        return result
    return wrapper

Read it in the order it executes. timed receives the original function as func. It defines wrapper, a closure that has captured func. It returns wrapper, which now stands in for the original.

When someone calls the decorated function, they are calling wrapper. It records the time, calls the real function with whatever arguments it was given, records the time again, prints, and returns the original result unchanged.

Three details make this work for any function. *args, **kwargs in the wrapper signature means it accepts any arguments. Passing *args, **kwargs through to func forwards them unchanged. And returning result means the decorator is transparent to callers, who get exactly what they would have got without it.

Use it:

python
@timed
def clean_text(raw: str) -> str:
    return re.sub(r"<[^>]+>", "", raw).strip().lower()

clean_text("<p>Hello</p>")
# clean_text took 0.1ms

[IMAGE PROMPT M3-4
Purpose: Show what a decorator does mechanically, replacing a name with a wrapper that calls the original.
Visual type: Before-and-after mechanism diagram with a call-flow trace.
Prompt: A clean educational diagram in two stacked sections. The upper section, headed "Before decoration", shows a label "clean_text" with an arrow pointing to a single box labelled "original function", and a caller arrow entering from the left labelled "call". The lower section, headed "After decoration", shows the same label "clean_text" but its arrow now points to a larger box labelled "wrapper", which contains, in vertical order, three inner strips labelled "start timer", "call original", and "print duration, return result". Inside the "call original" strip, an arrow points out to a separate smaller box labelled "original function" with a return arrow coming back. A caller arrow enters the wrapper from the left labelled "call". A caption beneath the lower section reads "the name now points at the wrapper, the original is captured inside it".
Required elements: The same function name pointing at different targets in each section, the wrapper containing three ordered inner strips, an out-and-back arrow to the original function, caller arrows in both sections, the caption.
Style: Clean educational illustration, professional, uncluttered, high contrast, flat vector, clear nesting.
Layout: Two horizontal sections stacked vertically, each reading left to right, caption beneath the lower section.
Text labels: "Before decoration", "After decoration", "clean_text", "original function", "wrapper", "start timer", "call original", "print duration, return result", "call", "the name now points at the wrapper, the original is captured inside it".
Aspect ratio: 4:3
Accessibility: Convey the change through arrow targets and explicit labels rather than colour, and number nothing implicitly.
Avoid: Code screenshots, decorative icons, tiny text, logos, watermarks.
Alt text: Two-part diagram showing that before decoration a function name points directly at the original function, and after decoration the same name points at a wrapper that starts a timer, calls the captured original, prints the duration, and returns the result.
END IMAGE PROMPT]

functools.wraps and lost identity

There is a problem with the decorator above.

python
@timed
def clean_text(raw: str) -> str:
    """Remove HTML tags and normalise the text."""
    ...

print(clean_text.__name__)     # 'wrapper'
print(clean_text.__doc__)      # None
help(clean_text)               # shows wrapper's signature, not clean_text's

The name now refers to wrapper, so the original function's name, docstring, and signature are gone. This breaks debugging output, help(), documentation generators, and some testing tools. Worse, if you decorate several functions, every traceback and log line says wrapper, which is useless.

functools.wraps fixes it by copying the original function's identifying attributes onto the wrapper.

python
import functools


def timed(func: Callable[..., Any]) -> Callable[..., Any]:
    @functools.wraps(func)
    def wrapper(*args: Any, **kwargs: Any) -> Any:
        start = time.perf_counter()
        result = func(*args, **kwargs)
        print(f"{func.__name__} took {(time.perf_counter() - start) * 1000:.1f}ms")
        return result
    return wrapper

One line, and clean_text.__name__ is 'clean_text' again, with its docstring intact. functools.wraps also sets __wrapped__ on the wrapper, pointing at the original, so tools that need to inspect the real signature can find it.

Always use functools.wraps. There is no situation in which forgetting it is beneficial, and the symptoms of forgetting it appear far from the cause.

Building @log_call and @validate_input

@log_call records what a function was called with and what it returned. The interesting part is what you must not log.

python
import functools
import logging

logger = logging.getLogger(__name__)


def log_call(func: Callable[..., Any]) -> Callable[..., Any]:
    @functools.wraps(func)
    def wrapper(*args: Any, **kwargs: Any) -> Any:
        logger.debug("calling %s", func.__name__, extra={"kwargs": list(kwargs)})
        try:
            result = func(*args, **kwargs)
        except Exception:
            logger.exception("%s raised", func.__name__)
            raise
        logger.debug("%s returned %s", func.__name__, type(result).__name__)
        return result
    return wrapper

Two deliberate choices. It logs the names of the keyword arguments rather than their values, and the type of the result rather than the result itself. A decorator applied broadly will eventually wrap a function handling an API key, a password, or a user's personal data, and a decorator that logs values will write all of it to your logs. Logging shape rather than content is the safe default.

Note also the raise after logging the exception. The decorator observes the failure and does not swallow it, so callers still see the error.

@validate_input is a decorator that takes an argument, which requires one more layer.

python
def validate_input(validator: Callable[[Any], bool], message: str):
    """Build a decorator that checks the first argument before calling."""
    def decorator(func: Callable[..., Any]) -> Callable[..., Any]:
        @functools.wraps(func)
        def wrapper(*args: Any, **kwargs: Any) -> Any:
            if args and not validator(args[0]):
                raise ValueError(f"{func.__name__}: {message}")
            return func(*args, **kwargs)
        return wrapper
    return decorator


@validate_input(lambda text: isinstance(text, str), "expected a string")
def clean_text(raw: str) -> str: ...

Three nested functions, and the reason is visible in the syntax. @validate_input(...) is a call, so it must return a decorator, which then receives the function. The layers are: take the configuration, take the function, take the call arguments.

This is a closure factory from Lesson 2, one level deeper. If the nesting is confusing, read it bottom up: wrapper does the work, decorator supplies the function, validate_input supplies the configuration.

When not to write a decorator. Decorators are attractive and easy to overuse. They add a layer of indirection, they make signatures harder for type checkers to follow, and stacked decorators apply bottom up, which surprises people. Use one when a genuinely cross-cutting concern applies to many functions, such as timing, logging, retrying, caching, or authorisation. For logic specific to one function, put the logic in the function.

functools.lru_cache

lru_cache is a decorator from the standard library that remembers results. If a function is called again with the same arguments, the stored result is returned without running the function.

python
import functools


@functools.lru_cache(maxsize=1024)
def tokenize(text: str) -> tuple[str, ...]:
    return tuple(expensive_tokenizer(text))

LRU stands for least recently used, which is the eviction policy. When the cache reaches maxsize entries, the entry not used for longest is discarded.

When it is a genuine win. The function must be pure, expensive relative to a dictionary lookup, and called repeatedly with the same arguments. Tokenising the same text repeatedly, loading a configuration file, and computing something derived from a fixed input all qualify.

The conditions that make it wrong, which matter more.

The function must be pure. Caching an impure function means the side effect happens on the first call and silently never again. A cached function that writes to a database writes once.

Results must not go stale. @lru_cache has no expiry. If the underlying data can change, the cache will serve old answers forever. Use a cache with a time limit instead, which a later module covers.

Arguments must be hashable, so strings, numbers, tuples, and frozen dataclasses work, while lists, dicts, and sets do not. This is the same hashability rule from the previous module, and it is why the example above returns a tuple rather than a list.

Memory is not free. maxsize=None means unbounded, and a long-running process with varied inputs will grow until it fails. Always set a size unless the input space is genuinely small.

Checking whether it helps.

python
tokenize.cache_info()
# CacheInfo(hits=842, misses=158, maxsize=1024, currsize=158)
tokenize.cache_clear()

A hit rate near zero means the cache is pure overhead plus memory, and should be removed. Measure rather than assume.

Predict the output.

python
import functools

call_count = 0

@functools.lru_cache(maxsize=32)
def slow_double(n: int) -> int:
    global call_count
    call_count += 1
    return n * 2

print(slow_double(5))
print(slow_double(5))
print(slow_double(6))
print(call_count)

Answer

text
10
10
12
2

The second call with 5 is served from the cache, so the function body does not run and call_count is not incremented. Only two calls actually executed.

This example also demonstrates the danger. call_count += 1 is a side effect, and it silently stops happening once results are cached. If that side effect had been anything important, such as recording usage or charging for an API call, the behaviour would be wrong in a way that is very hard to spot.