Introduction
Every Python developer meets decorators early — @st.cache_data, @app.route, @property — and most tutorials respond with syntax before concepts, which is why decorators stay mysterious. The truth: a decorator is just a function that takes a function and returns a new function. Everything else follows from that one sentence and one prerequisite idea.
This guide builds decorators from first principles, then shows the four you’ll actually write in real projects.
The Prerequisite: Functions Are Objects
In Python, a function is a value like any other — you can assign it, pass it, return it:
def shout(text):
return text.upper() + "!"
speak = shout # no parentheses — we pass the function itself
print(speak("hello")) # HELLO!
def run_twice(fn):
fn()
fn()
run_twice(lambda: print("hi")) # hi\nhi
Functions accepting functions and returning functions isn’t exotic — it’s how the Streamlit timer callbacks and every event-driven app on this site work.
Building a Decorator by Hand
Say you want to time any function. Without decorators:
import time
def slow_add(a, b):
time.sleep(1)
return a + b
start = time.time()
slow_add(1, 2)
print(f"took {time.time() - start:.1f}s") # repeated for every function... ugh
With a decorator — a function that wraps functions:
import time
import functools
def timed(fn):
@functools.wraps(fn)
def wrapper(*args, **kwargs):
start = time.time()
result = fn(*args, **kwargs)
print(f"{fn.__name__} took {time.time() - start:.2f}s")
return result
return wrapper
@timed
def slow_add(a, b):
time.sleep(1)
return a + b
slow_add(1, 2) # slow_add took 1.00s -> 3
@timed is pure sugar for slow_add = timed(slow_add). The name slow_add now points at wrapper, which adds timing around calls to the original. That’s the entire trick.
The Three Non-Negotiable Details
*args, **kwargs— the wrapper accepts anything and forwards it, sotimeddecorates functions with any signature.return result— forget this and every decorated function silently returnsNone. The #1 decorator bug.@functools.wraps(fn)— copies the original’s name and docstring onto the wrapper. Without it,help(slow_add)showswrapper’s metadata and debuggers get confused.
Decorators with Arguments
@cache_data(ttl=3600) has arguments — which means it’s a function returning a decorator returning a wrapper. Three layers:
def retry(times):
def decorator(fn):
@functools.wraps(fn)
def wrapper(*args, **kwargs):
for attempt in range(1, times + 1):
try:
return fn(*args, **kwargs)
except Exception as e:
print(f"Attempt {attempt} failed: {e}")
raise RuntimeError(f"All {times} attempts failed")
return wrapper
return decorator
@retry(times=3)
def flaky_api_call():
... # runs up to 3 times before giving up
Read it inside-out: retry(times=3) returns decorator; @decorator wraps flaky_api_call. The arguments live in the outermost layer’s closure — that’s how times reaches the wrapper.
The Four Decorators You’ll Actually Write
1. Caching — skip recomputation:
@functools.lru_cache(maxsize=None)
def fibonacci(n):
return n if n < 2 else fibonacci(n - 1) + fibonacci(n - 2)
This turns exponential recursion into linear — the same idea as Streamlit’s @st.cache_data from the currency converter, which caches API responses across reruns.
2. Timing/logging — the timed example above, pointed at any function.
3. Retry — the retry example, essential for anything touching a network — the web scraper and email sender both want this.
4. Validation/gating — check conditions before running:
def require_api_key(fn):
@functools.wraps(fn)
def wrapper(*args, **kwargs):
import os
if not os.environ.get("API_KEY"):
raise RuntimeError("Set API_KEY first")
return fn(*args, **kwargs)
return wrapper
Common Errors & Fixes
- Decorated function returns None — the wrapper forgot
return result. TypeError: wrapper() takes 0 positional arguments— the wrapper declared fixed parameters instead of*args, **kwargs.- “My function’s name is wrapper” in logs/help — missing
@functools.wraps(fn). - Decorator runs at import, not at call — correct and often surprising: the wrapping happens once when the
@line executes; the wrapper body runs per call. - Cached function never refreshes —
lru_cachekeys on arguments; mutable arguments (lists) break it — accept tuples, or usehash=True-safe inputs.
Key Concepts
- First-class functions — the prerequisite that makes decorators possible.
- The wrapper pattern — accept anything, delegate, return the result.
- Three-layer argument decorators — function → decorator → wrapper.
functools.wraps— metadata hygiene that costs one line.
What to Try Next
- Add
@timedto three functions in one of your projects and find the slow one. - Write a
@count_callsdecorator that prints how many times a function has run. - Read the source of
st.cache_data’s behavior by decorating a function and inspectingfunctools.wrapsmetadata. - Combine decorators — stack
@retry(times=3)over@timedand predict the order before running.
FAQ
Why do decorators run bottom-up when stacked?
Because @a\n@b\ndef f() means f = a(b(f)) — the decorator closest to the function wraps first. Timing inside retry measures each attempt; retry outside timing wraps the whole sequence.
Are decorators just for frameworks?
No — they’re for any cross-cutting concern you’d otherwise copy-paste into every function: timing, caching, retries, validation, logging. If you’re pasting the same three lines into ten functions, that’s a decorator.
What’s the difference between a decorator and a class-based one?
Same job, implemented with a class whose __call__ replaces wrapper — useful when the decorator needs state (like counting calls). Start with functions; reach for classes when you need attributes.