Advanced Decorators
Build decorators that survive type checkers, methods, async code and production debugging: factories, ParamSpec, descriptors, ordering and measured overhead
A payments service adds a @retry decorator to every client method. Two weeks
later the same call takes eleven seconds instead of two, because the retry
decorator was applied twice: once in the client class and once by a subclass
that copied the method. Each layer retries three times, the delays multiply, and
the timeout decorator above them sees only the total. Nothing in the traceback
names the duplicate wrapper.
The intermediate course covered the basic protocol: a decorator is a callable that receives a function and returns a replacement. That model is enough for a logging wrapper. It is not enough for a decorator that survives a type checker, binds to an instance method, awaits a coroutine, or explains itself during an incident.
This lesson covers the parts that break in real code. It separates definition
time from call time, builds factories and class-based decorators, preserves
metadata and signatures across wrapper layers, types wrappers with ParamSpec,
handles methods through the descriptor protocol, writes async wrappers that do
not swallow cancellation, predicts the order of stacked decorators, and measures
what a wrapper costs per call. The closing sections map the same patterns onto
FastAPI, pytest and Pydantic, and record the changes that landed in 3.14 and
3.15.
Every example runs on Python 3.11 or newer, and a few rely on 3.14 or 3.15. Those cases are marked inline. The benchmark in section 10 is a plain listing because three million calls in the browser runner would take minutes.
Key terms
- A callable that takes a function or class and returns a replacement for it.
- A callable that returns a decorator, used when the decorator needs configuration such as a retry count.
- The moment the class or function statement runs, which is when the decorator itself is applied.
- The moment the decorated function is invoked, which is when the wrapper body runs.
- The function or object returned by a decorator, which runs on every call and usually delegates to the original.
- The functools helper that copies metadata to a wrapper and records the original in __wrapped__.
- A typing construct that lets a decorator declare that the wrapper accepts exactly the parameters of the function it wraps.
- A typing construct that adds fixed leading parameters to a ParamSpec, used when a decorator injects an argument.
- An object that defines __get__, __set__ or __delete__ and controls attribute access on a class.
- A contextlib base class that lets a context manager also be used as a decorator.
- A functools decorator that turns a function into a dispatcher which chooses an implementation from the type of the first argument.
- The rule that the decorator closest to the function is applied first and therefore runs innermost at call time.
Decorator
Decorator factory
Definition time
Call time
Wrapper
wraps
ParamSpec
Concatenate
Descriptor
ContextDecorator
Singledispatch
Decorator order
1. What Runs When
The @ syntax is a rewrite. Writing @trace above a function is exactly
equivalent to defining the function and then calling trace on it. The
decorator runs once, when the definition executes. The wrapper it returns runs
on every call.
import functools
def trace(fn):
print("decorating", fn.__name__)
@functools.wraps(fn)
def wrapper(*args, **kwargs):
print("calling", fn.__name__)
return fn(*args, **kwargs)
return wrapper
@trace
def total(values):
return sum(values)
print("defined")
print(total([1, 2, 3]))The output starts with decorating total, then defined, then two lines from
the call. If the decorator printed its message on every call, that would be the
first sign that the wrapper and the decorator had been confused.
The same rewrite applies to classes. A class decorator receives the finished class object and returns a replacement, which may be the same class with extra attributes.
def add_repr(cls):
cls.__repr__ = lambda self: f"<{type(self).__name__}>"
return cls
@add_repr
class Ticket:
pass
print(Ticket())A class decorator cannot see the class body before it runs, which is why field
collection at class creation belongs to __set_name__ or __init_subclass__
rather than to a decorator. The previous lesson covered that pipeline.
Description
A sequence diagram separating definition time from call time, where the decorator receives the original function during import, returns a wrapper, and the wrapper delegates to the original on every later call.
2. Decorator Factories
A decorator with settings needs one more layer. The outer call receives the configuration and returns the actual decorator, which receives the function.
import functools
import time
def retry(*, attempts=3, delay=0.0, exceptions=(Exception,)):
def decorate(fn):
@functools.wraps(fn)
def wrapper(*args, **kwargs):
for attempt in range(1, attempts + 1):
try:
return fn(*args, **kwargs)
except exceptions:
if attempt == attempts:
raise
time.sleep(delay)
return wrapper
return decorate
calls = {"n": 0}
@retry(attempts=3, delay=0.01, exceptions=(ValueError,))
def flaky():
calls["n"] += 1
if calls["n"] < 3:
raise ValueError("not ready")
return "ok"
print(flaky(), calls["n"])Making the configuration keyword-only prevents the classic @retry(3) mistake,
where the first positional argument is silently treated as the function. With
*, @retry(3) raises TypeError at import, which is the right time to find
out.
A factory can also return functools.partial, which is shorter when the
configuration is a single value and no wrapper logic is needed. The tradeoff is
that partial objects have no __name__, so tools that print the callable show
a partial representation instead.
import functools
def prefix(text):
return functools.partial(lambda value, prefix: f"{prefix}{value}", prefix=text)
label = prefix("id-")
print(label("42"))For real code, the nested function is clearer. The factory pattern is the same
one used by dataclasses.dataclass, functools.lru_cache and pytest.fixture,
all of which accept options and return a decorator.
How to answer
Fill-in-the-blank exercise with 1 blanks. Type the missing words, then check your answer.
Complete the factory so the decorator accepts a keyword-only retry count.
def retry(*, attempts=3): def decorate(fn): @functools.wraps(fn) def wrapper(*args, **kwargs): ... return wrapper return blank
Word Bank, drag words into the blanks above:
3. Metadata Across Wrapper Layers
Without functools.wraps, the wrapper reports the wrong name, the wrong
docstring and the wrong annotations. Logs name every call wrapper, help()
shows the wrapper text, and frameworks that inspect signatures see
(*args, **kwargs).
import functools
import inspect
def plain_wrapper(fn):
def wrapper(*args, **kwargs):
return fn(*args, **kwargs)
return wrapper
def good_wrapper(fn):
@functools.wraps(fn)
def wrapper(*args, **kwargs):
return fn(*args, **kwargs)
return wrapper
@plain_wrapper
def charge(amount: int, currency: str = "USD") -> str:
return f"{amount} {currency}"
@good_wrapper
def refund(amount: int, currency: str = "USD") -> str:
return f"{amount} {currency}"
print(charge.__name__, inspect.signature(charge))
print(refund.__name__, inspect.signature(refund))
print(inspect.signature(refund, follow_wrapped=False))wraps assigns __module__, __name__, __qualname__, __annotations__,
__type_params__ and __doc__, updates __dict__, and sets __wrapped__ to
the original. inspect.signature follows __wrapped__ by default, which is why
the second signature matches the original. Passing follow_wrapped=False shows
the real wrapper signature.
With several layers, __wrapped__ forms a chain. inspect.unwrap walks it to
the original, which is the reliable way to find the real function under a stack
of decorators.
import functools
import inspect
def first(fn):
@functools.wraps(fn)
def wrapper(*args, **kwargs):
return fn(*args, **kwargs)
return wrapper
def second(fn):
@functools.wraps(fn)
def wrapper(*args, **kwargs):
return fn(*args, **kwargs)
return wrapper
@first
@second
def target():
return 1
print(target.__name__)
print(inspect.unwrap(target) is target.__wrapped__.__wrapped__)
print(inspect.unwrap(target)())Metadata does not fix pickling. pickle finds a function by module and
qualified name, so a wrapper can be pickled only when its name is bound at module
level. A decorator applied inside a function body produces a wrapper that cannot
be found by name, and the pickle call fails with PicklingError.
import pickle
def local_decorator(fn):
def wrapper(*args, **kwargs):
return fn(*args, **kwargs)
return wrapper
def build():
@local_decorator
def inner():
return 1
return inner
value = build()
print(value())
pickle.dumps(value) # PicklingError4. Typing a Decorator with ParamSpec
A wrapper typed as Callable[..., Any] erases the signature. A wrapper typed as
Callable[[int, str], str] is too specific. ParamSpec solves the general case:
it captures the parameter list of the wrapped function and reuses it on the
wrapper. It is part of the typing module, alongside TypeVar and
Concatenate.
import functools
from collections.abc import Callable
from typing import ParamSpec, TypeVar
P = ParamSpec("P")
R = TypeVar("R")
def logged(fn: Callable[P, R]) -> Callable[P, R]:
@functools.wraps(fn)
def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
return fn(*args, **kwargs)
return wrapper
@logged
def repeat(text: str, times: int = 2) -> str:
return text * times
print(repeat("ab", 3))A type checker now rejects repeat("ab", "three") and accepts repeat("ab").
Before ParamSpec, the usual workaround was an overload per arity, which
drifts as soon as the signature changes.
Concatenate covers the case where the wrapper injects a leading argument, such
as a database session or a request context. The wrapper adds the value, and the
caller's signature stays unchanged.
from collections.abc import Callable
from typing import Concatenate, ParamSpec, TypeVar
P = ParamSpec("P")
R = TypeVar("R")
class Session:
def query(self, sql: str) -> list[str]:
return [sql]
def with_session(fn: Callable[Concatenate[Session, P], R]) -> Callable[P, R]:
def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
return fn(Session(), *args, **kwargs)
return wrapper
@with_session
def count_rows(session: Session, table: str) -> int:
return len(session.query(f"select * from {table}"))
print(count_rows("users"))The factory version needs one more step: the return type is itself a callable that accepts a function and returns a function. The annotation is verbose, and it is the part most decorator libraries get wrong.
from collections.abc import Callable
from typing import ParamSpec, TypeVar
P = ParamSpec("P")
R = TypeVar("R")
def retrying(*, attempts: int = 3) -> Callable[[Callable[P, R]], Callable[P, R]]:
def decorate(fn: Callable[P, R]) -> Callable[P, R]:
def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
return fn(*args, **kwargs)
return wrapper
return decorateType checkers cannot verify that a wrapper preserves behaviour, only that the signature matches. A wrapper that reorders arguments or changes the return type still needs tests.
How to answer
Matching exercise with 5 pairs. Match each item on the left with its answer on the right.
Match each typing tool to the decorator problem it solves.
Drag answers from here:
5. Methods, Descriptors and Class-Based Decorators
A function is a descriptor, so a wrapper defined with def binds correctly when
it is stored on a class. A class-based decorator is an object, and an object is
not a descriptor unless it defines __get__. Without that method, calling
instance.method() passes no instance and the call fails.
import functools
import inspect
import types
class CountCalls:
def __init__(self, fn):
self.fn = fn
self.calls = 0
functools.update_wrapper(self, fn)
def __call__(self, *args, **kwargs):
self.calls += 1
return self.fn(*args, **kwargs)
def __get__(self, instance, owner=None):
if instance is None:
return self
return types.MethodType(self, instance)
class Greeter:
def __init__(self, name):
self.name = name
@CountCalls
def greet(self, greeting: str) -> str:
return f"{greeting}, {self.name}"
greeter = Greeter("Ada")
print(greeter.greet("hello"))
print(greeter.greet.calls)
print(inspect.signature(greeter.greet))types.MethodType binds the decorator instance to the instance, so __call__
receives the instance first and forwards it to the original method. Returning
self from __get__ would skip the binding, and the original method would
receive greeting in the self slot.
The order of @classmethod and a custom decorator matters because classmethod
is itself a descriptor. The safe order is @classmethod outermost, so the custom
decorator wraps a plain function and classmethod wraps the result.
def trace(fn):
def wrapper(*args, **kwargs):
return fn(*args, **kwargs)
return wrapper
class Factory:
@classmethod
@trace
def build(cls, name):
return f"{cls.__name__}:{name}"
print(Factory.build("x"))Reversing the order passes a classmethod object to trace, which is not
callable, so the decoration fails at import. The same rule applies to
@staticmethod and to @property, both of which are descriptors that expect a
function.
Description
A class diagram of three decorator shapes, a function wrapper that is a descriptor, a callable class that needs a get method to bind, and a factory that returns either one.
6. Async Decorators
An async function returns a coroutine when called. A synchronous wrapper that
returns that coroutine still works with await, but it cannot observe
exceptions, cancellation or the result without awaiting, so the wrapper body
must be async def as well.
import asyncio
import functools
def logged(fn):
@functools.wraps(fn)
async def wrapper(*args, **kwargs):
print("start", fn.__name__)
try:
return await fn(*args, **kwargs)
finally:
print("end", fn.__name__)
return wrapper
@logged
async def fetch(delay: float) -> str:
await asyncio.sleep(delay)
return "done"
print(asyncio.run(fetch(0.05)))The wrapper must not catch CancelledError and return a value. Cleanup belongs
in finally, and the exception must be re-raised so the caller can distinguish a
cancelled task from a finished one.
A decorator that is used on both sync and async functions needs a branch. Check
inspect.iscoroutinefunction on the wrapped callable and return the matching
wrapper. A single sync wrapper around an async function hides the coroutine from
frameworks that decide how to call a handler by inspecting it.
import asyncio
import functools
import inspect
def traced(fn):
if inspect.iscoroutinefunction(fn):
@functools.wraps(fn)
async def async_wrapper(*args, **kwargs):
return await fn(*args, **kwargs)
return async_wrapper
@functools.wraps(fn)
def wrapper(*args, **kwargs):
return fn(*args, **kwargs)
return wrapper
@traced
async def async_task():
await asyncio.sleep(0)
return "async"
@traced
def sync_task():
return "sync"
print(asyncio.run(async_task()), sync_task())contextlib.ContextDecorator and AsyncContextDecorator cover the resource
case. A context manager that subclasses ContextDecorator can be used with
with and as @manager(), and _recreate_cm controls whether each call gets a
fresh manager.
import time
from contextlib import ContextDecorator
class timed(ContextDecorator):
def __enter__(self):
self.start = time.perf_counter()
return self
def __exit__(self, *exc_info):
self.elapsed = time.perf_counter() - self.start
return False
@timed()
def work():
return sum(range(1000))
print(work(), work.elapsed >= 0)7. The Standard Library Decorators
functools.lru_cache keeps the results of recent calls in a dictionary keyed by
the arguments. It requires hashable arguments, and maxsize=None makes the
cache unbounded. functools.cache is the same thing with an unbounded default
and a shorter name.
import functools
@functools.lru_cache(maxsize=128)
def fib(n: int) -> int:
return n if n < 2 else fib(n - 1) + fib(n - 2)
print(fib(20))
print(fib.cache_info())
fib.cache_clear()
print(fib.cache_info())A cache on an instance method keeps a reference to self in the key, so a
service that creates a short-lived object per request and caches a method leaks
every request object. Use a module-level function, a cached_property, or an
explicit per-instance cache instead.
import functools
class Report:
def __init__(self, rows):
self.rows = rows
@functools.cached_property
def total(self):
return sum(self.rows)
report = Report([1, 2, 3])
print(report.total, report.total)
print("total" in report.__dict__)singledispatch turns a function into a dispatcher that chooses an
implementation from the type of the first argument, following the method
resolution order when no exact match exists. singledispatchmethod does the same
for methods, and it composes with classmethod and staticmethod.
import functools
@functools.singledispatch
def render(value):
return "object"
@render.register
def _(value: int | float):
return "number"
@render.register
def _(value: str):
return "text"
print(render(1), render(1.5), render("x"), render([]))
print(sorted(cls.__name__ for cls in render.registry))total_ordering fills in the missing comparison methods from __eq__ and one
of __lt__, __le__, __gt__ or __ge__. It is a decorator that generates
code, not a wrapper, and it is the cheapest way to make a value object sortable.
import functools
@functools.total_ordering
class Version:
def __init__(self, major, minor):
self.major = major
self.minor = minor
def __eq__(self, other):
return (self.major, self.minor) == (other.major, other.minor)
def __lt__(self, other):
return (self.major, self.minor) < (other.major, other.minor)
def __repr__(self):
return f"Version({self.major}, {self.minor})"
print(Version(3, 14) < Version(3, 15), Version(3, 14) <= Version(3, 14))dataclasses.dataclass is a class decorator that reads annotations and generates
__init__, __repr__ and comparison methods. Its options
are a good model for decorator design: frozen=True changes assignment
behaviour, slots=True changes storage, and order=True adds comparisons, all
without a subclass.
from dataclasses import dataclass
@dataclass(frozen=True, slots=True, order=True)
class Point:
x: int
y: int
print(Point(1, 2) < Point(1, 3))
print(hasattr(Point(1, 2), "__dict__"))8. Retries, Timing and Resource Decorators
A retry decorator needs more policy than most examples show. Retry only
idempotent operations, cap the attempts, add jitter, respect Retry-After, and
let the caller see the final exception. A bare except Exception around a
decorated call turns a bug into a delay.
import functools
import random
import time
def retry(*, attempts=3, base=0.05, exceptions=(TimeoutError,)):
def decorate(fn):
@functools.wraps(fn)
def wrapper(*args, **kwargs):
for attempt in range(1, attempts + 1):
try:
return fn(*args, **kwargs)
except exceptions:
if attempt == attempts:
raise
time.sleep(base * 2 ** (attempt - 1) * random.uniform(0.5, 1.5))
return wrapper
return decorate
attempts = {"n": 0}
@retry(attempts=4, base=0.01)
def poll():
attempts["n"] += 1
if attempts["n"] < 3:
raise TimeoutError("not yet")
return "ready"
print(poll(), attempts["n"])The timing decorator is the one most teams write first, and the one most likely
to be wrong. Use time.perf_counter, not time.time, because the wall clock can
jump. Record the exception path in finally, and emit the duration as a
structured field rather than a formatted string.
import functools
import time
def timed(fn):
@functools.wraps(fn)
def wrapper(*args, **kwargs):
start = time.perf_counter()
try:
return fn(*args, **kwargs)
finally:
elapsed = time.perf_counter() - start
print(f"{fn.__qualname__} {elapsed:.6f}s")
return wrapper
@timed
def sort_values(values):
return sorted(values)
print(sort_values([3, 1, 2]))A resource decorator that opens a connection must close it on every path. Write
it as a context manager and let ContextDecorator provide the decorator form,
which removes the duplicate try and finally.
Description
A state diagram of a decorated call, from wrapper entry through the try block, the original call, an optional exception, the finally block that records timing or releases a resource, and the exit.
9. Order, Composition and Failure Modes
Stacked decorators apply from the bottom up. The decorator closest to the function is applied first, so it is the innermost wrapper at call time. Reading order at the call site is therefore the reverse of reading order in the source.
import functools
def bold(fn):
@functools.wraps(fn)
def wrapper(*args, **kwargs):
return f"<b>{fn(*args, **kwargs)}</b>"
return wrapper
def italic(fn):
@functools.wraps(fn)
def wrapper(*args, **kwargs):
return f"<i>{fn(*args, **kwargs)}</i>"
return wrapper
@bold
@italic
def text():
return "hello"
print(text())The output is <b><i>hello</i></b>. italic was applied first, so it is
closest to the value, and bold wraps it. Swapping the two lines swaps the tags.
That is harmless for HTML and serious for authentication, caching and retries.
Order problem | Symptom | Fix |
|---|---|---|
Retry outside a timeout | Total time is attempts times the timeout | Put the timeout outside the retry, or budget the total |
Cache outside authentication | One user's response is served to another | Cache inside the authorization check, keyed by user |
Logging inside a silent wrapper | No log line for swallowed exceptions | Log outermost, or remove the swallowing wrapper |
Route decorator below a wrapper | Framework does not register the endpoint | Keep the framework decorator outermost |
Timing inside a retry | Only the last attempt is measured | Move timing outside the retry |
Double wrapping is the most common production accident. A base class decorates a method, a subclass copies the method and decorates it again, and the effect multiplies. The fix is to decorate once, at the definition site, and to add an idempotence guard when a decorator is applied by a library.
import functools
def once(fn):
if getattr(fn, "_decorated_once", False):
return fn
@functools.wraps(fn)
def wrapper(*args, **kwargs):
return fn(*args, **kwargs)
wrapper._decorated_once = True
return wrapperA decorator that catches Exception and returns None destroys the difference
between success and failure. A decorator that catches BaseException also
swallows KeyboardInterrupt and CancelledError. Catch the specific exception,
and let everything else propagate.
Description
A flowchart of a decorator review that asks whether the wrapper is idempotent, whether the order is correct, whether it preserves metadata, and whether it handles async and cancellation, with a rejection path for each failure.
How to answer
Ordering exercise with 6 items. Move them into the correct order, then check your answer.
Order the steps of reviewing a new decorator before merging it.
10. Measuring the Overhead
A wrapper costs a function call, a tuple and a dictionary. That is cheap per call and expensive in a loop. The numbers below come from CPython 3.14.4 on an eight-core Linux machine, with three million calls per case.
import functools
import time
N = 3_000_000
def plain(x):
return x + 1
def wrap(fn):
@functools.wraps(fn)
def wrapper(*args, **kwargs):
return fn(*args, **kwargs)
return wrapper
wrapped = wrap(plain)
class Callable:
def __init__(self, fn):
self.fn = fn
functools.update_wrapper(self, fn)
def __call__(self, *args, **kwargs):
return self.fn(*args, **kwargs)
class_obj = Callable(plain)
@functools.lru_cache(maxsize=None)
def cached(x):
return x + 1
cached(0)
def bench(fn, arg):
best = float("inf")
for _ in range(3):
start = time.perf_counter()
for _ in range(N):
fn(arg)
best = min(best, time.perf_counter() - start)
return best
print("plain", bench(plain, 1))
print("wrapped", bench(wrapped, 1))
print("callable", bench(class_obj, 1))
print("lru_cache", bench(cached, 1))Callable | Seconds for 3 million calls | Nanoseconds per call |
|---|---|---|
Plain function | 0.09 | 30 |
Function wrapper | 0.33 | 110 |
Class-based wrapper | 0.52 | 173 |
lru_cache hit | 0.12 | 40 |
Description
Bar chart of seconds for three million calls on CPython 3.14.4. A plain function takes 0.09 seconds, a function wrapper takes 0.33 seconds, a class based wrapper takes 0.52 seconds and an lru_cache hit takes 0.12 seconds.
The function wrapper adds about 80 nanoseconds per call on this machine, and the
class-based wrapper adds about 140. That is irrelevant for a network call and
relevant for a hot loop that runs a million times. lru_cache is cheaper than a
plain wrapper on a hit because it skips the Python-level call to the original,
which is why caching a pure function is often a net win even when the wrapper
costs something.
import functools
import time
N = 1_000_000
def plain(x):
return x * 2
@functools.cache
def cached(x):
return x * 2
cached(1)
start = time.perf_counter()
for index in range(N):
plain(index % 10)
plain_time = time.perf_counter() - start
start = time.perf_counter()
for index in range(N):
cached(index % 10)
cached_time = time.perf_counter() - start
print(round(plain_time, 3), round(cached_time, 3))The decision rule is the same as for any optimization: measure the real workload, keep the wrapper if the cost is below the noise of the work it wraps, and remove it from the hot path if it is not.
11. Case Studies
Case study: FastAPI path operation decorators
FastAPI registers an endpoint with @app.get, @app.post or @app.api_route.
The decorator stores the path, the response model and the dependencies, then
returns the function unchanged so the router can inspect its signature.
That is why the route decorator must be outermost: a wrapper above it would hide
the endpoint from registration, and a wrapper below it changes the signature the
router reads.
What to copy: return the original callable when the decorator only records metadata. What to avoid: stacking a custom decorator above the route decorator unless you have tested that the route still appears in the OpenAPI schema.
Case study: pytest fixtures
@pytest.fixture marks a function as a fixture and stores its configuration on
the function object, which the test collector reads later. The
scope and autouse options are factory arguments, and a fixture that yields
becomes a generator-based context manager that pytest finalizes after the test.
What to copy: keep the decorated function callable and put the metadata on an attribute, so the object still works when called directly. What to avoid: assuming a fixture is a normal function, because its arguments are resolved by name from other fixtures rather than passed by the caller.
Case study: Pydantic validators
Pydantic's @field_validator and @model_validator decorate class methods and
record when the validator runs, before or after the core validation
step. The decorator inspects the method kind, which is why
a validator on a class method and one on an instance method behave differently.
What to copy: make the decorator's configuration explicit with mode="before"
or mode="after" instead of relying on the wrapper's position. What to avoid:
a validator that mutates shared state, because it runs once per model instance
and the order across fields is not guaranteed.
12. What Changed in 2026
Change | Version | Why it matters |
|---|---|---|
functools.Placeholder | 3.14 | partial can reserve a positional slot instead of only pre-filling the left side |
Deferred annotations by default | 3.14 | A decorator that reads __annotations__ now evaluates them on demand |
annotationlib formats | 3.14 | Decorators and frameworks can request VALUE, FORWARDREF or STRING |
singledispatchmethod supports non-descriptor callables | 3.15 | Registrations that are not plain functions now work |
singledispatchmethod dispatches on the second argument | 3.15 | A method called on the class no longer misreads cls as the dispatch value |
TypeForm | 3.15 | Libraries that accept type expressions can annotate them precisely |
functools.Placeholder is the small change with the widest reach. Before 3.14,
partial could only bind the leading positional arguments, so a decorator that
wanted to pre-fill a later slot had to wrap the call in a lambda.
With Placeholder, the slot is reserved and filled at call time.
import functools
def report(prefix, value, suffix):
return f"{prefix}{value}{suffix}"
labelled = functools.partial(report, "[", functools.Placeholder, "]")
print(labelled("42"))PEP 649 changes what a decorator sees when it reads fn.__annotations__. On
3.14 and newer the annotations are evaluated on access, so a name that is not yet
defined raises NameError at that moment instead of at import. A
decorator that only copies metadata should not force evaluation; use
annotationlib.get_annotations with Format.STRING or Format.FORWARDREF when
the names may not exist yet.
The 3.15 singledispatchmethod changes fix two long-standing sharp edges. A
registration can now be a callable that is not a descriptor, and a method called
as a class attribute dispatches on the second argument instead of on cls.
Libraries that register bound helpers no longer need a wrapper function purely
to satisfy the dispatcher.
PEP 747 adds TypeForm, which lets a function annotate an argument that is
itself a type expression, such as int or list[str]. A decorator
that accepts a type for registration, validation or dependency lookup can now
say so, and type checkers can reject a value that is not a type form.
π Did you know?
functools.wrapscopies__type_params__, so a decorator on a generic function keeps the type parameter list visible to introspection.- A
classmethodobject is not callable, which is why@classmethodmust be the outermost decorator when it is combined with a custom wrapper. functools.cacheislru_cache(maxsize=None)with a shorter name, and it keeps every result untilcache_clear()is called.
2026 Updates (as of November 2026)
- Python 3.15.2 is the current stable release, and 3.15 is the version that adds
the
singledispatchmethodfixes described above. functools.Placeholderis available since 3.14 forpartialandpartialmethod.- PEP 649 deferred annotations are the default since 3.14, so decorators that
inspect annotations should use
annotationlib. TypeFormis available since 3.15 for libraries that accept type expressions.
Description
A short video that explains how ParamSpec preserves the parameter list of a decorated function, why Callable with an ellipsis loses type information, and how Concatenate adds a fixed argument.
13. Practice Questions
How to answer
Multiple-choice question with 4 options. Select one option, then choose Check Answer.
A decorator contains a print statement outside the wrapper. When does that print run?
How to answer
Multiple-choice question with 4 options. Select one option, then choose Check Answer.
What does functools.wraps add that a plain wrapper lacks?
How to answer
Multiple-choice question with 4 options. Select one option, then choose Check Answer.
Why does a decorator typed as Callable[..., Any] make type checkers less useful?
How to answer
Multiple-choice question with 4 options. Select one option, then choose Check Answer.
A callable class is used as a method decorator but instance.method() fails with a missing argument. What is missing?
How to answer
Multiple-choice question with 4 options. Select one option, then choose Check Answer.
With @bold above @italic above a function, which decorator is applied first?
How to answer
Multiple-choice question with 4 options. Select one option, then choose Check Answer.
Why should the wrapper around an async function usually be async def?
How to answer
Multiple-choice question with 4 options. Select one option, then choose Check Answer.
Why can lru_cache on an instance method cause a memory leak?
How to answer
Multiple-choice question with 4 options. Select one option, then choose Check Answer.
What does functools.singledispatch use to choose an implementation?
How to answer
Multiple-choice question with 4 options. Select one option, then choose Check Answer.
What does subclassing contextlib.ContextDecorator add to a context manager?
How to answer
Multiple-choice question with 4 options. Select one option, then choose Check Answer.
What does functools.Placeholder allow in Python 3.14?
Key Takeaways
- The decorator runs at definition time, once. The wrapper runs on every call. Confusing the two explains most "why did this print twice" bugs.
- A decorator with configuration needs three levels: factory, decorator,
wrapper. Make configuration keyword-only so
@retry(3)fails loudly. functools.wrapscopies metadata and sets__wrapped__.inspect.signaturefollows that link, andinspect.unwrapwalks the chain.- Type decorators with
ParamSpec, and addConcatenatewhen the wrapper injects a leading argument.Callable[..., Any]throws the signature away. - A function wrapper binds to methods automatically. A class-based decorator
needs
__get__andtypes.MethodTypeto do the same. - Async functions need async wrappers, which await the original and re-raise
CancelledErrorafter cleanup. - Decorators apply bottom up. The order of authentication, caching, retry and timeout decorators changes behaviour, so document and test it.
- Measure the wrapper. On this machine a function wrapper added about 80 nanoseconds per call, and a class-based wrapper about 140.
- FastAPI, pytest and Pydantic all keep the decorated function callable and store configuration on it. Copy that pattern, not the private internals.
References
Every factual claim links to its source, with the access date printed so the source can be rechecked.