Back to Blog
Python

Python Decorator Syntax: How the @ Symbol Works

python decorator syntax: Understand Python decorator syntax: how @ transforms functions, decorators with arguments, functools.wraps, stacking order, and runtime cost.

python decoratorsfunction wrappersfunctools.wrapsdecorator argumentspython syntax
Illustration of Python decorator syntax showing a function being wrapped by an outer decorator layer using the @ symbol.

The @ symbol is the core of python decorator syntax. When you place @name above a function definition, Python does not simply attach metadata to the function. It executes name at definition time and rebinds the function name to the result.

@timer def fetch_data(): ...

is exactly equivalent to:

def fetch_data(): ... fetch_data = timer(fetch_data)

The decorator is any callable that accepts the original function and returns a replacement. The replacement can be a new function, a class instance, or any callable object. Python evaluates the @ expression when it reaches the function definition, so timer must be defined before fetch_data in the module. If timer is not yet defined, you get a NameError.

Writing a Basic Function Decorator

The most common decorator is a function that takes a function and returns a wrapper function. The wrapper adds behavior before or after the original call:

def log_calls(func): def wrapper(*args, **kwargs): print(f"calling {func.__name__}") return func(*args, **kwargs) return wrapper @log_calls def add(a, b): return a + b

The wrapper accepts *args and **kwargs so it can forward any arguments to the original function. It returns func(*args, **kwargs) so the caller receives the same return value. After decoration, the name add refers to wrapper, not the original body.

This pattern covers most decorator use cases: logging, timing, authentication checks, input validation, and caching. The wrapper is a closure that captures func from the enclosing scope.

Decorators That Take Arguments

When a decorator needs configuration, you add a factory layer:

def retry(times): def decorator(func): def wrapper(*args, **kwargs): for attempt in range(times): try: return func(*args, **kwargs) except Exception: if attempt == times - 1: raise return wrapper return decorator @retry(times=3) def call_service(): ...

With @retry(times=3), Python first evaluates retry(times=3), which returns decorator. It then applies that returned callable to call_service. The two forms differ in what Python evaluates:

FormWhat Python evaluatesWhat gets bound
@retryretry(call_service)The return value of retry
@retry(times=3)retry(times=3) returns a callable, then that callable is applied to call_serviceThe inner wrapper

A common mistake is writing @retry instead of @retry(times=3). With @retry, the function object is passed as times, so retry returns its inner decorator function. The decorated name then points to that inner function, and calls behave unexpectedly instead of retrying.

Preserving Function Metadata with functools.wraps

A plain wrapper replaces the original function's identity. After decoration, add.__name__ becomes wrapper, and help(add) shows the wrapper's signature instead of the original. This breaks debugging, logging, and any tooling that inspects function metadata.

from functools import wraps def log_calls(func): @wraps(func) def wrapper(*args, **kwargs): print(f"calling {func.__name__}") return func(*args, **kwargs) return wrapper

functools.wraps copies __name__, __doc__, __module__, __qualname__, and __dict__ from the original function onto the wrapper. It also sets __wrapped__ so that inspect.signature can follow the chain back to the original function.

Use @wraps(func) on the inner wrapper of every function decorator you write. The cost is one import and one line of code, and it prevents subtle debugging problems later.

Stacking Multiple Decorators

Decorators apply bottom-up. The decorator closest to the function runs first, and its result is passed to the decorator above it:

@log_calls @retry(times=3) def call_service(): ...

This is equivalent to:

def call_service(): ... call_service = log_calls(retry(times=3)(call_service))

retry wraps call_service first, then log_calls wraps the result. When you call call_service(), execution enters log_calls.wrapper, then retry.wrapper, then the original body.

Order matters when decorators interact. A decorator that catches exceptions should sit outside a timing decorator, because the timing decorator would otherwise measure the retry loop as part of each call. Think about the order of operations the same way you would for nested function calls.

Class-Based Decorators

A decorator can be any callable, so a class with __call__ works as well:

class RateLimiter: def __init__(self, max_calls): self.max_calls = max_calls self.calls = 0 def __call__(self, func): def wrapper(*args, **kwargs): if self.calls >= self.max_calls: raise RuntimeError("limit reached") self.calls += 1 return func(*args, **kwargs) return wrapper

The class instance holds state that persists across calls. This is useful when the decorator needs to track counters, caches, or configuration that changes after decoration.

A class used directly as a decorator, without arguments, receives the function in __init__ rather than __call__. That pattern is less common and often confuses readers, so prefer the __call__ form shown above when you need state.

Runtime Cost and When to Avoid Decorators

Every decorator adds a function call layer to the decorated function. For functions called in tight loops, the extra frame overhead is measurable, though usually small. If profiling shows a hot path, consider whether the decorator's work can be moved outside the loop or applied only to the outer entry point.

Decorators also make stack traces longer. Each wrapper appears as an additional frame in a traceback. With several stacked decorators, debugging a failure inside the original function requires reading through multiple wrapper frames. functools.wraps keeps func.__name__ intact, but the frames are still present.

Use decorators when the cross-cutting behavior is genuinely shared across many functions. Avoid them for a single function where a plain helper call would be simpler and easier to trace.

Common Syntax Mistakes

The most frequent error is mixing up the two forms. @decorator passes the function directly. @decorator(...) evaluates the decorator expression first and applies the result. Writing @decorator when the decorator expects arguments produces confusing behavior, because the function object is passed as the first argument.

Another mistake is forgetting to return the wrapper. A decorator that returns None replaces the function with None, and the next call raises TypeError: 'NoneType' object is not callable.

Decorators defined at module level are evaluated at import time. If the decorator performs expensive work at decoration time, that cost is paid on import, not on the first call. Keep decoration-time work minimal and move heavy setup into the wrapper body or into lazy initialization.

python decorator syntax: Practical Usage and Code Examples | RYUSLOG DEV