Decorators: Functions That Wrap Functions
1 · The lesson
readA decorator is a function that takes a function and returns another function — usually one that wraps the original with extra behaviour. Logging, timing, caching, authentication, retry-on-failure, route registration in a web framework — all of it is decorators.
The @decorator syntax looks like magic the first time you see it. It isn't. It's a one-line shorthand for an assignment you could write by hand. Once you see the assignment, every decorator pattern in Python — @app.get("/"), @dataclass, @property, @lru_cache — collapses into the same mental model.
This lesson builds that model from the bottom up: first-class functions → closures → manual wrapping → @ sugar → decorators with arguments → the real-world tour.
1. Functions Are First-Class Objects
In Python a function is just a value. You can assign it, pass it, return it, store it in a list.
def shout(text): return text.upper() + "!" # Assign the function object to another name yell = shout print(yell("hello")) # HELLO! # Pass it as an argument def apply(fn, value): return fn(value) print(apply(shout, "hi")) # HI! # Return one from another def get_formatter(): return shout formatter = get_formatter() print(formatter("done")) # DONE!
shout (no parentheses) is the function object. shout("hello") calls it. Decorators only work because the first form is a legal value you can pass around.
2. Closures — The Prerequisite
A closure is an inner function that remembers variables from the enclosing function, even after the outer function has finished.
def make_counter(): count = 0 def step(): nonlocal count count += 1 return count return step c = make_counter() print(c()) # 1 print(c()) # 2 print(c()) # 3
step closes over count. Each call to make_counter() creates a fresh count — two counters don't share state:
a = make_counter() b = make_counter() print(a(), a(), b(), a()) # 1 2 1 3
setup added so this can run · defines make_counter
# Lightweight mock for objects whose attributes/methods aren't critical class _AutoMock: def __init__(self, name='mock'): self._name = name def __getattr__(self, k): return _AutoMock(self._name + '.' + k) def __call__(self, *a, **kw): print('-> ' + self._name + '() called') return _AutoMock(self._name + '()') def __repr__(self): return '<mock ' + self._name + '>' def __str__(self): return '<mock ' + self._name + '>' def __bool__(self): return True def __iter__(self): return iter([]) def __len__(self): return 0 def __getitem__(self, k): return _AutoMock(self._name + '[...]') def __setitem__(self, k, v): pass def __enter__(self): return self def __exit__(self, *a): return False async def __aenter__(self): return self async def __aexit__(self, *a): return False def __add__(self, o): return self def __radd__(self, o): return self def __sub__(self, o): return self def __mul__(self, o): return self def __rmul__(self, o): return self def __truediv__(self, o): return self def __eq__(self, o): return isinstance(o, _AutoMock) def __hash__(self): return hash(self._name) def __lt__(self, o): return True def __le__(self, o): return True def __gt__(self, o): return False def __ge__(self, o): return False def __mro_entries__(self, bases): return (object,) def make_counter(*_a, **_kw): print('-> make_counter() called') return _AutoMock('make_counter()')
That's the whole closure idea. A decorator is just a closure where the captured variable happens to be the original function.
3. Manual Wrapping — Before @
Let's build a decorator without the syntactic sugar. Goal: print "calling" and "done" around any function.
def trace(fn): def inner(*args, **kwargs): print(f"calling {fn.__name__}") result = fn(*args, **kwargs) print(f"done {fn.__name__}") return result return inner def greet(name): return f"Hi, {name}." greet = trace(greet) # replace greet with its wrapped version print(greet("Linus")) # calling greet # done greet # Hi, Linus.
setup added so this can run · defines args, kwargs
# Lightweight mock for objects whose attributes/methods aren't critical class _AutoMock: def __init__(self, name='mock'): self._name = name def __getattr__(self, k): return _AutoMock(self._name + '.' + k) def __call__(self, *a, **kw): print('-> ' + self._name + '() called') return _AutoMock(self._name + '()') def __repr__(self): return '<mock ' + self._name + '>' def __str__(self): return '<mock ' + self._name + '>' def __bool__(self): return True def __iter__(self): return iter([]) def __len__(self): return 0 def __getitem__(self, k): return _AutoMock(self._name + '[...]') def __setitem__(self, k, v): pass def __enter__(self): return self def __exit__(self, *a): return False async def __aenter__(self): return self async def __aexit__(self, *a): return False def __add__(self, o): return self def __radd__(self, o): return self def __sub__(self, o): return self def __mul__(self, o): return self def __rmul__(self, o): return self def __truediv__(self, o): return self def __eq__(self, o): return isinstance(o, _AutoMock) def __hash__(self): return hash(self._name) def __lt__(self, o): return True def __le__(self, o): return True def __gt__(self, o): return False def __ge__(self, o): return False def __mro_entries__(self, bases): return (object,) args = _AutoMock('args') kwargs = _AutoMock('kwargs')
Three moving parts:
1. trace takes a function fn.
2. It defines inner — a closure over fn — which does the extra work and then calls the original.
3. It returns inner. We assign the result back to the name greet, replacing the original.
The original greet is still alive inside the closure. It's just no longer reachable by the name greet at module level.
4. The @ Syntax — Pure Sugar
@trace def greet(name): return f"Hi, {name}."
setup added so this can run · defines trace
# Lightweight mock for objects whose attributes/methods aren't critical class _AutoMock: def __init__(self, name='mock'): self._name = name def __getattr__(self, k): return _AutoMock(self._name + '.' + k) def __call__(self, *a, **kw): print('-> ' + self._name + '() called') return _AutoMock(self._name + '()') def __repr__(self): return '<mock ' + self._name + '>' def __str__(self): return '<mock ' + self._name + '>' def __bool__(self): return True def __iter__(self): return iter([]) def __len__(self): return 0 def __getitem__(self, k): return _AutoMock(self._name + '[...]') def __setitem__(self, k, v): pass def __enter__(self): return self def __exit__(self, *a): return False async def __aenter__(self): return self async def __aexit__(self, *a): return False def __add__(self, o): return self def __radd__(self, o): return self def __sub__(self, o): return self def __mul__(self, o): return self def __rmul__(self, o): return self def __truediv__(self, o): return self def __eq__(self, o): return isinstance(o, _AutoMock) def __hash__(self): return hash(self._name) def __lt__(self, o): return True def __le__(self, o): return True def __gt__(self, o): return False def __ge__(self, o): return False def __mro_entries__(self, bases): return (object,) trace = _AutoMock('trace')
That is exactly the same as:
def greet(name): return f"Hi, {name}." greet = trace(greet)
setup added so this can run · defines trace
# Lightweight mock for objects whose attributes/methods aren't critical class _AutoMock: def __init__(self, name='mock'): self._name = name def __getattr__(self, k): return _AutoMock(self._name + '.' + k) def __call__(self, *a, **kw): print('-> ' + self._name + '() called') return _AutoMock(self._name + '()') def __repr__(self): return '<mock ' + self._name + '>' def __str__(self): return '<mock ' + self._name + '>' def __bool__(self): return True def __iter__(self): return iter([]) def __len__(self): return 0 def __getitem__(self, k): return _AutoMock(self._name + '[...]') def __setitem__(self, k, v): pass def __enter__(self): return self def __exit__(self, *a): return False async def __aenter__(self): return self async def __aexit__(self, *a): return False def __add__(self, o): return self def __radd__(self, o): return self def __sub__(self, o): return self def __mul__(self, o): return self def __rmul__(self, o): return self def __truediv__(self, o): return self def __eq__(self, o): return isinstance(o, _AutoMock) def __hash__(self): return hash(self._name) def __lt__(self, o): return True def __le__(self, o): return True def __gt__(self, o): return False def __ge__(self, o): return False def __mro_entries__(self, bases): return (object,) def trace(*_a, **_kw): print('-> trace() called') return _AutoMock('trace()')
@trace placed above a def runs greet = trace(greet) immediately after the function is defined. Nothing more, nothing less. Every decorator you'll ever see is this rewrite.
5. A Real Decorator — @timer
Print how long a function took. Useful for spot-checking a slow function without reaching for a profiler.
import time from functools import wraps def timer(fn): @wraps(fn) def wrapper(*args, **kwargs): start = time.perf_counter() result = fn(*args, **kwargs) elapsed = time.perf_counter() - start print(f"{fn.__name__} took {elapsed*1000:.2f} ms") return result return wrapper @timer def slow_sum(n): return sum(i*i for i in range(n)) slow_sum(1_000_000) # slow_sum took 58.43 ms
setup added so this can run · defines args, kwargs
# Lightweight mock for objects whose attributes/methods aren't critical class _AutoMock: def __init__(self, name='mock'): self._name = name def __getattr__(self, k): return _AutoMock(self._name + '.' + k) def __call__(self, *a, **kw): print('-> ' + self._name + '() called') return _AutoMock(self._name + '()') def __repr__(self): return '<mock ' + self._name + '>' def __str__(self): return '<mock ' + self._name + '>' def __bool__(self): return True def __iter__(self): return iter([]) def __len__(self): return 0 def __getitem__(self, k): return _AutoMock(self._name + '[...]') def __setitem__(self, k, v): pass def __enter__(self): return self def __exit__(self, *a): return False async def __aenter__(self): return self async def __aexit__(self, *a): return False def __add__(self, o): return self def __radd__(self, o): return self def __sub__(self, o): return self def __mul__(self, o): return self def __rmul__(self, o): return self def __truediv__(self, o): return self def __eq__(self, o): return isinstance(o, _AutoMock) def __hash__(self): return hash(self._name) def __lt__(self, o): return True def __le__(self, o): return True def __gt__(self, o): return False def __ge__(self, o): return False def __mro_entries__(self, bases): return (object,) args = _AutoMock('args') kwargs = _AutoMock('kwargs')
time.perf_counter() is the right clock for measuring durations — monotonic, high-resolution, never affected by system clock changes. Don't use time.time() for benchmarking.
6. *args, **kwargs — Why Every Wrapper Needs Them
A wrapper has to accept whatever the original function accepts. You don't know in advance. The catch-all signature *args, **kwargs solves that:
def log(fn): @wraps(fn) def wrapper(*args, **kwargs): print(f"args={args} kwargs={kwargs}") return fn(*args, **kwargs) return wrapper @log def add(a, b): return a + b @log def connect(host, *, port=5432, ssl=True): ... add(2, 3) # args=(2, 3) kwargs={} connect("db", port=6543) # args=('db',) kwargs={'port': 6543}
setup added so this can run · defines wraps, args, kwargs
# Lightweight mock for objects whose attributes/methods aren't critical class _AutoMock: def __init__(self, name='mock'): self._name = name def __getattr__(self, k): return _AutoMock(self._name + '.' + k) def __call__(self, *a, **kw): print('-> ' + self._name + '() called') return _AutoMock(self._name + '()') def __repr__(self): return '<mock ' + self._name + '>' def __str__(self): return '<mock ' + self._name + '>' def __bool__(self): return True def __iter__(self): return iter([]) def __len__(self): return 0 def __getitem__(self, k): return _AutoMock(self._name + '[...]') def __setitem__(self, k, v): pass def __enter__(self): return self def __exit__(self, *a): return False async def __aenter__(self): return self async def __aexit__(self, *a): return False def __add__(self, o): return self def __radd__(self, o): return self def __sub__(self, o): return self def __mul__(self, o): return self def __rmul__(self, o): return self def __truediv__(self, o): return self def __eq__(self, o): return isinstance(o, _AutoMock) def __hash__(self): return hash(self._name) def __lt__(self, o): return True def __le__(self, o): return True def __gt__(self, o): return False def __ge__(self, o): return False def __mro_entries__(self, bases): return (object,) def wraps(*_a, **_kw): print('-> wraps() called') return _AutoMock('wraps()') args = _AutoMock('args') kwargs = _AutoMock('kwargs')
Write the wrapper signature as def wrapper(a, b): and your decorator works only for two-positional-argument functions. Always use *args, **kwargs unless you have a deliberate reason not to.
7. functools.wraps — Preserving Identity
Wrap a function without @wraps and the wrapped object loses its name, docstring, and signature.
def trace(fn): def inner(*a, **kw): return fn(*a, **kw) return inner @trace def greet(name): """Say hello.""" return f"Hi, {name}." print(greet.__name__) # inner — lost! print(greet.__doc__) # None — lost! help(greet) # shows inner(*a, **kw), not greet(name)
setup added so this can run · defines a, kw
# Lightweight mock for objects whose attributes/methods aren't critical class _AutoMock: def __init__(self, name='mock'): self._name = name def __getattr__(self, k): return _AutoMock(self._name + '.' + k) def __call__(self, *a, **kw): print('-> ' + self._name + '() called') return _AutoMock(self._name + '()') def __repr__(self): return '<mock ' + self._name + '>' def __str__(self): return '<mock ' + self._name + '>' def __bool__(self): return True def __iter__(self): return iter([]) def __len__(self): return 0 def __getitem__(self, k): return _AutoMock(self._name + '[...]') def __setitem__(self, k, v): pass def __enter__(self): return self def __exit__(self, *a): return False async def __aenter__(self): return self async def __aexit__(self, *a): return False def __add__(self, o): return self def __radd__(self, o): return self def __sub__(self, o): return self def __mul__(self, o): return self def __rmul__(self, o): return self def __truediv__(self, o): return self def __eq__(self, o): return isinstance(o, _AutoMock) def __hash__(self): return hash(self._name) def __lt__(self, o): return True def __le__(self, o): return True def __gt__(self, o): return False def __ge__(self, o): return False def __mro_entries__(self, bases): return (object,) a = _AutoMock('a') kw = _AutoMock('kw')
Frameworks that introspect functions (Flask, FastAPI, pytest, inspect.signature) break in subtle ways when the metadata is wrong. The fix is one line:
from functools import wraps def trace(fn): @wraps(fn) # copies __name__, __doc__, __wrapped__, signature def inner(*a, **kw): return fn(*a, **kw) return inner
setup added so this can run · defines a, kw
# Lightweight mock for objects whose attributes/methods aren't critical class _AutoMock: def __init__(self, name='mock'): self._name = name def __getattr__(self, k): return _AutoMock(self._name + '.' + k) def __call__(self, *a, **kw): print('-> ' + self._name + '() called') return _AutoMock(self._name + '()') def __repr__(self): return '<mock ' + self._name + '>' def __str__(self): return '<mock ' + self._name + '>' def __bool__(self): return True def __iter__(self): return iter([]) def __len__(self): return 0 def __getitem__(self, k): return _AutoMock(self._name + '[...]') def __setitem__(self, k, v): pass def __enter__(self): return self def __exit__(self, *a): return False async def __aenter__(self): return self async def __aexit__(self, *a): return False def __add__(self, o): return self def __radd__(self, o): return self def __sub__(self, o): return self def __mul__(self, o): return self def __rmul__(self, o): return self def __truediv__(self, o): return self def __eq__(self, o): return isinstance(o, _AutoMock) def __hash__(self): return hash(self._name) def __lt__(self, o): return True def __le__(self, o): return True def __gt__(self, o): return False def __ge__(self, o): return False def __mro_entries__(self, bases): return (object,) a = _AutoMock('a') kw = _AutoMock('kw')
@wraps(fn) also sets inner.__wrapped__ = fn, so tools (and you) can unwrap a stack of decorators to find the original. Always use @wraps. It's not optional in production code.
8. Decorators With Arguments
What if you want @retry(times=3) instead of plain @retry? You need one more level of nesting. The pattern is:
- Outer function takes the decorator's arguments and returns…
- A real decorator that takes a function and returns…
- A wrapper that does the work.
Three levels. Build it slowly:
import time from functools import wraps def retry(times=3, delay=0.1): def decorator(fn): @wraps(fn) def wrapper(*args, **kwargs): last_exc = None for attempt in range(1, times + 1): try: return fn(*args, **kwargs) except Exception as e: last_exc = e print(f"attempt {attempt} failed: {e}") time.sleep(delay) raise last_exc return wrapper return decorator @retry(times=3, delay=0.5) def flaky(): import random if random.random() < 0.7: raise ConnectionError("network blip") return "ok" print(flaky())
setup added so this can run · defines args, kwargs
# Lightweight mock for objects whose attributes/methods aren't critical class _AutoMock: def __init__(self, name='mock'): self._name = name def __getattr__(self, k): return _AutoMock(self._name + '.' + k) def __call__(self, *a, **kw): print('-> ' + self._name + '() called') return _AutoMock(self._name + '()') def __repr__(self): return '<mock ' + self._name + '>' def __str__(self): return '<mock ' + self._name + '>' def __bool__(self): return True def __iter__(self): return iter([]) def __len__(self): return 0 def __getitem__(self, k): return _AutoMock(self._name + '[...]') def __setitem__(self, k, v): pass def __enter__(self): return self def __exit__(self, *a): return False async def __aenter__(self): return self async def __aexit__(self, *a): return False def __add__(self, o): return self def __radd__(self, o): return self def __sub__(self, o): return self def __mul__(self, o): return self def __rmul__(self, o): return self def __truediv__(self, o): return self def __eq__(self, o): return isinstance(o, _AutoMock) def __hash__(self): return hash(self._name) def __lt__(self, o): return True def __le__(self, o): return True def __gt__(self, o): return False def __ge__(self, o): return False def __mro_entries__(self, bases): return (object,) args = _AutoMock('args') kwargs = _AutoMock('kwargs')
Reading the call from the outside in: retry(times=3, delay=0.5) runs first and returns decorator. Then @decorator is applied to flaky — same as flaky = decorator(flaky). The end result: flaky is now wrapper, which has times=3 and delay=0.5 baked into its closure.
@retry (no parentheses) and @retry() (empty parentheses) are different calls. If you want both forms to work, you write extra plumbing — usually not worth it. Pick one convention.
9. Stacking Decorators — Order Matters
@a @b def f(): ... # is equivalent to: f = a(b(f))
setup added so this can run · defines a, b
# Lightweight mock for objects whose attributes/methods aren't critical class _AutoMock: def __init__(self, name='mock'): self._name = name def __getattr__(self, k): return _AutoMock(self._name + '.' + k) def __call__(self, *a, **kw): print('-> ' + self._name + '() called') return _AutoMock(self._name + '()') def __repr__(self): return '<mock ' + self._name + '>' def __str__(self): return '<mock ' + self._name + '>' def __bool__(self): return True def __iter__(self): return iter([]) def __len__(self): return 0 def __getitem__(self, k): return _AutoMock(self._name + '[...]') def __setitem__(self, k, v): pass def __enter__(self): return self def __exit__(self, *a): return False async def __aenter__(self): return self async def __aexit__(self, *a): return False def __add__(self, o): return self def __radd__(self, o): return self def __sub__(self, o): return self def __mul__(self, o): return self def __rmul__(self, o): return self def __truediv__(self, o): return self def __eq__(self, o): return isinstance(o, _AutoMock) def __hash__(self): return hash(self._name) def __lt__(self, o): return True def __le__(self, o): return True def __gt__(self, o): return False def __ge__(self, o): return False def __mro_entries__(self, bases): return (object,) def a(*_a, **_kw): print('-> a() called') return _AutoMock('a()') def b(*_a, **_kw): print('-> b() called') return _AutoMock('b()')
Bottom-up application. The decorator nearest the def runs first. That changes behaviour in real ways:
@cache @retry(times=3) def fetch(url): ... # retry wraps fetch first; cache wraps the retrying version. # A cached result skips retries entirely. Failures are NOT cached. @retry(times=3) @cache def fetch(url): ... # cache wraps fetch first; retry wraps the cached version. # Every retry hits the cache layer — useless on a cache miss # that keeps failing, but cheap on a hot cache.
setup added so this can run · defines cache, retry
# Lightweight mock for objects whose attributes/methods aren't critical class _AutoMock: def __init__(self, name='mock'): self._name = name def __getattr__(self, k): return _AutoMock(self._name + '.' + k) def __call__(self, *a, **kw): print('-> ' + self._name + '() called') return _AutoMock(self._name + '()') def __repr__(self): return '<mock ' + self._name + '>' def __str__(self): return '<mock ' + self._name + '>' def __bool__(self): return True def __iter__(self): return iter([]) def __len__(self): return 0 def __getitem__(self, k): return _AutoMock(self._name + '[...]') def __setitem__(self, k, v): pass def __enter__(self): return self def __exit__(self, *a): return False async def __aenter__(self): return self async def __aexit__(self, *a): return False def __add__(self, o): return self def __radd__(self, o): return self def __sub__(self, o): return self def __mul__(self, o): return self def __rmul__(self, o): return self def __truediv__(self, o): return self def __eq__(self, o): return isinstance(o, _AutoMock) def __hash__(self): return hash(self._name) def __lt__(self, o): return True def __le__(self, o): return True def __gt__(self, o): return False def __ge__(self, o): return False def __mro_entries__(self, bases): return (object,) cache = _AutoMock('cache') def retry(*_a, **_kw): print('-> retry() called') return _AutoMock('retry()')
Neither order is "correct" — it depends on what you want. Decide consciously every time you stack.
10. Class-Based Decorators
A class with __call__ is also callable, so it can be a decorator. Use this when the decorator needs to hold meaningful state — counters, registries, lazy-initialised resources.
from functools import wraps class CallCounter: def __init__(self, fn): wraps(fn)(self) # copy __name__, __doc__ onto the instance self.fn = fn self.calls = 0 def __call__(self, *args, **kwargs): self.calls += 1 return self.fn(*args, **kwargs) @CallCounter def ping(): return "pong" ping(); ping(); ping() print(ping.calls) # 3 print(ping.__name__) # ping
setup added so this can run · defines args, kwargs
# Lightweight mock for objects whose attributes/methods aren't critical class _AutoMock: def __init__(self, name='mock'): self._name = name def __getattr__(self, k): return _AutoMock(self._name + '.' + k) def __call__(self, *a, **kw): print('-> ' + self._name + '() called') return _AutoMock(self._name + '()') def __repr__(self): return '<mock ' + self._name + '>' def __str__(self): return '<mock ' + self._name + '>' def __bool__(self): return True def __iter__(self): return iter([]) def __len__(self): return 0 def __getitem__(self, k): return _AutoMock(self._name + '[...]') def __setitem__(self, k, v): pass def __enter__(self): return self def __exit__(self, *a): return False async def __aenter__(self): return self async def __aexit__(self, *a): return False def __add__(self, o): return self def __radd__(self, o): return self def __sub__(self, o): return self def __mul__(self, o): return self def __rmul__(self, o): return self def __truediv__(self, o): return self def __eq__(self, o): return isinstance(o, _AutoMock) def __hash__(self): return hash(self._name) def __lt__(self, o): return True def __le__(self, o): return True def __gt__(self, o): return False def __ge__(self, o): return False def __mro_entries__(self, bases): return (object,) args = _AutoMock('args') kwargs = _AutoMock('kwargs')
Function-based decorators are more concise and what you'll see most often. Reach for a class when:
- you need attributes on the decorated object (
.calls,.cache_info(),.reset()), - the decorator owns multiple methods worth exposing,
- you want subclassing.
For everything else, a function with a closure is lighter.
11. Decorators On Methods
self is just the first positional argument. Your *args, **kwargs wrapper handles it without special-casing:
def trace(fn): @wraps(fn) def wrapper(*args, **kwargs): print(f"calling {fn.__name__}") return fn(*args, **kwargs) return wrapper class Account: def __init__(self, balance): self.balance = balance @trace def deposit(self, amount): self.balance += amount return self.balance Account(100).deposit(50) # calling deposit
setup added so this can run · defines wraps, args, kwargs
# Lightweight mock for objects whose attributes/methods aren't critical class _AutoMock: def __init__(self, name='mock'): self._name = name def __getattr__(self, k): return _AutoMock(self._name + '.' + k) def __call__(self, *a, **kw): print('-> ' + self._name + '() called') return _AutoMock(self._name + '()') def __repr__(self): return '<mock ' + self._name + '>' def __str__(self): return '<mock ' + self._name + '>' def __bool__(self): return True def __iter__(self): return iter([]) def __len__(self): return 0 def __getitem__(self, k): return _AutoMock(self._name + '[...]') def __setitem__(self, k, v): pass def __enter__(self): return self def __exit__(self, *a): return False async def __aenter__(self): return self async def __aexit__(self, *a): return False def __add__(self, o): return self def __radd__(self, o): return self def __sub__(self, o): return self def __mul__(self, o): return self def __rmul__(self, o): return self def __truediv__(self, o): return self def __eq__(self, o): return isinstance(o, _AutoMock) def __hash__(self): return hash(self._name) def __lt__(self, o): return True def __le__(self, o): return True def __gt__(self, o): return False def __ge__(self, o): return False def __mro_entries__(self, bases): return (object,) def wraps(*_a, **_kw): print('-> wraps() called') return _AutoMock('wraps()') args = _AutoMock('args') kwargs = _AutoMock('kwargs')
You've already met three method decorators from OOP: @classmethod, @staticmethod, and @property. They follow the same protocol — the @ line runs method = classmethod(method). Built-in decorators, identical mechanism.
12. Real-World Tour
A few decorators you'll meet constantly:
# functools.lru_cache — memoise pure functions, bounded cache from functools import lru_cache @lru_cache(maxsize=128) def fib(n): return n if n < 2 else fib(n-1) + fib(n-2) print(fib(100)) # instant; without the cache, this is hopeless print(fib.cache_info()) # CacheInfo(hits=98, misses=101, ...)
# dataclasses — replaces __init__ / __repr__ / __eq__ boilerplate from dataclasses import dataclass @dataclass class Point: x: float y: float print(Point(1, 2)) # Point(x=1, y=2)
# FastAPI / Flask — route registration @app.get("/users/{user_id}") def get_user(user_id: int): return {"id": user_id}
setup added so this can run · defines app
# Lightweight mock for objects whose attributes/methods aren't critical class _AutoMock: def __init__(self, name='mock'): self._name = name def __getattr__(self, k): return _AutoMock(self._name + '.' + k) def __call__(self, *a, **kw): print('-> ' + self._name + '() called') return _AutoMock(self._name + '()') def __repr__(self): return '<mock ' + self._name + '>' def __str__(self): return '<mock ' + self._name + '>' def __bool__(self): return True def __iter__(self): return iter([]) def __len__(self): return 0 def __getitem__(self, k): return _AutoMock(self._name + '[...]') def __setitem__(self, k, v): pass def __enter__(self): return self def __exit__(self, *a): return False async def __aenter__(self): return self async def __aexit__(self, *a): return False def __add__(self, o): return self def __radd__(self, o): return self def __sub__(self, o): return self def __mul__(self, o): return self def __rmul__(self, o): return self def __truediv__(self, o): return self def __eq__(self, o): return isinstance(o, _AutoMock) def __hash__(self): return hash(self._name) def __lt__(self, o): return True def __le__(self, o): return True def __gt__(self, o): return False def __ge__(self, o): return False def __mro_entries__(self, bases): return (object,) app = _AutoMock('app')
The route decorator doesn't even wrap the function in the usual sense — it just registers get_user in a routing table and returns it untouched. Decorators don't have to modify behaviour; they're a generic "do something at definition time" hook.
Common Mistakes
1. Forgetting functools.wraps
Your decorated function shows up as inner or wrapper in tracebacks, help() is useless, and any framework that inspects function metadata silently misbehaves. Always @wraps(fn) on the inner wrapper.
2. Calling the wrapped function in the decorator body
def bad_log(fn): print(f"calling {fn.__name__}") return fn(...) # WRONG — runs ONCE at definition time
The decorator body runs when Python sees @bad_log — at definition time, not at call time. The work belongs inside inner/wrapper, which runs each time the decorated function is called.
3. Forgetting *args, **kwargs
def trace(fn): @wraps(fn) def inner(): # only works for zero-arg functions return fn() return inner
setup added so this can run · defines wraps
# Lightweight mock for objects whose attributes/methods aren't critical class _AutoMock: def __init__(self, name='mock'): self._name = name def __getattr__(self, k): return _AutoMock(self._name + '.' + k) def __call__(self, *a, **kw): print('-> ' + self._name + '() called') return _AutoMock(self._name + '()') def __repr__(self): return '<mock ' + self._name + '>' def __str__(self): return '<mock ' + self._name + '>' def __bool__(self): return True def __iter__(self): return iter([]) def __len__(self): return 0 def __getitem__(self, k): return _AutoMock(self._name + '[...]') def __setitem__(self, k, v): pass def __enter__(self): return self def __exit__(self, *a): return False async def __aenter__(self): return self async def __aexit__(self, *a): return False def __add__(self, o): return self def __radd__(self, o): return self def __sub__(self, o): return self def __mul__(self, o): return self def __rmul__(self, o): return self def __truediv__(self, o): return self def __eq__(self, o): return isinstance(o, _AutoMock) def __hash__(self): return hash(self._name) def __lt__(self, o): return True def __le__(self, o): return True def __gt__(self, o): return False def __ge__(self, o): return False def __mro_entries__(self, bases): return (object,) def wraps(*_a, **_kw): print('-> wraps() called') return _AutoMock('wraps()')
You'll get TypeError: inner() takes 0 positional arguments but 1 was given the moment you decorate anything that takes a parameter. Use *args, **kwargs unless you're deliberately constraining the signature.
4. Stacking order surprises
@a @b def f is a(b(f)). The decorator closest to def runs first. @cache @retry and @retry @cache produce very different programs — pick consciously.
5. Shared mutable state in the closure
def memoize(fn): cache = {} # one cache per decoration — shared across calls @wraps(fn) def inner(*args): if args not in cache: cache[args] = fn(*args) return cache[args] return inner
setup added so this can run · defines wraps, args
# Lightweight mock for objects whose attributes/methods aren't critical class _AutoMock: def __init__(self, name='mock'): self._name = name def __getattr__(self, k): return _AutoMock(self._name + '.' + k) def __call__(self, *a, **kw): print('-> ' + self._name + '() called') return _AutoMock(self._name + '()') def __repr__(self): return '<mock ' + self._name + '>' def __str__(self): return '<mock ' + self._name + '>' def __bool__(self): return True def __iter__(self): return iter([]) def __len__(self): return 0 def __getitem__(self, k): return _AutoMock(self._name + '[...]') def __setitem__(self, k, v): pass def __enter__(self): return self def __exit__(self, *a): return False async def __aenter__(self): return self async def __aexit__(self, *a): return False def __add__(self, o): return self def __radd__(self, o): return self def __sub__(self, o): return self def __mul__(self, o): return self def __rmul__(self, o): return self def __truediv__(self, o): return self def __eq__(self, o): return isinstance(o, _AutoMock) def __hash__(self): return hash(self._name) def __lt__(self, o): return True def __le__(self, o): return True def __gt__(self, o): return False def __ge__(self, o): return False def __mro_entries__(self, bases): return (object,) def wraps(*_a, **_kw): print('-> wraps() called') return _AutoMock('wraps()') args = _AutoMock('args')
This is correct if you want a shared cache for the lifetime of the program. It's a bug if you wanted each call independent. State in the closure persists between calls — that's the whole point, but also the whole risk.
🎯 Your Turn — Build @memoize(maxsize=None)
Write a decorator that caches a function's return values keyed by its arguments. Requirements:
@memoizeand@memoize(maxsize=128)both work (i.e. callable with or without arguments — for simplicity, accept that the call form@memoize(maxsize=...)is required; the bare@memoizeform is optional).- When
maxsizeisNone, the cache grows unbounded. When it's an int, evict the oldest entry once the cache exceeds that size. - Preserve the original function's name and docstring via
functools.wraps. - Expose
wrapper.cache_info()returning{"hits": int, "misses": int, "size": int}. - Expose
wrapper.__wrapped__(free if you use@wraps).
from functools import wraps def memoize(maxsize=None): def decorator(fn): # TODO 1: create a cache dict, hits counter, misses counter # TODO 2: define wrapper(*args, **kwargs) that: # - builds a hashable key from args and kwargs # - returns the cached value if present (hits += 1) # - otherwise calls fn, stores result, evicts if needed (misses += 1) # TODO 3: attach .cache_info() to the wrapper # TODO 4: return the wrapper, decorated with @wraps(fn) ... return decorator @memoize(maxsize=3) def slow_square(n): """Square a number, slowly.""" import time; time.sleep(0.1) return n * n print(slow_square(4)) print(slow_square(4)) # cached print(slow_square.cache_info()) # {'hits': 1, 'misses': 1, 'size': 1}
Hint 1 — Hashable cache keys
Dict keys must be hashable.args is already a tuple (hashable if all elements are). kwargs is a dict (not hashable). Convert kwargs to a frozenset of items, or sort and tuple-ify: key = (args, tuple(sorted(kwargs.items()))).
Hint 2 — Tracking insertion order for eviction
A plain dict in Python 3.7+ preserves insertion order. To evict the oldest, grab the first key:oldest = next(iter(cache)) then del cache[oldest]. Or use collections.OrderedDict and its .popitem(last=False) for the same effect — slightly more explicit.
Show full solution
from functools import wraps def memoize(maxsize=None): def decorator(fn): cache = {} stats = {"hits": 0, "misses": 0} @wraps(fn) def wrapper(*args, **kwargs): key = (args, tuple(sorted(kwargs.items()))) if key in cache: stats["hits"] += 1 return cache[key] stats["misses"] += 1 result = fn(*args, **kwargs) cache[key] = result if maxsize is not None and len(cache) > maxsize: # Evict the oldest entry — dicts preserve insertion order oldest = next(iter(cache)) del cache[oldest] return result def cache_info(): return {"hits": stats["hits"], "misses": stats["misses"], "size": len(cache)} def cache_clear(): cache.clear() stats["hits"] = 0 stats["misses"] = 0 wrapper.cache_info = cache_info wrapper.cache_clear = cache_clear return wrapper return decorator @memoize(maxsize=3) def slow_square(n): """Square a number, slowly.""" import time; time.sleep(0.05) return n * n for n in [2, 3, 2, 4, 5, 2]: # 2 is hit twice — once after eviction slow_square(n) print(slow_square.cache_info()) # {'hits': 1, 'misses': 5, 'size': 3} print(slow_square.__name__) # slow_square — preserved by @wraps print(slow_square.__doc__) # "Square a number, slowly."
setup added so this can run · defines args, kwargs
# Lightweight mock for objects whose attributes/methods aren't critical class _AutoMock: def __init__(self, name='mock'): self._name = name def __getattr__(self, k): return _AutoMock(self._name + '.' + k) def __call__(self, *a, **kw): print('-> ' + self._name + '() called') return _AutoMock(self._name + '()') def __repr__(self): return '<mock ' + self._name + '>' def __str__(self): return '<mock ' + self._name + '>' def __bool__(self): return True def __iter__(self): return iter([]) def __len__(self): return 0 def __getitem__(self, k): return _AutoMock(self._name + '[...]') def __setitem__(self, k, v): pass def __enter__(self): return self def __exit__(self, *a): return False async def __aenter__(self): return self async def __aexit__(self, *a): return False def __add__(self, o): return self def __radd__(self, o): return self def __sub__(self, o): return self def __mul__(self, o): return self def __rmul__(self, o): return self def __truediv__(self, o): return self def __eq__(self, o): return isinstance(o, _AutoMock) def __hash__(self): return hash(self._name) def __lt__(self, o): return True def __le__(self, o): return True def __gt__(self, o): return False def __ge__(self, o): return False def __mro_entries__(self, bases): return (object,) args = _AutoMock('args') kwargs = _AutoMock('kwargs')
The three-level structure — memoize → decorator → wrapper — is the standard shape of every parameterised decorator. The closure carries cache, stats, and maxsize. The wrapper exposes inspection methods (cache_info, cache_clear) as attributes — exactly how functools.lru_cache does it. You've just rebuilt the core of lru_cache from scratch.
The real lru_cache uses an OrderedDict and moves accessed keys to the end on every hit, making it a true Least Recently Used cache. The version above evicts in insertion order — closer to FIFO. Try upgrading it to true LRU as an extension.
What You Learned
- Functions are first-class — you can pass, return, and assign them. Decorators rely on this.
- A closure is an inner function that captures variables from the enclosing scope. Every decorator is a closure over the original function.
@decoratorabovedef fis exactlyf = decorator(f). Nothing more.- Wrappers use
*args, **kwargsso they work with any signature. - Always
@functools.wraps(fn)on the inner wrapper — preserves name, docstring, signature, and sets__wrapped__. - Decorators with arguments are three-level: outer → decorator → wrapper. Outer call returns the real decorator.
- Stacked decorators apply bottom-up:
@a @b def fisa(b(f)). Order changes behaviour. - Class-based decorators (with
__call__) are useful when the decorator carries state worth exposing. - Real-world decorators you'll meet everywhere:
@lru_cache,@dataclass,@property,@app.get(...).
Next: Generators — lazy iteration with yield, the foundation of streaming pipelines and most of itertools.
Practice this
on practicepython.inShort exercises that run in your browser and tell you what your code actually did, not just whether a test passed.