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\nhiFunctions 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... ughWith 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
** — the wrapper accepts anything and forwards it, so timed` decorates functions with any signature.None. The #1 decorator bug.help(slow_add) shows wrapper'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 upRead 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 wrapperCommon Errors & Fixes
return result.*args, **kwargs.@functools.wraps(fn).@ line executes; the wrapper body runs per call.lru_cache keys on arguments; mutable arguments (lists) break it — accept tuples, or use hash=True-safe inputs.Key Concepts
What to Try Next
@timed to three functions in one of your projects and find the slow one.@count_calls decorator that prints how many times a function has run.st.cache_data's behavior by decorating a function and inspecting functools.wraps metadata.@retry(times=3) over @timed and 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.