Decorators: Functions That Wrap Functions
1 · The lesson
readA decorator is a function that takes a function and returns another function. Usually the new function wraps the original and adds extra behaviour. Logging, timing, caching, authentication, retrying on failure, and registering routes in a web framework are all commonly done with decorators.
The @decorator syntax can look strange the first time you see it. But it is only a one-line shorthand for an assignment you could write by hand. Once you see that assignment, every decorator in Python (@app.get("/"), @dataclass, @property, @lru_cache) works in the same way.
This lesson builds the idea step by step: first-class functions → closures → manual wrapping → @ syntax → decorators with arguments → real-world examples.
1. Functions Are First-Class Objects
In Python a function is a value, like a number or a string. You can assign it to a name, pass it to a function, return it, or 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 work because the function object is a normal value that you can pass around.
2. Closures: What You Need First
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 remembers count: we say it closes over count. Each call to make_counter() creates a new count, so 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 · re-runs 1 earlier block on this page first
# 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()') # Earlier blocks on this page that this one builds on, re-run quietly first. _pm_earlier = [ 'def make_counter():\n count = 0\n def step():\n nonlocal count\n count += 1\n return count\n return step\n\nc = make_counter()\nprint(c()) # 1\nprint(c()) # 2\nprint(c()) # 3', ] def _pm_run_earlier(_srcs, _g): import sys as _s, io as _io, warnings as _w for _src in _srcs: _o, _e = _s.stdout, _s.stderr _s.stdout = _s.stderr = _io.StringIO() try: with _w.catch_warnings(): _w.simplefilter('ignore') exec(compile(_src, '<earlier block>', 'exec'), _g) except (Exception, SystemExit): pass finally: _s.stdout, _s.stderr = _o, _e if 'matplotlib.pyplot' in _s.modules: _s.modules['matplotlib.pyplot'].close('all') # their plots belong to their own blocks _pm_run_earlier(_pm_earlier, globals()) del _pm_earlier, _pm_run_earlier
That's the whole closure idea. The wrapper function inside a decorator is a closure, and the variable it remembers is the original function.
3. Manual Wrapping: Before @
Let's build a decorator without the @ syntax. 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')
There are three parts:
1. trace takes a function fn.
2. It defines inner, a closure over fn. inner does the extra work and calls the original.
3. It returns inner. We assign the result back to the name greet, replacing the original.
The original greet still exists inside the closure. But the module-level name greet now points to inner, not to the original.
4. The @ Syntax: A Shortcut
@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 above a def runs greet = trace(greet) immediately after the function is defined. That is all it does. Every decorator you will see works this way.
5. A Real Decorator: @timer
This decorator prints how long a function took. It is useful for a quick check on a slow function, without using 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. It never goes backwards, it has high resolution, and changes to the system clock don't affect it. Don't use time.time() for benchmarking.
6. *args, **kwargs: Why Every Wrapper Needs Them
A wrapper has to accept whatever arguments the original function accepts, and you don't know them in advance. The signature *args, **kwargs accepts any arguments, so it solves this:
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 functions with exactly two positional arguments. Always use *args, **kwargs unless you have a deliberate reason not to.
7. functools.wraps: Keeping the Function's Name
If you wrap a function without @wraps, the decorated function 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 (the real name is lost!) print(greet.__doc__) # None (the docstring is 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) can break in ways that are hard to find when this information is wrong. The fix is one line:
from functools import wraps def trace(fn): @wraps(fn) # copies __name__ and __doc__, and sets __wrapped__ 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 in real 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.
That is three levels. Let's build it step by step:
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 calls = 0 @retry(times=3, delay=0.5) def flaky(): global calls calls += 1 if calls < 3: # the first two calls fail, like a short network outage raise ConnectionError("network blip") return "ok" print(flaky()) # attempt 1 failed: network blip # attempt 2 failed: network blip # ok
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')
flaky fails on its first two calls and succeeds on the third. The wrapper prints a message for each failure, waits half a second, and tries again, so you get "ok" in the end. If all three tries had failed, the wrapper would raise the last error.
Read the call from the outside in. retry(times=3, delay=0.5) runs first and returns decorator. Then decorator is applied to flaky, the same as flaky = decorator(flaky). In the end, the name flaky refers to wrapper, and the closure of wrapper stores times=3 and delay=0.5.
@retry (no parentheses) and @retry() (empty parentheses) are different calls. If you want both forms to work, you need extra code, which is usually not worth it. Pick one form and use it everywhere.
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()')
Decorators are applied from the bottom up. The decorator nearest the def is applied first. The order changes how the function behaves:
@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 goes through the cache. This does not help when the # call keeps failing, but it is cheap when the result is cached.
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 always correct. It depends on what you want, so think about the order every time you stack decorators.
10. Class-Based Decorators
A class with __call__ is also callable, so it can be a decorator. Use this when the decorator needs to keep state: counters, registries, or resources that are created on first use.
from functools import wraps class CallCounter: def __init__(self, fn): wraps(fn)(self) # copy __name__ and __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 shorter, and you'll see them most often. Use 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 simpler.
11. Decorators On Methods
self is just the first positional argument. Your *args, **kwargs wrapper passes it on like any other argument, so you need no special code:
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 work the same way: the @classmethod line runs method = classmethod(method). They are built in, but the mechanism is identical.
12. Real-World Examples
Here are a few decorators you will see very often:
# functools.lru_cache: remembers a function's results in a cache of limited size 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 would never finish. print(fib.cache_info()) # CacheInfo(hits=98, misses=101, ...)
# dataclasses: writes __init__, __repr__ and __eq__ for you from dataclasses import dataclass @dataclass class Point: x: float y: float print(Point(1, 2)) # Point(x=1, y=2)
# FastAPI or Flask: registering a URL route @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 wrap the function in the usual sense. It registers get_user in a routing table and returns the same function unchanged. So a decorator doesn't have to change behaviour. It is a general way to run some code when a function is defined.
Common Mistakes
1. Forgetting functools.wraps
Your decorated function shows up as inner or wrapper in tracebacks, help() shows the wrong information, and frameworks that read function metadata can behave wrongly without any error. Always put @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: this runs ONCE, when the function is defined
The decorator body runs when Python reaches @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 functions with no arguments 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 as soon as you call a decorated function that takes a parameter. Use *args, **kwargs unless you want to limit the signature on purpose.
4. Stacking order surprises
@a @b def f is a(b(f)). The decorator closest to def is applied first. @cache @retry and @retry @cache behave very differently, so choose the order on purpose.
5. Shared mutable state in the closure
def memoize(fn): cache = {} # one cache for each decorated function, shared by all 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 to be independent. State in the closure stays between calls. That is why closures are useful, and also why they can cause bugs.
🎯 Your Turn: Build @memoize(maxsize=None)
Write a decorator that caches a function's return values keyed by its arguments. Requirements:
- The call form
@memoize(maxsize=...)must work. Making the bare@memoizeform (without parentheses) work too is optional. - When
maxsizeisNone, the cache has no size limit. When it's an int, remove the oldest entry once the cache grows past 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__(@wrapssets this for you).
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 the result, drops the oldest if full (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 the items and make a tuple: key = (args, tuple(sorted(kwargs.items()))).
Hint 2: Tracking insertion order for eviction
A plain dict in Python 3.7+ preserves insertion order. To remove the oldest entry, get the first key:oldest = next(iter(cache)) then del cache[oldest]. Or use collections.OrderedDict and its .popitem(last=False) for the same effect. It makes the intent slightly clearer.
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: # Remove the oldest entry (dicts keep keys in 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]: # the 2nd call with 2 is a hit. The 3rd is a miss (2 was removed). slow_square(n) print(slow_square.cache_info()) # {'hits': 1, 'misses': 5, 'size': 3} print(slow_square.__name__) # slow_square (kept 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 decorator that takes arguments. The closure carries cache, stats, and maxsize. The wrapper exposes inspection methods (cache_info, cache_clear) as attributes, which is what functools.lru_cache does too. You have rebuilt the core of lru_cache from scratch.
The real lru_cache keeps its keys in recency order (a linked list, written in C) and moves a key to the end on every hit, making it a true Least Recently Used cache. The version above removes entries in insertion order, so it is closer to FIFO (first in, first out). 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).- Wrappers use
*args, **kwargsso they work with any signature. - Always put
@functools.wraps(fn)on the inner wrapper. It keeps the name, docstring and signature, and sets__wrapped__. - Decorators with arguments have three levels: outer → decorator → wrapper. The 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 basis of streaming pipelines and of most of itertools.
Practice this
on practicepython.inShort exercises that run in your browser and tell you what your code did, as well as whether a test passed.
Found this useful? Share it with a friend who’s learning Python.