AlgoMaster Logo

Decorators

High Priority20 min readUpdated June 6, 2026
Listen to this chapter
Unlock Audio

A decorator is a function that takes another function, wraps it in some extra behavior, and returns the wrapped version. The @decorator line above a def is Python's syntax for this pattern, but the mechanics are functions taking functions and returning functions. Decorators are used for timing, logging, caching, authentication, retries, and registering routes in a web framework. This lesson covers the basic shape, the @ sugar, decorators that take their own arguments, the functools.wraps helper that keeps the wrapped function's identity intact, stacking, common patterns, and a few performance notes.

What a Decorator Does

A decorator is a function that takes a function as input and returns a function as output. That sentence is the whole idea. Everything else is mechanics.

Consider timing an order-pricing function. Placing time.perf_counter() calls around every call site is noisy and easy to forget. A decorator allows the timing logic to be written once and applied to any function with one line.

Three things happened. time_it(price_order) took the original function as input. It defined a new function wrapper that does the timing around a call to the original. It returned wrapper. The name price_order was then rebound to point at the wrapper instead of the original.

The next call, price_order(3, 29.99, 0.10), runs the wrapper. The wrapper records the start time, calls the real pricing function (which it captured as func), records the end time, prints the elapsed time, and returns the result. From the caller's point of view, price_order still computes an order price. From the inside, every call now also reports its timing.

The pattern is common enough that Python has dedicated syntax for it.

The @decorator Syntax

Writing price_order = time_it(price_order) after every definition is repetitive. Python provides the @ syntax to do the same rebinding inline:

The @time_it line above the def is syntactic sugar. It means the same thing as writing price_order = time_it(price_order) after the definition. Python sees the @, runs the decorator on the function it precedes, and rebinds the name.

The decorator runs once, at the time the function is defined. The wrapper it returns runs every time the decorated function is called. That distinction matters: any setup the decorator does (parsing config, opening a connection, allocating a cache) happens once at definition time, while the wrapper logic happens on every call.

What is func bound to inside the wrapper after time_it has returned? The original, undecorated function. The wrapper captured it as a free variable (a closure cell). When the wrapper runs, it can still call func and get the original behavior, even though the name price_order now points at the wrapper.

A Logging Decorator

A second example shows the same shape applied to a different problem. Logging is a common reason to write a decorator: every call to a particular function should record its arguments and result somewhere.

The decorator has the same shape as the timing one: take a function, define a wrapper that runs extra code around a call to the original, return the wrapper. The wrapper signature is always (*args, **kwargs) because the decorated function's signature isn't known in advance, and the wrapper must forward whatever the caller passes.

The *args and **kwargs pattern makes decorators reusable. The same log_calls decorator can wrap a function that takes one argument, three arguments, or any other shape, because the wrapper accepts everything and forwards everything.

The output shows that the wrapper's print calls happen before and after the original function runs. The wrapped function's behavior is unchanged; the decorator sandwiches extra code around it.

Each call through a decorator adds one extra function call (the wrapper) and the overhead of building args and kwargs. For most code this is nanoseconds and invisible. For a function called millions of times in a tight loop, decorators can become a measurable cost; profile before assuming so.

Preserving Metadata with functools.wraps

The decorators above have a problem: the wrapper takes over the original function's identity. The name and docstring change accordingly:

The name price_order.__name__ is now "wrapper", because price_order is bound to the wrapper function and the wrapper was named wrapper. The docstring is gone for the same reason: the wrapper has no docstring of its own. Tools that read __name__ (debuggers, logging, traceback printers) and tools that read __doc__ (help systems, doc generators) get the wrong information.

The fix is functools.wraps, a decorator-for-decorators that copies the original function's metadata onto the wrapper. Apply it inside the decorator, to the wrapper:

@wraps(func) copies func.__name__, func.__doc__, func.__module__, func.__qualname__, func.__annotations__, and a few other attributes onto the wrapper. From the outside, the wrapper now looks like the original. Tools and humans both see the right name and docstring.

The rule: always use `@wraps(func)` on the wrapper inside a decorator. There's no reason not to, and forgetting it produces subtle bugs that show up only when something inspects the function's metadata.

help reads __name__ and __doc__, and because of @wraps, it sees the right function. Without @wraps, help(cart_total) would show Help on function wrapper, which is wrong and confusing.

Decorators with Arguments

The decorators so far take only the function. Some decorators need configuration: a retry decorator that takes a count, a cache decorator that takes a maximum size, a timing decorator that takes a label. The way to do this is to write a decorator factory: a function that takes the configuration and returns a real decorator.

Three layers of functions. retry(times=3) is the outermost call; it captures times and returns the real decorator. That decorator takes charge_card and returns the wrapper. The wrapper does the retry loop, calling the original function up to times times.

The shape @retry(times=3) is now a function call, not a bare name. Python evaluates retry(times=3) first, which produces a decorator. Then it applies that decorator to charge_card like any other decorator. The difference from a plain @time_it (no parentheses) is that this one has parentheses with arguments inside.

The pattern is "factory returns a decorator, decorator returns a wrapper". Every decorator with arguments follows this shape.

A more realistic version uses a real delay between retries:

The factory takes two arguments with defaults; the decorator captures both and uses them inside the wrapper. The shape is the same; the wrapper has more to do.

One technique sometimes used: making the parentheses optional, so @retry and @retry(times=3) both work. It's occasionally useful, but it makes the decorator harder to read. The straightforward approach (always require parentheses for configurable decorators) is more common.

Stacking Decorators

More than one decorator can be applied to the same function. The order matters, and the rule is: decorators apply from bottom up, meaning the one closest to the def runs first, then the one above it, and so on.

Read the stack from bottom to top. @add_exclamation is closest to the def, so Python applies it first: greet = add_exclamation(greet). Then @log_calls applies to the result: greet = log_calls(greet). The final greet is log_calls's wrapper, which wraps add_exclamation's wrapper, which wraps the real greet.

Calling greet("alice") flows control through the layers from outside in. First the log_calls wrapper runs and prints [log] calling greet. Then it calls the next layer, the add_exclamation wrapper. That wrapper calls the real greet, which returns "Hello, alice", then appends "!", returning "Hello, alice!". Control returns to log_calls, which returns the same value to the caller.

Swap the order and the behavior changes:

Same output in this case, but the call order is different. Now log_calls is the inner wrapper and add_exclamation is the outer one. When greet("alice") runs, add_exclamation's wrapper calls log_calls's wrapper, which prints the log line and then calls the real function. The [log] calling greet happens after entering add_exclamation but before entering the real function.

The order matters more when the decorators do non-commutative things. A @cache outside a @log_calls means cached results don't get logged; a @log_calls outside a @cache means every call gets logged, even cached ones. Choose the order based on the desired behavior.

The diagram shows the call cycle for the first stack (@log_calls on top). Each wrapper passes through to the next layer, the innermost function runs, and the result bubbles back out, picking up modifications on the way.

Common Decorator Patterns

Some decorators appear frequently enough that recognizing them is part of reading Python. The most common shapes follow, in compact form.

Caching with functools.lru_cache

A cache decorator stores the results of a function so repeat calls with the same arguments don't recompute. Python's functools.lru_cache is the standard implementation:

Without the cache, fibonacci(30) would make millions of recursive calls. With it, each unique argument is computed once and reused. cache_info() shows the hit and miss counts. One of the most useful real-world decorators (lru_cache) ships in the standard library.

Authorization Checks

Web frameworks and APIs often use a decorator to check that the current user is allowed to call a function. The wrapper checks a permission and either calls the wrapped function or raises an error.

The wrapper checks the user's role before calling the real function. If the check fails, the original function never runs. This is the shape underlying most web framework @login_required and @requires_permission decorators.

Input Validation

A decorator can validate arguments before they reach the function, raising a clear error if something is wrong.

The validation logic lives in one place and applies to any function that needs it. The wrapped function doesn't need to know that validation happens; it runs with the assurance that its arguments are sane.

Registering Functions

A decorator can have a side effect: registering the decorated function in a list, dict, or other registry. The function is still returned unchanged, so it works normally, but the registry now knows about it.

The decorator doesn't wrap the function in this case; it stores a reference and returns the original. Frameworks like Flask use this pattern for route registration: @app.route("/cart") doesn't change the function; it tells the framework "when a request comes for /cart, call this function".

The exact memory address in the function repr varies between runs; the shape is what matters.

Class-Based Decorators (Brief)

A decorator doesn't have to be a function. Any callable will do, and the most common alternative is a class with a __call__ method. The class version is useful when the decorator has state that should persist across calls, or when methods on the decorator itself are needed.

@CountCalls rebinds cart_total to an instance of the CountCalls class. Each call goes through __call__, which increments the counter and forwards to the real function. The count attribute is accessible from outside, which would be awkward with a closure-based decorator.

For now, the takeaway is that a decorator is anything callable that takes and returns a function, and a class with __call__ is one shape that fits.

Performance Considerations

Decorators have a real but usually negligible runtime cost. Three sources contribute:

The first is the extra function call per invocation. The wrapper is itself a function, so every call to the decorated function does at least two function calls instead of one. On modern Python, that's tens to hundreds of nanoseconds. For most code this is invisible.

The second is building `args` and `kwargs`. The standard def wrapper(*args, **kwargs) pattern builds a new tuple and a new dict for every call, then unpacks them when calling the underlying function. Usually invisible, but in a tight loop calling a decorated function millions of times, this can dominate.

The third is whatever the wrapper itself does. A logging decorator that does I/O on every call is slow because logging is slow, not because decorators are slow. A caching decorator that hashes complex arguments to look them up is slow because hashing complex arguments is slow.

The exact numbers depend on the machine and Python version; the shape is what matters. Per-call overhead is in the tens of nanoseconds for a pass-through wrapper. That's roughly the time it takes a modern CPU to execute a few hundred instructions, far below the threshold where any normal program would notice.

A pass-through decorator typically adds 50 to 150 ns per call on modern Python. If the decorated function does any real work (a database query, an HTTP request, a hash, a list iteration over more than a handful of elements), the decorator overhead is rounding error. Optimize away from decorators only when measurement says the wrapper is a bottleneck.

Real Built-In Decorators

Three decorators appear in everyday Python code, all from the standard library or built-ins. Knowing what they do makes a lot of real code easier to read.

`@property` turns a method into a read-only attribute. obj.some_property runs the method and returns the result, as if reading an attribute.

cart.total is written like an attribute access (no parentheses) but actually runs the method. @property is a decorator like any other.

`@staticmethod` marks a method that doesn't depend on the instance or the class. It's used inside class bodies and doesn't receive self.

No self parameter, no instance needed. The decorator changes how Python treats the function inside the class.

`@cache` (Python 3.9+) and `@lru_cache` memoize a function's results. The functools lesson covers them in detail.

The second call to slow_lookup("sku-1") doesn't print "computing"; the cache returned the previous result. The function only runs once per unique argument.

These three decorators (and a few others like @classmethod and @dataclass) cover most of the decorator usage in real code. Once the mechanism is clear, recognizing them is a matter of knowing what each one does.

Quiz

Decorators Quiz

10 quizzes