Build decorators from ordinary function reassignment, then preserve arguments, returned results, metadata, state, configuration, and stacked call order.
You will learn: Explain decorator syntax as function transformation and write wrappers that forward calls, preserve results and metadata, retain state, accept configuration, and stack predictably.
Practice in: Google Colab, JupyterLab, or a local editor
Suppose several game actions need a small audit message:
def open_gate(code):print("calling open_gate") result =f"gate {code} opened"print("finished open_gate")return resultdef reveal_map(region):print("calling reveal_map") result =f"map of {region} revealed"print("finished reveal_map")return result
The useful bodies differ, but the before/after behavior repeats. A decorator is a function transformation: it receives one function and returns replacement behavior, usually a wrapper that calls the original.
This lesson will answer:
When does the decorator run, and when does the wrapper run?
How does a wrapper preserve arbitrary call shapes and returned values?
Why does functools.wraps matter?
In what order do configured or stacked decorators operate?
2. Build a wrapper manually before using @
Start with a zero-argument function so the transformation is visible:
define the original read_compass functionread_compass = add_log(read_compass)
The decorator expression runs when Python executes the def statement, often while a module is imported or a notebook definition cell runs. The wrapper body runs only when the decorated function is called later.
Definition time installs replacement behavior; call time travels through the wrapper to the original and back.
flowchart LR
A["define original function"] --> B["decorator returns wrapper"]
B --> C["public name refers to wrapper"]
C -->|"later call"| D["wrapper before behavior"]
D --> E["original function"]
E --> F["wrapper after behavior and return"]
4. A wrapper must return the original result
This broken wrapper calls correctly but discards the result:
The wrapper reaches its end without return, so callers see None even though the original returned 42. A transparent decorator must capture and return the original result unless changing that result is its documented purpose.
*args collects positional arguments passed to the wrapper. **kwargs collects keyword arguments. The original call function(*args, **kwargs) expands both collections, preserving the caller’s call shape.
A wrapper that forwards only positional arguments would lose quiet=True. A wrapper that forwards arguments but not the result still breaks the return contract. Both directions matter: arguments flow inward; results flow outward.
6. Preserve useful metadata with functools.wraps
Without help, the public function reports the wrapper’s name and docstring:
assert open_gate.__name__=="wrapper"
Tools, help systems, and readers need the original public identity. Apply functools.wraps to the wrapper:
from functools import wrapsdef add_log(function):@wraps(function)def wrapper(*args, **kwargs):print(f"calling {function.__name__}") result = function(*args, **kwargs)print(f"finished {function.__name__}")return resultreturn wrapper@add_logdef reveal_map(region):"""Return a message revealing one region."""returnf"map of {region} revealed"assert reveal_map("Crater") =="map of Crater revealed"assert reveal_map.__name__=="reveal_map"assert reveal_map.__doc__ =="Return a message revealing one region."
wraps(function) is itself a decorator applied to wrapper. It copies key metadata and exposes a __wrapped__ link used by introspection tools. It does not fix lost arguments or results; your wrapper still owns those responsibilities.
The count binding belongs to this decorated function’s closure. A separately decorated function receives separate state. This decorator intentionally changes the result contract to (call_number, original_result), so its name and documentation should make that choice clear. A transparent audit decorator would instead log elsewhere and return only the original result.
8. A configurable decorator adds one more function layer
Suppose logging should include a chosen label. The outer function receives configuration and returns the actual decorator:
from functools import wrapsdef tagged(label):def decorate(function):@wraps(function)def wrapper(*args, **kwargs): result = function(*args, **kwargs)return"["+ label +"] "+ resultreturn wrapperreturn decorate@tagged("TREASURE")def describe_treasure(name):returnf"found {name}"assert describe_treasure("moonstone") =="[TREASURE] found moonstone"
Trace the three layers:
Function layer
Receives
Returns
tagged
label configuration
decorate
decorate
original function
wrapper
wrapper
later call arguments
decorated call result
At definition time, tagged("TREASURE") runs first and returns decorate. Python then applies that decorator to describe_treasure. The wrapper waits for later calls.
The decorator closest to def wraps the original first. At call time, the outer < > wrapper starts first, calls the inner [ ] wrapper, which calls the original. Results then return outward. Swapping the decorator lines changes the result to [<moon>].
10. Decorators should make the public contract clearer, not mysterious
Good foundational uses are narrow and visible: logging, counting, timing at a later stage, access checks in frameworks, caching, or registering functions. Every decorator changes the effective public callable, even when it tries to be transparent.
Avoid a decorator when:
a direct helper call would be clearer;
the wrapper silently changes the result type;
decoration depends on surprising changing global state;
several layers make call order difficult to explain; or
different functions require incompatible call contracts.
Decorator syntax hides a transformation behind one line. Use it only when that shared transformation is easier to understand than explicit repetition.
11. Lab: track game achievements
Create a configurable audit decorator that records one event per successful call while preserving the decorated function’s behavior and metadata:
from functools import wrapsdef track_achievement(log, *, badge):"""Return a decorator that appends successful call records to log."""raiseNotImplementedError
The event must be a dictionary with badge, function, args, kwargs, and result. Append it after the original returns.
events = []@track_achievement(events, badge="gatekeeper")def open_gate(code, *, quiet=False):"""Return an opened-gate message.""" suffix =" quietly"if quiet else""returnf"gate {code} opened{suffix}"@track_achievement(events, badge="cartographer")def reveal_map(region):"""Return a revealed-map message."""returnf"map of {region} revealed"assert open_gate("A7") =="gate A7 opened"assert open_gate("B2", quiet=True) =="gate B2 opened quietly"assert reveal_map("Crater") =="map of Crater revealed"assert open_gate.__name__=="open_gate"assert open_gate.__doc__ =="Return an opened-gate message."assert events == [ {"badge": "gatekeeper","function": "open_gate","args": ("A7",),"kwargs": {},"result": "gate A7 opened", }, {"badge": "gatekeeper","function": "open_gate","args": ("B2",),"kwargs": {"quiet": True},"result": "gate B2 opened quietly", }, {"badge": "cartographer","function": "reveal_map","args": ("Crater",),"kwargs": {},"result": "map of Crater revealed", },]
The decorator intentionally mutates the supplied log; that effect is explicit in its contract. The wrapped game functions still return their original strings.
Hint: write and test one layer at a time
The outer call remembers log and badge, then returns decorate. decorate receives function and returns a @wraps(function) wrapper. The wrapper forwards arguments, keeps the result, appends one dictionary, and returns the result.
Show one complete solution after attempting the lab
from functools import wrapsdef track_achievement(log, *, badge):"""Return a decorator that appends successful call records to log."""def decorate(function):@wraps(function)def wrapper(*args, **kwargs): result = function(*args, **kwargs) event = {"badge": badge,"function": function.__name__,"args": args,"kwargs": kwargs.copy(),"result": result, } log.append(event)return resultreturn wrapperreturn decorate
12. Explain definition time and call time separately
Using track_achievement, state:
when configuration arguments are evaluated;
when the original function is passed to decorate;
what public name refers to after definition;
what the wrapper receives on each later call;
where the result travels; and
which state changes deliberately.
Then apply @surround above a tracked function and predict both the returned text and recorded result. The outer wrapper sees the result of the complete inner decorated call; reversing the stack changes which result the audit records.
Key points
TipKey points
Decorator syntax passes a newly defined function to another function and rebinds the public name to the returned callable.
Decorator application occurs at definition time; wrapper bodies run at later call time.
Transparent wrappers forward *args and **kwargs and return the original result.
functools.wraps preserves useful public metadata but does not repair broken forwarding or missing returns.
Closures can retain small per-decorated-function state; nonlocal updates it deliberately.
A configurable decorator has three layers: configuration, decoration, and later wrapped calls.
Stacked decorators apply inside out and execute as nested wrappers, with results returning outward.