PythonMastery
intermediate 18 min read · lesson 5 of 13 in Python Intermediate

Decorators: Functions That Wrap Functions

1 · The lesson

read

A 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.

python
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.

python
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:

python
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.

python
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

python
@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:

python
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.

python
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:

python
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.

python
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:

python
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:

python
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

python
@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:

python
@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.

python
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:

python
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:

python
# 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, ...)
python
# 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)
python
# 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

python
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

python
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

python
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:

  • @memoize and @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 @memoize form is optional).
  • When maxsize is None, 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).
python
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
python
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.
  • @decorator above def f is exactly f = decorator(f). Nothing more.
  • Wrappers use *args, **kwargs so 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 f is a(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.in

Short exercises that run in your browser and tell you what your code actually did, not just whether a test passed.