Advanced Decorators
Advanced⏱75 minutesπŸ“–15 min readLesson 4 of 10

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.

ℹ️Note

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

Decorator
A callable that takes a function or class and returns a replacement for it.
Decorator factory
A callable that returns a decorator, used when the decorator needs configuration such as a retry count.
Definition time
The moment the class or function statement runs, which is when the decorator itself is applied.
Call time
The moment the decorated function is invoked, which is when the wrapper body runs.
Wrapper
The function or object returned by a decorator, which runs on every call and usually delegates to the original.
wraps
The functools helper that copies metadata to a wrapper and records the original in __wrapped__.
ParamSpec
A typing construct that lets a decorator declare that the wrapper accepts exactly the parameters of the function it wraps.
Concatenate
A typing construct that adds fixed leading parameters to a ParamSpec, used when a decorator injects an argument.
Descriptor
An object that defines __get__, __set__ or __delete__ and controls attribute access on a class.
ContextDecorator
A contextlib base class that lets a context manager also be used as a decorator.
Singledispatch
A functools decorator that turns a function into a dispatcher which chooses an implementation from the type of the first argument.
Decorator order
The rule that the decorator closest to the function is applied first and therefore runs innermost at call time.

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.

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

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

100%
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.

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

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

Fill in the Blanks
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:

decorate

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

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

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

plaintext
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) # PicklingError

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

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

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

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

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

Matching Question
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.

ParamSpec
Drop here
Concatenate
Drop here
TypeVar
Drop here
overload
Drop here
Protocol
Drop here

Drag answers from here:

Keep the wrapped function's parameter list
Add a fixed leading parameter to the wrapper
Keep the return type tied to the wrapped function
Describe several call signatures for one name
Describe a structural type that a wrapper can satisfy

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.

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

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

100%
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.

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

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

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

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

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

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

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

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

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

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

100%
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.

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

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

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

100%
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.

Ordering Question
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.

Check whether the decorator runs at definition time or call time
Confirm that metadata and the signature survive the wrapper
Verify the order relative to framework and auth decorators
Test async functions and cancellation if the code can be async
Measure the per-call overhead on a realistic workload
Add a test that fails when the wrapper swallows an exception

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.

plaintext
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
Seconds for three million calls through four callables
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.

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

python
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.wraps copies __type_params__, so a decorator on a generic function keeps the type parameter list visible to introspection.
  • A classmethod object is not callable, which is why @classmethod must be the outermost decorator when it is combined with a custom wrapper.
  • functools.cache is lru_cache(maxsize=None) with a shorter name, and it keeps every result until cache_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 singledispatchmethod fixes described above.
  • functools.Placeholder is available since 3.14 for partial and partialmethod.
  • PEP 649 deferred annotations are the default since 3.14, so decorators that inspect annotations should use annotationlib.
  • TypeForm is available since 3.15 for libraries that accept type expressions.
anthonywritescodeWatch on YouTube
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

Practice Question
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?

Practice Question
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?

Practice Question
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?

Practice Question
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?

Practice Question
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?

Practice Question
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?

Practice Question
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?

Practice Question
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?

Practice Question
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?

Practice Question
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?

βœ…Success

Key Takeaways

  1. The decorator runs at definition time, once. The wrapper runs on every call. Confusing the two explains most "why did this print twice" bugs.
  2. A decorator with configuration needs three levels: factory, decorator, wrapper. Make configuration keyword-only so @retry(3) fails loudly.
  3. functools.wraps copies metadata and sets __wrapped__. inspect.signature follows that link, and inspect.unwrap walks the chain.
  4. Type decorators with ParamSpec, and add Concatenate when the wrapper injects a leading argument. Callable[..., Any] throws the signature away.
  5. A function wrapper binds to methods automatically. A class-based decorator needs __get__ and types.MethodType to do the same.
  6. Async functions need async wrappers, which await the original and re-raise CancelledError after cleanup.
  7. Decorators apply bottom up. The order of authentication, caching, retry and timeout decorators changes behaviour, so document and test it.
  8. Measure the wrapper. On this machine a function wrapper added about 80 nanoseconds per call, and a class-based wrapper about 140.
  9. 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.

  1. Python Software Foundation Β· Kevin D. Smith, Jim J. Jewett, Skip Montanaro Β· Accessed 2026-11-08

  2. Python Software Foundation Β· Collin Winter Β· Accessed 2026-11-08

  3. Python Software Foundation Β· Accessed 2026-11-08

  4. Python Software Foundation Β· Accessed 2026-11-08

  5. Python Software Foundation Β· Mark Mendoza Β· Accessed 2026-11-08

  6. Python Software Foundation Β· Accessed 2026-11-08

  7. Python Software Foundation Β· Accessed 2026-11-08

  8. Python Software Foundation Β· Accessed 2026-11-08

  9. FastAPI Β· Accessed 2026-11-08

  10. pytest project Β· Accessed 2026-11-08

  11. [11]Validators, PydanticDocumentation

    Pydantic Β· Accessed 2026-11-08

  12. [12]What's New In Python 3.14Documentation

    Python Software Foundation Β· Accessed 2026-11-08

  13. Python Software Foundation Β· Eric V. Smith Β· Accessed 2026-11-08

  14. Python Software Foundation Β· Accessed 2026-11-08

  15. [15]What's New In Python 3.15Documentation

    Python Software Foundation Β· Accessed 2026-11-08

  16. Python Software Foundation Β· Jelle Zijlstra Β· Accessed 2026-11-08

Progress40%