Decorators — Writing and Using Them
Functions that wrap functions. How decorators actually work, and writing your own from scratch.
The Foundation Decorators Are Built On
You already know functions can be passed as arguments and returned from other functions — this was covered when Functions were introduced, and used again with map/filter in the functional-tools module. A decorator is nothing more exotic than a function that takes a function as input and returns a new function as output.
def loud(func):
def wrapper():
print("About to call the function...")
func()
print("...function finished.")
return wrapper
def greet():
print("Hello!")
greet = loud(greet) # manually "decorating" greet by reassigning it
greet()
# About to call the function...
# Hello!
# ...function finished.Every ingredient of a decorator is already visible here: loud takes a function, defines a new inner function (wrapper) that calls the original and adds behaviour around it, and returns that wrapper. The @ syntax you are about to see is purely syntax sugar for exactly the reassignment on the line above.
@decorator Is Sugar for func = decorator(func)
def loud(func):
def wrapper():
print("About to call the function...")
func()
print("...function finished.")
return wrapper
@loud
def greet():
print("Hello!")
# The line above is EXACTLY equivalent to:
# def greet():
# print("Hello!")
# greet = loud(greet)
greet() # identical output to Part 01@decorator as "replace the function immediately below with decorator(that function)." Once you can mentally unwrap the @ syntax into the plain reassignment it really is, decorators stop looking like magic — they are ordinary function calls, just applied at definition time instead of call time.A Real Decorator Must Forward Everything
The loud example above only works on functions that take no arguments and return nothing — useless for real code. A proper decorator uses *args and **kwargs (covered in the previous module) so it can wrap any function signature, and it must explicitly return whatever the wrapped function returns.
def loud(func):
def wrapper(*args, **kwargs):
print(f"Calling {func.__name__} with args={args}, kwargs={kwargs}")
result = func(*args, **kwargs)
print(f"{func.__name__} returned {result!r}")
return result # critical — forget this and every decorated function silently returns None
return wrapper
@loud
def add(a, b):
return a + b
total = add(3, 4)
# Calling add with args=(3, 4), kwargs={}
# add returned 7
print(total) # 7 — the real return value made it throughreturn result inside the wrapper is the single most common decorator bug. The decorated function still "runs" and appears to work — but every call site that relies on its return value silently receives None instead, often not noticed until much later.The Metadata a Naive Decorator Silently Destroys
Once a function is decorated, it is literally replaced by the wrapper — which means things like func.__name__ and func.__doc__ now report the wrapper's identity, not the original function's, unless you fix it.
def loud(func):
def wrapper(*args, **kwargs):
return func(*args, **kwargs)
return wrapper
@loud
def add(a, b):
"""Add two numbers together."""
return a + b
print(add.__name__) # "wrapper" — WRONG! Should be "add"
print(add.__doc__) # None — WRONG! The real docstring is lostfrom functools import wraps
def loud(func):
@wraps(func) # copies __name__, __doc__, and more from func onto wrapper
def wrapper(*args, **kwargs):
return func(*args, **kwargs)
return wrapper
@loud
def add(a, b):
"""Add two numbers together."""
return a + b
print(add.__name__) # "add" — correct
print(add.__doc__) # "Add two numbers together." — correct@wraps(func) should be considered mandatory on every decorator you write — without it, debugging tools, documentation generators, and anything that introspects a function's identity (including some testing frameworks) see the wrapper's generic identity instead of the real function's, which becomes a genuinely confusing debugging experience once a codebase has several decorators stacked or applied broadly.Decorator Factories — One More Layer of Nesting
Sometimes you want to configure a decorator itself — @retry(times=3) instead of just @retry. This requires a decorator factory: a function that takes the configuration arguments and returns the actual decorator, which then returns the wrapper. Three levels of nested functions in total.
from functools import wraps
import time
def retry(times): # layer 1: takes the CONFIGURATION
def decorator(func): # layer 2: takes the FUNCTION
@wraps(func)
def wrapper(*args, **kwargs): # layer 3: runs on each CALL
last_error = None
for attempt in range(1, times + 1):
try:
return func(*args, **kwargs)
except ConnectionError as e:
last_error = e
print(f"Attempt {attempt} failed, retrying...")
time.sleep(1)
raise last_error
return wrapper
return decorator
@retry(times=3)
def fetch_data():
...Unwrapped, @retry(times=3) above def fetch_data(): means: fetch_data = retry(3)(fetch_data) — first retry(3) runs and returns the actual decorator function, and that is what gets applied to fetch_data. This is the same nesting pattern used by closures (the very next module), and understanding closures makes this pattern click far faster.
Multiple Decorators — Order Matters, Both Ways
@decorator_a
@decorator_b
def my_function():
...
# Equivalent to:
# my_function = decorator_a(decorator_b(my_function))Two directions of "order" both matter, and they are opposites of each other. Application order (which decorator wraps which) works bottom-up: decorator_b wraps the original function first, then decorator_a wraps the already-wrapped result. Execution order (what actually runs when you call the function) works top-down: decorator_a's wrapper code runs first (since it is the outermost layer), which then calls into decorator_b's wrapper, which finally calls the real function.
from functools import wraps
def announce(label):
def decorator(func):
@wraps(func)
def wrapper(*args, **kwargs):
print(f"[{label}] entering")
result = func(*args, **kwargs)
print(f"[{label}] exiting")
return result
return wrapper
return decorator
@announce("OUTER")
@announce("INNER")
def task():
print(" doing the actual work")
task()
# [OUTER] entering
# [INNER] entering
# doing the actual work
# [INNER] exiting
# [OUTER] exitingNotice OUTER starts first and finishes last — exactly like nested parentheses, the outermost call is the first thing you "enter" and the last thing you "exit."
Adding Caching to a Slow Endpoint at a Miami Travel-Tech Company
A function that looks up flight-route metadata from a slow internal database is called repeatedly with the same handful of popular routes, hundreds of times a minute, each call taking 200ms. An engineer reaches for Python's built-in functools.lru_cache decorator rather than hand-rolling a caching layer.
def get_route_info(origin, destination):
return slow_database_lookup(origin, destination) # ~200ms every timefrom functools import lru_cache
@lru_cache(maxsize=256)
def get_route_info(origin, destination):
return slow_database_lookup(origin, destination)
get_route_info("JFK", "LAX") # ~200ms — cache miss, runs the real lookup
get_route_info("JFK", "LAX") # ~0ms — cache hit, returns the stored result instantlyWhy this is exactly the kind of thing decorators are for
The caching logic — checking whether this exact combination of arguments was seen before, storing results, evicting the oldest entries once maxsize is reached — is completely generic and has nothing to do with flight routes specifically. lru_cache is itself just a decorator, written using exactly the pattern covered in this module, and applying it required changing precisely one line, with zero changes to get_route_info's own logic. This is the real payoff of decorators: cross-cutting behaviour (caching, logging, timing, retries, access control) added without touching the function's actual implementation at all.
Four Misconceptions About Decorators
5 Interview Questions — With Complete Answers
Decorator Mistakes Beginners Make Constantly
Errors You Will Hit With Decorators — And Exactly Why
🎯 Key Takeaways
- ✓A decorator is a function that takes a function and returns a new (usually wrapping) function. @decorator is sugar for func = decorator(func).
- ✓A wrapper should accept *args, **kwargs to support any function signature, and must explicitly return the original function's result — the most common decorator bug is forgetting that return.
- ✓functools.wraps(func) on the inner wrapper preserves __name__, __doc__, and other metadata — treat it as mandatory on every decorator you write.
- ✓A decorator that takes its own arguments (@retry(times=3)) needs three levels of nesting: a factory, the decorator, and the wrapper.
- ✓Stacked decorators apply bottom-up but execute top-down at call time — the outermost decorator enters first and exits last.
- ✓functools.lru_cache, @property, @staticmethod, and @classmethod are all decorators you likely already use — decorators are an everyday Python tool, not an obscure advanced feature.
What comes next
Module 30 covers context managers and the with statement — what with is actually doing under the hood, and building your own for resource management.
Module 30 → Context Managers and the with StatementDiscussion
0Have a better approach? Found something outdated? Share it — your knowledge helps everyone learning here.