Command Palette

Search for a command to run...

Hectal
PHASE 9Intermediate ~27 min· topic 6 of 9

Topic 9.6

Decorators

In one line

A decorator is a function that takes a function and returns a new one, usually a wrapper that adds behaviour before or after the original. The @name line above a def is shorthand for rebinding the name to the wrapped version.

Think of it like this

Gift wrapping. The present inside does not change, but the wrapping adds something around it: a ribbon, a card, a "fragile" sticker. A decorator wraps a function the same way. The function inside still does its job, and the wrapper adds logging, timing, caching or retrying around it.

Words you'll meet

New words in this topic, in plain English. Come back here whenever one feels fuzzy.

Decorator
A function that takes a function and returns a new function, usually one that wraps the original.
Wrapper
The inner function a decorator returns. It runs extra code around the original.
Closure
An inner function that remembers variables from the function that created it.
@ syntax
Shorthand: @d above def f means f = d(f).
*args, **kwargs
Catch-all parameters that collect every positional and keyword argument so the wrapper can pass them on.
functools.wraps
A helper that copies the original function's name, docstring and other details onto the wrapper.
Stacking
Putting more than one decorator on a function. The one closest to def applies first.
Backoff
Waiting longer after each failed attempt before trying again.

Step by step

01Build a decorator by hand, with no @

Start without the @ so nothing is hidden. log_calls takes a function and returns a wrapper. We then rebind the name by hand.

main.pywhole filepython
def log_calls(func):
    def wrapper(*args, **kwargs):
        print("calling", func.__name__)
        return func(*args, **kwargs)
    return wrapper

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

add = log_calls(add)      # exactly what @log_calls does
print(add(2, 3))

02The same thing with @

@log_calls above the def runs add = log_calls(add) right after the function is created. That is all. The decorator runs once, at definition time; the wrapper runs every call.

The same thing with @diagram
Rendering diagram…

03Pass arguments through and return the result

*args, **kwargs accepts whatever the caller sends and func(*args, **kwargs) forwards it unchanged. Then return the result. If you skip the return, the decorated function returns None no matter what the original does. The break-it section shows the error this causes.

04Always use functools.wraps

Add @functools.wraps(func) above the wrapper. Now add.__name__ is "add" and add.__doc__ is the original docstring. add.__wrapped__ gives the undecorated function, which is handy in tests: add.__wrapped__(1, 1) runs the original without the logging.

main.pywhole filepython
import functools

def log_calls(func):
    @functools.wraps(func)
    def wrapper(*args, **kwargs):
        print(f"calling {func.__name__}")
        return func(*args, **kwargs)
    return wrapper

05Stacking order

With @shout above @exclaim above def greet, Python builds greet = shout(exclaim(greet)). The call goes in through shout then exclaim to the original, and the result comes out through exclaim (adds !) then shout (upper-cases). Swap the two and the order of the effects changes.

Stacking orderdiagram
Rendering diagram…

06A retry decorator for a flaky API

Real model APIs fail now and then. A retry decorator tries again a few times and gives up (re-raises) if every attempt fails. A real one waits between attempts, with the wait growing each time (exponential backoff: 1 s, 2 s, 4 s, ...) using time.sleep. The example here skips the waiting and uses a fake function that fails twice and then succeeds, so it runs instantly and the same every time.

Retry only errors that are temporary (timeouts, connection resets, rate limits). Retrying a bug, such as a wrong password, just wastes time.

07Decorators that keep state, and what to avoid

A wrapper is an object, so it can have attributes: wrapper.calls += 1 counts calls and ping.calls reads the count afterwards. A timing decorator would call time.perf_counter() before and after; in real code you log the measurement, but in lessons and tests we print fixed text so the output is the same every time.

Avoid doing slow work or side effects when the decorator itself runs (at import time). Keep the decorator body light and put the real work inside the wrapper.

Try it yourself

  1. 1

    See what wraps does

    In the first example, remove the @functools.wraps(func) line from log_calls and run it. Compare add.__name__ and add.__doc__ before and after. Then put it back.

  2. 2

    Swap the stacking order

    Swap the lines @shout and @exclaim. Predict the new result of greet("ada") first. (The ! is now added before the upper-casing, which makes no difference here; try a decorator that adds "?" and one that reverses the string to see an order that does matter.)

  3. 3

    Make the API fail forever

    In the second example, change state["calls"] <= 2 to state["calls"] <= 5. After three failed attempts the decorator re-raises the error, and you see a traceback ending in ConnectionError: server busy.

Code & diagrams

Logging decorator with wraps, and stacking New tab

Delete @functools.wraps(func) and print add.__name__ again. It becomes 'wrapper'.

Sign in to run this example in your browser.

Expected output

calling add with (2,) {'b': 3}
add returned 5
5
add - Add two numbers.
original still reachable: 2
HELLO ADA!
Retry decorator and a call counter New tab

The fake API fails on its first two calls. Change `<= 2` to `<= 5` and watch the retry give up and re-raise the error.

Sign in to run this example in your browser.

Expected output

attempt 1 failed: server busy
attempt 2 failed: server busy
answer to 'What is a decorator?'
total calls: 3
ping was called 3 times
A realistic decorator: timing steps of a pipeline New tab

Timing is the most common decorator in data and AI code. Real code would read time.perf_counter() before and after. Here a fake clock (an iterator from Topic 9.1) gives fixed readings, so the output never changes. Try decorating a third function.

Sign in to run this example in your browser.

Expected output

load_data took 0.25 seconds
rows: [1, 2, 3]
train took 2.50 seconds
trained on 3 rows

Break it on purpose

Errors are the best teachers. Make each change, read the error, guess what went wrong, then reveal the answer.

Break #1

Forget to return the result in the wrapper

Call func(...) in the wrapper but do not return its value.

main.pywhole filepython
def log_calls(func):
    def wrapper(*args, **kwargs):
        print("calling", func.__name__)
        func(*args, **kwargs)
    return wrapper

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

total = add(2, 3)
print("total:", total)
print(total + 1)
terminal
$ python main.py
── what you'll see ──
calling add
total: None
Traceback (most recent call last):
File "main.py", line 13, in <module>
print(total + 1)
~~~~~~^~~
TypeError: unsupported operand type(s) for +: 'NoneType' and 'int'

Myth vs fact

Myth

A decorator runs every time you call the function.

Fact

The decorator itself runs once, when the def is executed. The wrapper it returns runs on every call.

Myth

@decorator changes the original function's code.

Fact

It does not. It makes a new function that calls the original, and rebinds the name to the new one.

Myth

functools.wraps is optional decoration.

Fact

Without it, names, docstrings and signatures are lost, which breaks logs, help(), framework registration and tools that inspect functions. Treat it as required.

Pro corner

Extra depth for experienced readers. New to this? Skip it for now and come back later.

  • ▸

    Decorators work because def is an executable statement that creates a function object and binds a name. @d just inserts a call to d between creation and binding. Any callable returning anything can be a decorator; returning a non-function (for example the registered original after recording it) is legal and common for registries.

  • ▸

    A decorator can wrap async def functions too, but the wrapper must itself be async def and await the call. A plain wrapper around an async function returns the coroutine immediately: the caller can still await it, but your "after" code (timing, logging) runs before the work has happened, and a try/except in the wrapper (a retry!) never sees the exception, which only appears later when the caller awaits. inspect.iscoroutinefunction also reports False for it. The same trap applies to generator functions: a timing wrapper around a generator function measures only the creation of the generator object, not the iteration (Phase 12 covers async).

  • ▸

    functools.wraps copies __module__, __name__, __qualname__, __doc__, __annotations__ and updates __dict__; it sets __wrapped__, which inspect.signature follows, so the wrapper reports the original's signature. Type checkers need ParamSpec to keep the signature of a decorated function precise (Phase 10).

  • ▸

    In production, a retry decorator should add jitter (a small random delay) to the backoff so that many clients do not retry at the same moment, respect a maximum wait, and retry only errors that are known to be temporary. Libraries such as tenacity provide this; the hand-written version teaches the idea.

Remember this

  1. 1

    In Python, functions are values: you can pass them as arguments, return them from functions, and store them in variables (Topic 5.6). A function defined inside another function is a closure that remembers the outer variables (Topic 5.7). A decorator uses both facts: it takes a function, defines a wrapper inside that remembers the function, and returns the wrapper.

  2. 2

    The @ syntax is only a shorthand. Writing @log_calls above def add(...) means exactly: add = log_calls(add). After the line runs, the name add points at the wrapper, not the original. The original lives on inside the wrapper's closure. Decorators run once, when the def is executed, not each time the function is called.

  3. 3

    A wrapper must accept any arguments and pass them on, so it works for any function: def wrapper(*args, **kwargs): (the stars collect any arguments, Topic 5.3) then result = func(*args, **kwargs), and then return the result. Forgetting the return is the classic mistake: the decorated function then always returns None.

  4. 4

    Use functools.wraps(func) on the wrapper (Topic 9.5). Without it, the decorated function reports the wrapper's name and docstring: add.__name__ becomes "wrapper", which makes logs and help() confusing and breaks tools that read names and docs. (A traceback still shows the frame as wrapper, because wraps changes the function object, not the code inside it.) wraps also sets add.__wrapped__, a pointer to the original for introspection and testing.

  5. 5

    Stacking. Several decorators apply from the bottom up: the one nearest the def wraps first. @a above @b above def f means f = a(b(f)). When called, the outermost wrapper (a) runs first on the way in and last on the way out. Order matters whenever decorators do different things, such as caching and logging.

  6. 6

    Typical decorators: logging (record calls), timing (measure a function; real code prints or records the measured time), retry (call again after a temporary failure), caching (Topic 9.5) and access checks (refuse to run unless the user is allowed). In AI code, a retry decorator with a short pause between attempts wraps calls to model APIs, which fail from time to time with timeouts and rate limits. Web frameworks such as FastAPI (Topic 15.5) use decorators to attach a function to a URL.

Explain it without notes

01

What is a decorator, and what does @decorator above a function mean exactly?

02

Why does a decorator's wrapper use *args, **kwargs, and what happens if it forgets to return the result?

03

What does functools.wraps do and why should every wrapper use it?

04

In what order do stacked decorators apply and run?

05

Describe a retry decorator for a flaky API call. What should it retry, and what should it not?

Practice

01

Write a decorator shout that upper-cases the string returned by a function. Decorate def greet(name) and print greet("ada").

02

Write a decorator log_result that prints name -> result using the function name and the returned value, and still returns the value. Apply it to a function that multiplies two numbers.

03

Write a decorator count_calls that stores the number of calls in wrapper.calls. Call the decorated function twice and print the count.

Trade-offs

  • ↔

    Reuse versus hidden behaviour. A decorator adds behaviour in one place for many functions, but a reader of def f may not notice what the decorator above it does. Keep decorators few, well-named and documented.

  • ↔

    Wrapper overhead. Every call goes through an extra function, which costs a small amount of time and adds a line to every traceback. It is negligible for network calls and noticeable only in very hot loops.

Done when you can

  • Done when you can explain that @d above def f means f = d(f).

  • Done when you can write a wrapper that forwards *args, **kwargs and returns the result.

  • Done when you can use functools.wraps and say what it fixes.

  • Done when you can work out the order effects of two stacked decorators.

  • Done when you can write a retry decorator and say which errors it should retry.