Decorators: Reusable Behavior in One Line
A decorator wraps a function to add behavior before or after it runs, without touching the function's own code. You have already used them: @dataclass, @property, and the @app.route in web frameworks are all decorators. This lesson demystifies how they work so you can read them confidently and write your own for logging, timing, and retries, which are exactly the things data and AI scripts need.
What You'll Learn
- What the
@syntax actually does - How to write a decorator from scratch
- How to preserve a wrapped function's identity
- Real uses: timing, logging, and retrying flaky API calls
Functions Are Objects
The whole idea rests on one fact: in Python, a function is a value you can pass around and return, just like a number.
def shout(text):
return text.upper()
f = shout # assign the function to a variable
print(f("hi")) # HI
A decorator is simply a function that takes a function and returns a new function that adds something around it.
Writing Your First Decorator
def logged(func):
def wrapper(*args, **kwargs):
print(f"calling {func.__name__}")
result = func(*args, **kwargs)
print(f"done {func.__name__}")
return result
return wrapper
@logged
def add(a, b):
return a + b
print(add(2, 3))
# calling add
# done add
# 5
@logged above add means exactly add = logged(add). The *args, **kwargs lets the wrapper accept whatever arguments the original function takes and forward them unchanged. This pattern, wrap-call-return, is the skeleton of nearly every decorator.
- @loggedwraps the function
- beforelog the call
- run originalfunc(*args)
- afterlog + return result
Preserve the Function's Identity with functools.wraps
A plain wrapper hides the original function's name and docstring, which breaks debugging and tools. Always add @functools.wraps:
import functools
def logged(func):
@functools.wraps(func) # copies name, docstring, etc.
def wrapper(*args, **kwargs):
return func(*args, **kwargs)
return wrapper
Without it, add.__name__ would report wrapper. With it, it correctly reports add. Make this a habit.
A Genuinely Useful Decorator: Timing
Timing how long a step takes is constant in data work. Write it once, reuse it everywhere:
import functools, time
def timed(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
start = time.perf_counter()
result = func(*args, **kwargs)
elapsed = time.perf_counter() - start
print(f"{func.__name__} took {elapsed:.3f}s")
return result
return wrapper
@timed
def train():
time.sleep(0.2)
train() # train took 0.200s
Other classic uses: a @retry decorator that re-runs a flaky API call a few times, a @cache that stores results so repeated calls skip the work (the standard library ships functools.cache for this), and access checks in web routes.
Reading Decorators You Didn't Write
You will see decorators far more often than you write them. When you see @something above a function, mentally translate it to "this function gets wrapped with extra behavior." That alone makes frameworks much less mysterious.
Try It
Run this timing decorator, then add a print inside wrapper that also reports the arguments the function was called with.
Key Takeaways
- A decorator is a function that takes a function and returns a wrapped version with added behavior.
@decoratorabove a function meansfunc = decorator(func).- Use
*args, **kwargsin the wrapper to forward any arguments unchanged. - Always add
@functools.wraps(func)so the wrapped function keeps its name and docstring. - Common real uses: timing, logging, retrying flaky calls, and caching results.

