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:
@dabovedef fmeansf = 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
defapplies 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.
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.
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.
import functools
def log_calls(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
print(f"calling {func.__name__}")
return func(*args, **kwargs)
return wrapper05Stacking 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.
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
See what wraps does
In the first example, remove the
@functools.wraps(func)line fromlog_callsand run it. Compareadd.__name__andadd.__doc__before and after. Then put it back. - 2
Swap the stacking order
Swap the lines
@shoutand@exclaim. Predict the new result ofgreet("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
Make the API fail forever
In the second example, change
state["calls"] <= 2tostate["calls"] <= 5. After three failed attempts the decorator re-raises the error, and you see a traceback ending inConnectionError: server busy.
Code & diagrams
Delete @functools.wraps(func) and print add.__name__ again. It becomes 'wrapper'.
Expected output
calling add with (2,) {'b': 3}
add returned 5
5
add - Add two numbers.
original still reachable: 2
HELLO ADA!The fake API fails on its first two calls. Change `<= 2` to `<= 5` and watch the retry give up and re-raise the error.
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 timesTiming 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.
Expected output
load_data took 0.25 seconds
rows: [1, 2, 3]
train took 2.50 seconds
trained on 3 rowsBreak 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.
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)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
defis an executable statement that creates a function object and binds a name.@djust inserts a call todbetween 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 deffunctions too, but the wrapper must itself beasync defandawaitthe 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 atry/exceptin the wrapper (a retry!) never sees the exception, which only appears later when the caller awaits.inspect.iscoroutinefunctionalso reportsFalsefor 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.wrapscopies__module__,__name__,__qualname__,__doc__,__annotations__and updates__dict__; it sets__wrapped__, whichinspect.signaturefollows, so the wrapper reports the original's signature. Type checkers needParamSpecto 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
tenacityprovide this; the hand-written version teaches the idea.
Remember this
- 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
The
@syntax is only a shorthand. Writing@log_callsabovedef add(...)means exactly:add = log_calls(add). After the line runs, the nameaddpoints at the wrapper, not the original. The original lives on inside the wrapper's closure. Decorators run once, when thedefis executed, not each time the function is called. - 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) thenresult = func(*args, **kwargs), and then return the result. Forgetting thereturnis the classic mistake: the decorated function then always returnsNone. - 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 andhelp()confusing and breaks tools that read names and docs. (A traceback still shows the frame aswrapper, becausewrapschanges the function object, not the code inside it.)wrapsalso setsadd.__wrapped__, a pointer to the original for introspection and testing. - 5
Stacking. Several decorators apply from the bottom up: the one nearest the
defwraps first.@aabove@babovedef fmeansf = 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
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
What is a decorator, and what does @decorator above a function mean exactly?
Why does a decorator's wrapper use *args, **kwargs, and what happens if it forgets to return the result?
What does functools.wraps do and why should every wrapper use it?
In what order do stacked decorators apply and run?
Describe a retry decorator for a flaky API call. What should it retry, and what should it not?
Practice
Write a decorator shout that upper-cases the string returned by a function. Decorate def greet(name) and print greet("ada").
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.
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 fmay 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
@dabovedef fmeansf = d(f).Done when you can write a wrapper that forwards
*args, **kwargsand returns the result.Done when you can use
functools.wrapsand 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.