In this article, you will learn how Python decorators work under the hood and how to build reliable ones for timing, retries, caching, and input validation.
Topics we will cover include:
- How the @ syntax, closures, and functools.wraps combine to make a decorator that preserves the original function’s behavior and metadata.
- How to build a timing decorator and a configurable retry decorator factory that never hide exceptions.
- When to use functools.lru_cache and a signature-based validation decorator, and when a decorator is the wrong tool.

The feature pipeline module started with three clean functions. One loads embeddings from a local store, one fetches a vocabulary from an internal service, and one transforms a batch. Six weeks later, a third of each of them is boundary code. load_embeddings checks that its path exists and its batch size is positive before it does anything. fetch_vocab wraps its request in a for attempt in range(3) loop with a time.sleep() inside it. transform_batch reads time.perf_counter() twice and prints the difference, and a module-level dictionary in front of it holds results by key so the same batch isn’t transformed twice. None of that boundary code is the function’s job. All of it was pasted in by hand, so the three functions retry, time and guard their inputs in three slightly different ways. The next function someone adds will get a fourth.
def fetch_vocab(name, attempts=3): if not name: raise ValueError("name must not be empty") start = time.perf_counter() for attempt in range(attempts): try: vocab = _request_vocab(name) # the actual work break except TimeoutError: if attempt == attempts - 1: raise time.sleep(0.5) print(f"fetch_vocab took {time.perf_counter() - start:.3f}s") return vocab |
Timing, retrying, caching and checking inputs all happen at the same place: the moment a call enters the function and the moment it leaves. Python has a construct for exactly that place. A decorator takes a function and hands back a wrapper that runs code before and after the original. The repeated boundary logic can then live once, in the wrapper, and be applied with one line above each def. If the same logic has to run around every call, why keep writing it inside each function? Most people learn decorators as things you use, from @lru_cache or a web framework’s @app.route, and rarely as things you build. That gap is why a homemade retry decorator so often swallows the exception it was meant to report. This tutorial goes the other way: syntax first, the closure underneath it, the metadata fix every wrapper needs, and then the four boundary behaviors in turn, ending with a rule for when a decorator is the wrong tool.
Decorators Are Function Reassignments With Better Syntax
The line that removes the magic is an assignment. Given a function announce that accepts a function and returns a new one, these two definitions of greet leave the same kind of object bound to the same name.
def announce(func): def wrapper(): print("calling", func.__name__) return func() return wrapper
def greet(): return "hello"
greet = announce(greet) # greet is now the wrapper
@announce # identical outcome def greet(): return "hello" |
The Python glossary calls the @ form syntactic sugar for that reassignment. The language reference spells out when it happens: decorator expressions are evaluated when the function is defined, in the scope that contains the definition, and the value they return is bound to the function name in place of the function object. Two consequences follow from “when the function is defined”. The decorator itself runs once, at import time, so anything it does outside the wrapper happens before any call, whether that is registering the function in a table or printing a line. And whatever the decorator returns is what the name greet means from then on. A decorator that forgets to return anything leaves greet bound to None, and the first call raises TypeError.
Stacked decorators nest. @f1 above @f2 above def func is func = f1(f2(func)), so the bottom decorator wraps the function first and the top one wraps the result. Hold onto that ordering. It decides which decorator sees a cached result and which one sees a validation error, and the last section comes back to it.
Closures Make the Wrapper Remember the Function
The announce above only works for functions that take no arguments, which makes it useless. The general shape has three parts, and every decorator in this article is a variation on it.
def announce(func): def wrapper(*args, **kwargs): print("calling", func.__name__) result = func(*args, **kwargs) print("returned", result) return result return wrapper |
The outer function receives func, the inner wrapper does the work at call time, and the outer function returns wrapper without calling it. What makes this work is that wrapper can still see func after announce has returned. Python functions close over the names of their enclosing scope, so the func inside wrapper is the specific function announce was handed, kept alive for as long as the wrapper exists. Ten decorated functions get ten wrappers, each remembering its own original. *args and **kwargs forward whatever the caller passed, positional or keyword, and return result hands back whatever the original produced. Drop either and the decorator has changed the function’s contract, which is the one thing a boundary wrapper must not do.
Reading func from the closure is free. Rebinding a name in it is not. A wrapper that counts calls needs nonlocal count before count += 1, because without the declaration the assignment creates a new local and the count never leaves zero. The nonlocal statement exists for exactly this, and it is the right tool for one counter or one “already warned” flag. A wrapper that needs more state than that is better off as a class with a __call__ method. The state then becomes attributes a test can inspect, instead of four hidden variables in a closure.

Five moments, two of them at definition time and three at call time, and the closure is the line between them. Credit: original diagram based on the Python language reference for function definitions and the functools documentation.
Preserve Function Identity With functools.wraps
Decorate transform_batch with the announce above and ask Python what it is.
@announce def transform_batch(batch, size=64): """Apply the fitted transform to one batch.""" ...
print(transform_batch.__name__) # wrapper print(transform_batch.__doc__) # None help(transform_batch) # documents wrapper(*args, **kwargs) |
The name is gone, the docstring is gone, and the signature help() shows is the wrapper’s catch-all. Anything that reads that metadata breaks in a way that is hard to trace. A traceback names wrapper on every decorated function, a doc generator emits empty entries, and a profiler lists twelve functions all called wrapper. The fix is one line above the inner function.
import functools
def announce(func): @functools.wraps(func) def wrapper(*args, **kwargs): print("calling", func.__name__) return func(*args, **kwargs) return wrapper |
functools.wraps copies the original’s name, qualified name, module, docstring and annotations onto the wrapper, and it sets __wrapped__ to the original function. The documentation’s own description of the alternative is that the metadata reflects the wrapper “rather than the original function definition, which is typically less than helpful”. __wrapped__ is the part people forget about. That attribute gives tests and tooling a route back to the undecorated function. A unit test can call transform_batch.__wrapped__(batch) to check the transform with no timing or retry in the way, and inspect.signature() follows __wrapped__ by default, so the reported signature is the original’s. From here on, every decorator in this article carries @functools.wraps(func). That is the template: outer function, @wraps, inner wrapper forwarding *args, **kwargs, return the wrapper.
Time a Call Without Changing Its Job
The first useful decorator replaces the two perf_counter() lines from the opening.
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
import functools, time
def timed(func): @functools.wraps(func) def wrapper(*args, **kwargs): start = time.perf_counter() status = "failed" try: result = func(*args, **kwargs) status = "ok" return result finally: elapsed = time.perf_counter() - start print(f"{func.__name__}: {elapsed:.3f}s ({status})") return wrapper
@timed def transform_batch(batch, size=64): ... |
The try/finally is the part that matters. The elapsed time is printed on both paths out of the call. A batch transform that raises still reports how long it ran before it died, which is the number you want when the real question is whether it hung or crashed. The exception continues to the caller untouched, because finally doesn’t catch anything, and the return value passes through return result unchanged. Nothing about what transform_batch does has moved; only the observation was added around it.
perf_counter() is the right clock for the subtraction. The documentation describes it as the clock with the highest available resolution for measuring a short duration, with an undefined reference point, so only the difference between two readings is a valid number. Don’t log the raw value as a timestamp; it isn’t one. Use time.time() or datetime when the log needs to say when, and perf_counter() when it needs to say how long. Keep the ambition in proportion too. One wrapper printing one line measures one call in one process. That is enough to find out that the tokenizer step is the slow one, and nowhere near enough to compare two implementations, which is a job for timeit or a profiler.
Build Retry as a Decorator Factory
timed takes no options. A retry wrapper can’t get away with that. Attempts, delay and which exceptions count as transient all depend on the call, so a decorator hard-coded to three attempts and TimeoutError gets copied and edited within a week. The decorator needs arguments, and a decorator with arguments is one layer deeper. @retry(max_attempts=3) first calls retry(max_attempts=3), which returns a decorator, and that decorator then receives the function. The language reference’s equivalence covers this directly: @f1(arg) above def func is func = f1(arg)(func).
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 |
import functools, time
def retry(max_attempts=3, delay=0.5, exceptions=(TimeoutError,)): def decorator(func): @functools.wraps(func) def wrapper(*args, **kwargs): for attempt in range(1, max_attempts + 1): try: return func(*args, **kwargs) except exceptions as exc: if attempt == max_attempts: raise print(f"{func.__name__} attempt {attempt} failed: {exc!r}; retrying") time.sleep(delay) return wrapper return decorator
@retry(max_attempts=3, delay=0.5, exceptions=(TimeoutError, ConnectionError)) def fetch_vocab(name): return _request_vocab(name) |
Three layers, and each does one thing: retry captures the options, decorator captures the function, wrapper runs the attempts. Two decisions in wrapper carry the safety. The except clause names only the configured exceptions. A KeyError from a bug in _request_vocab therefore propagates on the first attempt instead of being retried three times and blamed on the network. And on the last attempt the bare raise re-raises the exception that just occurred, traceback intact. A retry decorator that returns None after exhausting its attempts has converted a failure into a silent wrong answer, which is worse than the failure.
time.sleep() is the simple teaching choice for the delay. The documentation is precise about what it does: it suspends the calling thread for the given number of seconds, and the suspension may run longer than requested because of other activity on the system. Inside a threaded worker pool, a sleeping retry is holding its thread. For a service call from a script it is fine. A production client usually takes its retry policy from the client library or from a dedicated library with backoff and jitter. Those libraries are built on the same three layers as the wrapper above.
One honest caveat: retries are only safe for calls that can run twice without harm. Fetching a vocabulary is one of those. Posting a payment, sending a message, appending to a file or inserting a row is not, because a timeout that arrives after the write went through will trigger a second write. For those operations the retry policy belongs next to the operation’s own idempotency key or consistency rules. A general-purpose @retry on top of them is a bug waiting for a slow network.
Cache Only Calls That Can Safely Repeat
The module-level dictionary in front of transform_batch was a cache written by hand: no size limit, no way to inspect it, and a key built from whatever the author thought of at the time. The standard library has already written that cache properly. functools.lru_cache wraps a function in a memoizing callable. It keeps the results of the most recent calls, up to maxsize, keyed on the arguments.
import functools
@functools.lru_cache(maxsize=256) def token_count(text, model_name="base"): return len(_tokenizer(model_name).encode(text))
token_count("the same prompt") # computed token_count("the same prompt") # returned from the cache print(token_count.cache_info()) # CacheInfo(hits=1, misses=1, maxsize=256, currsize=1) token_count.cache_clear() # empty it, for example after a tokenizer change |
cache_info() reports hits, misses, the limit and the current size. That is how you find out whether the cache is earning its memory; a hit count near zero means the arguments never repeat and the decorator is pure overhead. cache_clear() empties it. __wrapped__ is there too, so token_count.__wrapped__(text) bypasses the cache when a test needs the real computation. maxsize=None removes the limit. That is right for a small, fixed domain and wrong for a long-running process fed by user input, where the cache turns into a slow memory leak.
The constraints come from the implementation. A dictionary holds the results, so every argument has to be hashable; pass a list and the call raises TypeError before the function runs. The cache holds references to the arguments and return values until they are evicted or cleared, so a cached function that takes a large array keeps the array alive. Keyword order can matter to the key, so f(a=1, b=2) and f(b=2, a=1) may occupy two entries. The documentation’s list of what not to cache is short: functions with side effects, functions that need to create a distinct mutable object on each call, and impure functions such as time() or random(). The mutable-object rule produces the strangest bugs. If token_count returned a list, every caller would receive the same list object, and the first one to append to it would change the answer for everyone after.
A custom cache decorator built from the closure template above is about fifteen lines, a reasonable exercise and rarely the right call in real code. lru_cache is thread-safe, gives you the introspection above for free and has had its edge cases found by other people. Write your own when you need something it doesn’t do, such as a time-to-live or a key that ignores one argument, and say so in the docstring.
Validate Inputs at the Function Boundary
Of the four patterns, validation is the only one where *args, **kwargs gets in the way. The wrapper needs to check size, and size might have arrived positionally, by keyword or not at all, through a default. inspect.signature() solves that. It returns a Signature for the function, and Signature.bind() maps the actual positional and keyword arguments onto the parameter names exactly as the call would, raising TypeError if they don’t fit.
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 |
import functools, inspect
def check(**rules): def decorator(func): sig = inspect.signature(func) @functools.wraps(func) def wrapper(*args, **kwargs): bound = sig.bind(*args, **kwargs) bound.apply_defaults() for name, rule in rules.items(): value = bound.arguments[name] if not rule(value): raise ValueError(f"{func.__name__}: {name}={value!r} failed {rule.__name__}") return func(*args, **kwargs) return wrapper return decorator
def positive(v): return v > 0 def unit_interval(v): return 0.0 <= v <= 1.0
@check(size=positive, threshold=unit_interval) def transform_batch(batch, size=64, threshold=0.5): ...
transform_batch(batch, 0) # ValueError: transform_batch: size=0 failed positive |
The signature is computed once, at decoration time, because it doesn’t change. bind() runs on every call and returns a BoundArguments whose arguments mapping goes from parameter name to value. apply_defaults() fills in the parameters the caller left out, so a rule on threshold still runs when the default 0.5 is in play. Each rule is a plain predicate with a name, which is what makes the error message readable. The check raises before func is called, so a bad size never reaches the transform, and a good one costs one bind() per call. Wrong argument counts are handled too, though not by your code: bind() raises a TypeError, just as the call would have, only earlier.
This decorator leaves two jobs alone, on purpose. It doesn’t read annotations: size: int on the parameter list is documentation for readers and type checkers, and at runtime Python does nothing with it. An annotation enforces nothing unless a library or explicit code reads __annotations__ and checks, so add rules for the invariants that matter and leave the types to the checker. And it doesn’t scale into a data model. Two or three predicates on one function’s arguments is the right size for this decorator. Once the arguments are a record with fields that depend on each other, the validation belongs on the record, which is what dataclasses with a __post_init__ or a library such as Pydantic are for; the earlier tutorial on dataclasses for structured application data covers that boundary. A decorator guards a call; it shouldn’t try to be a schema.
Keep Decorators Small Enough to Trust
The four patterns fit in one sentence in the order they were built: timing observes the call, retry repeats it, caching remembers it, validation rejects it early. Each one earns a decorator for the same reason. The behavior belongs at the boundary of many functions, it is identical at every one of them, and it has nothing to do with what any of those functions computes. The same reason is the test for whether a new decorator should exist. Business logic that varies by function stays in the function. A decorator that reads the arguments to decide what the function should do has hidden part of the function somewhere the reader won’t look.
| Pattern | Good fit | Avoid when | Primary caveat |
|---|---|---|---|
timing @timed |
Finding which stage of a job is slow; one call, one process, one printed line | Comparing implementations or machines; that is timeit or a profiler | perf_counter() differences only; try/finally so a failed call still reports |
retry factory @retry(max_attempts=3, exceptions=(TimeoutError,)) |
Read-like calls that can run twice without harm, with a narrow exception tuple | Writes, payments, messages, deletes; anything with a side effect | Re-raise on the last attempt; sleep() blocks the thread and can overshoot |
caching @functools.lru_cache(maxsize=256) |
Pure functions with hashable arguments that repeat | Side effects, time-sensitive results, functions returning mutable objects | Arguments and results stay referenced until eviction; maxsize=None never evicts |
validation @check(size=positive) |
Two or three invariants on one function’s arguments, checked before the call | Records with fields that depend on each other; use a dataclass or a schema library | Annotations enforce nothing at runtime; bind() raises TypeError for a bad call shape |
The four boundary behaviors and the case each one is wrong for. Credit: original table based on the Python functools, time and inspect documentation.
Whatever the pattern, the wrapper keeps to the contract that runs through this article. It carries @functools.wraps, it forwards every argument and returns the original’s result, it never turns an exception into None, and any state it holds is small enough to inspect. Stacking is where that contract gets tested. Decorators are applied bottom-up, so the top one is the outermost wrapper and runs first on each call. Put the check outermost and the cache inside it if the cache should never store a rejected call, and put @timed on the outside if the number should include the retries. That first order has a cost worth knowing: with @check above @lru_cache, the outer wrapper is a plain function, so cache_info() is reachable only through transform_batch.__wrapped__. Three decorators whose order matters is about the limit at which a reader can still predict what a call will do. Past that, write a function that composes them explicitly.
The patterns in the earlier guide to production Python decorators are these same mechanics with logging, memory checks and monitoring attached. They are only as trustworthy as the closure, the wraps and the re-raise underneath them. A decorator’s job is to make a function’s boundary clearer than it was. The moment it makes the boundary cleverer instead, the function was better off with the four pasted lines.