Decorators
Functions that transform functions
Section titled “Functions that transform functions”A decorator is a function that takes a function as its argument and returns a new function.
Because functions are first-class objects in Python, a decorator is just an ordinary function — there is no special syntax involved until you add @.
def my_decorator(func): def wrapper(*args, **kwargs): print("Before") result = func(*args, **kwargs) print("After") return result return wrapper
def say_hello(): print("Hello!")
say_hello = my_decorator(say_hello) # manual decorationsay_hello()# Before# Hello!# AfterThe @ syntax
Section titled “The @ syntax”The @decorator syntax is shorthand — it applies the decorator automatically right after the function is defined.
These two are exactly equivalent:
@my_decoratordef say_hello(): print("Hello!")
# is identical to:def say_hello(): print("Hello!")say_hello = my_decorator(say_hello)Preserving metadata with functools.wraps
Section titled “Preserving metadata with functools.wraps”Without functools.wraps, the wrapper function replaces the original’s __name__, __doc__, and other attributes.
This breaks introspection, logging, and documentation tools.
import functools
def my_decorator(func): @functools.wraps(func) # copies __name__, __doc__, __annotations__, etc. def wrapper(*args, **kwargs): print(f"Calling {func.__name__}") return func(*args, **kwargs) return wrapper
@my_decoratordef add(a: int, b: int) -> int: """Return the sum of a and b.""" return a + b
print(add.__name__) # add (not 'wrapper')print(add.__doc__) # Return the sum of a and b.Always apply @functools.wraps(func) to the inner wrapper function in every decorator you write.
Broken vs fixed, side by side — including inspect.signature
Section titled “Broken vs fixed, side by side — including inspect.signature”functools.wraps copies more than __name__/__doc__ — it also lets inspect.signature report the wrapped function’s real signature instead of wrapper’s generic (*args, **kwargs). Tools that generate API docs or CLI help text from inspect.signature silently show the wrong signature when wraps is missing:
import functoolsimport inspect
def broken_decorator(func): def wrapper(*args, **kwargs): return func(*args, **kwargs) return wrapper
def fixed_decorator(func): @functools.wraps(func) def wrapper(*args, **kwargs): return func(*args, **kwargs) return wrapper
@broken_decoratordef add(a: int, b: int) -> int: """Return the sum of a and b.""" return a + b
@fixed_decoratordef sub(a: int, b: int) -> int: """Return a minus b.""" return a - b
print(add.__name__, inspect.signature(add)) # wrapper (*args, **kwargs)print(sub.__name__, inspect.signature(sub)) # sub (a: int, b: int) -> intA practical logging decorator
Section titled “A practical logging decorator”import functoolsimport 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__} finished in {elapsed:.6f}s") return result return wrapper
@timeddef compute(n: int) -> int: return sum(range(n))functools.lru_cache and functools.cache
Section titled “functools.lru_cache and functools.cache”functools.cache (and its older, more configurable sibling functools.lru_cache) is a decorator that memoizes a function’s return value keyed by its arguments. Repeated calls with the same arguments skip the body entirely:
import functools
@functools.lru_cache(maxsize=32)def fib(n: int) -> int: if n < 2: return n return fib(n - 1) + fib(n - 2)
fib(20)print(fib.cache_info()) # CacheInfo(hits=18, misses=21, maxsize=32, currsize=21)Three sharp edges to know before reaching for it:
- Arguments must be hashable. The cache key is built from the arguments, so a
list(or any unhashable object) argument raisesTypeError: unhashable type: 'list'— not silently skip the cache. maxsize=None(or@functools.cache, which is exactlylru_cache(maxsize=None)) never evicts. Every distinct set of arguments the function has ever been called with stays cached for the process’s lifetime — an unbounded, growing cache is a memory leak if the input space is large or unbounded.@lru_cacheon a method cachesselftoo.selfis part of the cache key, so every instance that has ever called the cached method is kept alive by the cache — even instances no other code still references. This defeats normal garbage collection of those instances until the cache is cleared or the class itself is deleted. Preferfunctools.cached_propertyfor a per-instance cache, or cache a module-level function that takes plain, hashable data instead ofself.
class Doubler: def __init__(self, factor: int) -> None: self.factor = factor
@functools.lru_cache(maxsize=None) def compute(self, x: int) -> int: return x * self.factor
d1 = Doubler(2)d1.compute(5)d2 = Doubler(3)d2.compute(5)print(d2.compute.cache_info())# CacheInfo(hits=0, misses=2, maxsize=None, currsize=2)# both d1 and d2 are now referenced by the cache, forever, until cache_clear()functools.partial
Section titled “functools.partial”functools.partial bakes in some of a function’s arguments ahead of time, returning a new callable that only needs the rest:
import functools
def power(base: float, exponent: float) -> float: return base ** exponent
square = functools.partial(power, exponent=2)cube = functools.partial(power, exponent=3)print(square(5)) # 25print(cube(2)) # 8partial is a plain object (not a function wrapped by decoration) — it exposes .func and .keywords so you can introspect what was fixed. It is also the standard fix for late-binding closures in a loop: partial(f, i=i) freezes the current value of i the same way a default argument does.
Class decorators
Section titled “Class decorators”A decorator does not have to wrap a function — it can wrap a class, receiving the class object and returning a class (usually the same one, mutated or extended):
def add_repr(cls): """Class decorator: attach a generic __repr__ built from __dict__.""" def __repr__(self): fields = ", ".join(f"{k}={v!r}" for k, v in self.__dict__.items()) return f"{cls.__name__}({fields})" cls.__repr__ = __repr__ return cls
@add_reprclass Point: def __init__(self, x, y): self.x = x self.y = y
print(Point(1, 2)) # Point(x=1, y=2)@dataclass is the most common class decorator in the standard library — it works exactly this way, attaching __init__, __repr__, and __eq__ to the class it decorates.
@property is just a decorator
Section titled “@property is just a decorator”property is not special syntax — it is a built-in class, and @property decorates a method the same way any other decorator decorates a function: it replaces the name with a new object.
class Temperature: def __init__(self, celsius: float) -> None: self._celsius = celsius
@property def celsius(self) -> float: return self._celsius
@celsius.setter def celsius(self, value: float) -> None: self._celsius = value
t = Temperature(20.0)print(t.celsius) # 20.0 — looks like attribute access, runs a methodt.celsius = 25.0 # runs the setterprint(type(Temperature.celsius).__name__) # propertycelsius = property(_get_celsius, _set_celsius) is exactly what @property / @celsius.setter expand to — the same “decorator replaces the name with a new object” mechanism as every decorator in this lesson, just returning a property instead of a wrapped function.
Decorators that take arguments
Section titled “Decorators that take arguments”To pass arguments to a decorator, add one more level of nesting: a factory function that accepts the arguments and returns the actual decorator.
import functools
def retry(max_attempts: int = 3): def decorator(func): @functools.wraps(func) def wrapper(*args, **kwargs): for attempt in range(1, max_attempts + 1): try: return func(*args, **kwargs) except Exception as exc: if attempt == max_attempts: raise print(f"Attempt {attempt} failed: {exc}. Retrying...") return wrapper return decorator
@retry(max_attempts=3)def fetch(url: str) -> str: # would call an HTTP client here return f"Response from {url}"@retry(max_attempts=3) first calls retry(max_attempts=3), which returns decorator.
Python then applies decorator to fetch — so the full call chain is fetch = retry(3)(fetch).
That is three levels of nested function, one for each (: retry (the factory, called with the decorator’s own arguments), decorator (called once, with the function being decorated), and wrapper (called every time the decorated function is called).
Full runnable demo
Section titled “Full runnable demo”import functools
def log_calls(level: str = "INFO"): """Decorator factory: log calls at the given level.""" def decorator(func): @functools.wraps(func) def wrapper(*args, **kwargs): arg_str = ", ".join( [repr(a) for a in args] + [f"{k}={v!r}" for k, v in kwargs.items()] ) print(f"[{level}] {func.__name__}({arg_str}) called") result = func(*args, **kwargs) print(f"[{level}] {func.__name__} returned {result!r}") return result return wrapper return decorator
@log_calls(level="DEBUG")def add(a: int, b: int) -> int: """Return the sum of a and b.""" return a + b
@log_calls()def greet(name: str, greeting: str = "Hello") -> str: return f"{greeting}, {name}!"
print("--- add ---")add(3, 4)
print("--- greet ---")greet("Alice")greet("Bob", greeting="Hi")
print("--- metadata preserved ---")print(add.__name__)print(add.__doc__)Loading Python runtime (first run only)…
Depth demo: wraps, lru_cache, partial, class decorators, property
Section titled “Depth demo: wraps, lru_cache, partial, class decorators, property”The playground below runs every depth topic above, including the two failures — the broken decorator losing its signature, and lru_cache raising TypeError on an unhashable argument:
import functoolsimport inspect
# --- functools.wraps: broken vs fixed ---def broken_decorator(func): def wrapper(*args, **kwargs): return func(*args, **kwargs) return wrapper
def fixed_decorator(func): @functools.wraps(func) def wrapper(*args, **kwargs): return func(*args, **kwargs) return wrapper
@broken_decoratordef add(a: int, b: int) -> int: """Return the sum of a and b.""" return a + b
@fixed_decoratordef sub(a: int, b: int) -> int: """Return a minus b.""" return a - b
print("--- functools.wraps ---")print("broken __name__:", add.__name__)print("broken __doc__:", add.__doc__)print("broken signature:", inspect.signature(add))print("fixed __name__:", sub.__name__)print("fixed __doc__:", sub.__doc__)print("fixed signature:", inspect.signature(sub))
# --- lru_cache / cache ---print("\n--- functools.lru_cache ---")
@functools.lru_cache(maxsize=32)def fib(n: int) -> int: if n < 2: return n return fib(n - 1) + fib(n - 2)
print("fib(20):", fib(20))print("cache_info:", fib.cache_info())
fib.cache_clear()print("after cache_clear:", fib.cache_info())
@functools.cachedef total(items): return sum(items)
try: total([1, 2, 3])except TypeError as e: print(type(e).__name__ + ":", e)
class Doubler: def __init__(self, factor: int) -> None: self.factor = factor
@functools.lru_cache(maxsize=None) def compute(self, x: int) -> int: return x * self.factor
d1 = Doubler(2)d1.compute(5)print("cache after d1.compute(5):", d1.compute.cache_info())d2 = Doubler(3)d2.compute(5)print("cache after d2.compute(5) — self is part of the key, entry #2 kept alive:", d2.compute.cache_info())
# --- functools.partial ---print("\n--- functools.partial ---")
def power(base: float, exponent: float) -> float: return base ** exponent
square = functools.partial(power, exponent=2)cube = functools.partial(power, exponent=3)print("square(5):", square(5))print("cube(2):", cube(2))print("square.func.__name__:", square.func.__name__)print("square.keywords:", square.keywords)
# --- class decorator ---print("\n--- class decorator ---")
def add_repr(cls): """Class decorator: attach a generic __repr__ built from __dict__.""" def __repr__(self): fields = ", ".join(f"{k}={v!r}" for k, v in self.__dict__.items()) return f"{cls.__name__}({fields})" cls.__repr__ = __repr__ return cls
@add_reprclass Point: def __init__(self, x, y): self.x = x self.y = y
print(Point(1, 2))
# --- @property is just a decorator ---print("\n--- @property is a decorator ---")
class Temperature: def __init__(self, celsius: float) -> None: self._celsius = celsius
@property def celsius(self) -> float: return self._celsius
@celsius.setter def celsius(self, value: float) -> None: self._celsius = value
t = Temperature(20.0)print("read:", t.celsius)t.celsius = 25.0print("after write:", t.celsius)print("Temperature.celsius is a:", type(Temperature.celsius).__name__)Loading Python runtime (first run only)…
Mental Model
Section titled “Mental Model”Reading @log_calls(level="DEBUG") above def add(...) bottom-up shows the three call levels a parameterized decorator always has — one function call per pair of parentheses in the source:
flowchart TD A["retry(max_attempts=3)\n(the factory — called once, with the decorator's own args)"] --> B["decorator\n(called once, with the function being decorated)"] B --> C["wrapper\n(called every time fetch(...) is called)"] C --> D["func(*args, **kwargs)\n(the original fetch)"]
Stacked decorators (@a @b @c above def f()) apply bottom-up but run outside-in: c wraps f, b wraps c’s result, a wraps b’s result — so at call time a’s wrapper code runs first, then b’s, then c’s, then finally f itself.
Trade-offs
Section titled “Trade-offs”| What you get | Benefit | Cost |
|---|---|---|
@functools.wraps(func) | __name__/__doc__/inspect.signature all report the real function | One more import and one more line per decorator — easy to forget |
@functools.lru_cache/@cache | Skip recomputation entirely on repeat calls | Arguments must be hashable; unbounded (maxsize=None) growth is a memory leak; caching a method keeps every self alive |
functools.partial | No new function definition needed to fix some arguments | The result is a partial object, not a function — __name__/__doc__ are not copied automatically |
| Class decorator | Same “wrap and replace” mechanism as a function decorator, reusable across classes | Mutating cls in place (as opposed to returning a new class) can surprise code that kept a reference to the original class object |
@property | Attribute-looking syntax that can validate, compute, or log on read/write | Every read/write is a method call — do not put expensive or side-effecting work behind what looks like a plain attribute |
Common Misconceptions
Section titled “Common Misconceptions”- “A decorator without
functools.wrapsstill behaves identically, just with a different__name__” — fact:inspect.signaturealso breaks, so anything that introspects the function (documentation generators, some CLI/DI frameworks) sees(*args, **kwargs)instead of the real parameters. - “
lru_cache/cacheis always safe to slap on any function” — fact: it raisesTypeErroron unhashable arguments, and withmaxsize=Noneit never forgets, which is a memory leak for functions called with a large or unbounded variety of inputs — and on a method, it keeps every calling instance alive. - “
@propertyrequires special interpreter support” — fact: it is an ordinary decorator around an ordinary class,property— the same mechanism (replace the name with the decorator’s return value) as every other decorator in this lesson. - “Decorators are only for logging/timing examples” — fact:
@dataclass,@functools.cache,@app.route(...)in web frameworks,@pytest.fixture, and@propertyare all decorators doing real structural work, not just wrapping with print statements. - “Stacked decorators run in the order they’re applied (top to bottom)” — fact: they apply bottom-up (closest to the function first) but the resulting wrappers run outside-in at call time — the topmost decorator’s code is the first to execute.
Reading AI-generated code
Section titled “Reading AI-generated code”- Does every decorator’s inner
wrapperhave@functools.wraps(func)right above it? Its absence is easy to miss in a review and breaks__name__,__doc__, andinspect.signature. - Is
@lru_cacheor@cacheapplied to a method (a function whose first parameter isself)? Check whether the instances are meant to be cached forever — if not, this is a memory leak. - Does a cached function ever receive a
list,dict, or other unhashable argument? That call raisesTypeErrorat the call site, not at decoration time. - Is a decorator applied to a class instead of a function? Confirm it returns the class (or a valid replacement) — a class decorator that returns
Nonesilently destroys the class name. - For a decorator that takes arguments, count the parentheses: is there really a three-level nest (factory → decorator → wrapper), or did the author forget one level and pass the function directly to the factory?
🐛 Spot the bug
import functools
class ReportGenerator: def __init__(self, dataset_id: str) -> None: self.dataset_id = dataset_id
@functools.lru_cache(maxsize=None) def summary(self, rows: int) -> str: return f"{self.dataset_id}: summarized {rows} rows"
def build_report(dataset_id: str, rows: int) -> str: generator = ReportGenerator(dataset_id) return generator.summary(rows)
for i in range(100_000): build_report(f"dataset-{i}", rows=10)
print("done")Show the answer
@functools.lru_cache(maxsize=None) on summary includes self in the cache key. Every one of the 100,000 short-lived ReportGenerator instances created in the loop gets cached forever — nothing is ever evicted, so none of them can be garbage-collected, and the cache grows without bound for the life of the process (confirmed: ReportGenerator.summary.cache_info().currsize reaches 100000 after the loop, and it never shrinks). The fix is to not cache a method that carries per-instance state at all — cache a plain function of hashable data instead (functools.cache(lambda dataset_id, rows: f"{dataset_id}: summarized {rows} rows")), or use functools.cached_property-style per-instance caching if the value should live and die with one instance.