Yesterday: functions are values. Today's payoff. A decorator is a function that takes a function and returns a new function:
def logged(fn):
def wrapper(*args, **kwargs):
print(f"call {fn.__name__}")
return fn(*args, **kwargs)
return wrapper
@logged
def add(a, b):
return a + b
print(add(3, 4))Output:
call add
7
What does @logged do exactly?
Pure syntactic sugar. The line @logged directly above def add is the same as writing add = logged(add) after the def. So add is now wrapper, not the original function. When you call add(3, 4), you're really calling wrapper(3, 4) — which prints, then forwards the call to the original via fn(*args, **kwargs), returns the result.
And *args, **kwargs lets wrapper forward any arguments?
Right. The wrapper doesn't know what arguments the wrapped function expects, so it accepts everything (*args is positional, **kwargs is keyword) and passes them through. This makes the decorator work on any function.
Why would you ever write one?
Cross-cutting concerns. Logging, timing, caching, retries, permission checks — code you want to add to many functions without polluting their bodies. You write the wrapper once, decorate every function that needs it, and the bodies stay focused on what they do.
def logged(fn): # 1. takes a function
def wrapper(*args, **kwargs): # 2. defines a wrapper
print(f"call {fn.__name__}") # 3. extra behaviour
return fn(*args, **kwargs) # 4. forward the call
return wrapper # 5. returns the wrapper
@logged
def add(a, b):
return a + b@logged is sugar# These two are identical:
@logged
def add(a, b):
return a + b
# vs.
def add(a, b):
return a + b
add = logged(add)The @logged syntax is shorter and sits right above the function it decorates — easier to spot when reading.
*args, **kwargs — accept anythingIn the wrapper signature:
*args — captures positional arguments as a tuple**kwargs — captures keyword arguments as a dictIn the call fn(*args, **kwargs):
*args — unpacks the tuple back into positional args**kwargs — unpacks the dict back into keyword argsResult: the wrapper passes through anything, regardless of how the inner function is declared. Decorators work on add(a, b), on greet(name, greeting="hi"), on anything.
The wrapper must return fn(*args, **kwargs) — otherwise the decorated function returns None:
def broken(fn):
def wrapper(*args, **kwargs):
fn(*args, **kwargs) # missing return!
return wrapper
@broken
def add(a, b):
return a + b
print(add(3, 4)) # None — silent bugForgetting return is the #1 decorator bug.
| Decorator | Effect |
|---|---|
@logged | print before/after each call |
@timed | print elapsed time |
@cached (or @functools.cache) | memoise the result by argument |
@retry(times=3) | retry on exception |
@property (week 4) | expose a method as an attribute |
functools.wraps — preserve metadataA detail you'll see in real code:
from functools import wraps
def logged(fn):
@wraps(fn)
def wrapper(*args, **kwargs):
print(f"call {fn.__name__}")
return fn(*args, **kwargs)
return wrapper@wraps(fn) copies __name__, __doc__, etc. from the original onto the wrapper. Without it, add.__name__ would be "wrapper" — confusing in error messages and tooling. Always use @wraps in production decorators. Today's exercise skips it for simplicity.
Yesterday: functions are values. Today's payoff. A decorator is a function that takes a function and returns a new function:
def logged(fn):
def wrapper(*args, **kwargs):
print(f"call {fn.__name__}")
return fn(*args, **kwargs)
return wrapper
@logged
def add(a, b):
return a + b
print(add(3, 4))Output:
call add
7
What does @logged do exactly?
Pure syntactic sugar. The line @logged directly above def add is the same as writing add = logged(add) after the def. So add is now wrapper, not the original function. When you call add(3, 4), you're really calling wrapper(3, 4) — which prints, then forwards the call to the original via fn(*args, **kwargs), returns the result.
And *args, **kwargs lets wrapper forward any arguments?
Right. The wrapper doesn't know what arguments the wrapped function expects, so it accepts everything (*args is positional, **kwargs is keyword) and passes them through. This makes the decorator work on any function.
Why would you ever write one?
Cross-cutting concerns. Logging, timing, caching, retries, permission checks — code you want to add to many functions without polluting their bodies. You write the wrapper once, decorate every function that needs it, and the bodies stay focused on what they do.
def logged(fn): # 1. takes a function
def wrapper(*args, **kwargs): # 2. defines a wrapper
print(f"call {fn.__name__}") # 3. extra behaviour
return fn(*args, **kwargs) # 4. forward the call
return wrapper # 5. returns the wrapper
@logged
def add(a, b):
return a + b@logged is sugar# These two are identical:
@logged
def add(a, b):
return a + b
# vs.
def add(a, b):
return a + b
add = logged(add)The @logged syntax is shorter and sits right above the function it decorates — easier to spot when reading.
*args, **kwargs — accept anythingIn the wrapper signature:
*args — captures positional arguments as a tuple**kwargs — captures keyword arguments as a dictIn the call fn(*args, **kwargs):
*args — unpacks the tuple back into positional args**kwargs — unpacks the dict back into keyword argsResult: the wrapper passes through anything, regardless of how the inner function is declared. Decorators work on add(a, b), on greet(name, greeting="hi"), on anything.
The wrapper must return fn(*args, **kwargs) — otherwise the decorated function returns None:
def broken(fn):
def wrapper(*args, **kwargs):
fn(*args, **kwargs) # missing return!
return wrapper
@broken
def add(a, b):
return a + b
print(add(3, 4)) # None — silent bugForgetting return is the #1 decorator bug.
| Decorator | Effect |
|---|---|
@logged | print before/after each call |
@timed | print elapsed time |
@cached (or @functools.cache) | memoise the result by argument |
@retry(times=3) | retry on exception |
@property (week 4) | expose a method as an attribute |
functools.wraps — preserve metadataA detail you'll see in real code:
from functools import wraps
def logged(fn):
@wraps(fn)
def wrapper(*args, **kwargs):
print(f"call {fn.__name__}")
return fn(*args, **kwargs)
return wrapper@wraps(fn) copies __name__, __doc__, etc. from the original onto the wrapper. Without it, add.__name__ would be "wrapper" — confusing in error messages and tooling. Always use @wraps in production decorators. Today's exercise skips it for simplicity.
Create a free account to get started. Paid plans unlock all tracks.