The @ symbol sitting above a function definition is one of the first pieces of Python syntax that stops beginners in their tracks. It looks like metadata, or a comment, or some kind of annotation—but it is none of those things. It is a decorator, and it is doing something concrete: it takes the function defined below it, passes that function into another function, and replaces the original with whatever comes back. Once that mechanic clicks, decorators stop being mysterious and start being one of the more useful tools in the language.
This article walks through every layer of Python decorator syntax, starting with the prerequisites that make decorators possible in the first place. Each concept builds on the one before it, and every section includes code you can run directly in a Python interpreter.
Why Decorators Exist: First-Class Functions and Closures
Decorators work because of two properties that Python functions have: they are first-class objects, and they support closures. Without understanding these two ideas, the @ syntax feels like magic. With them, it becomes a logical next step.
First-Class Functions
In Python, functions are objects. They can be assigned to variables, stored in data structures, passed as arguments to other functions, and returned as values from other functions. This is what "first-class" means—functions have the same status as integers, strings, or any other object:
def greet(name):
return f"Hello, {name}"
# Assign a function to a variable
say_hello = greet
print(say_hello("Ada")) # Hello, Ada
# Pass a function as an argument
def apply(func, value):
return func(value)
print(apply(greet, "Grace")) # Hello, Grace
# Return a function from another function
def make_greeter(greeting):
def greeter(name):
return f"{greeting}, {name}"
return greeter
casual = make_greeter("Hey")
print(casual("Alan")) # Hey, Alan
The third example—returning a function from another function—is the pattern that decorators are built on. The outer function (make_greeter) configures behavior, and the inner function (greeter) captures that configuration and carries it forward.
Closures
A closure is an inner function that remembers the variables from its enclosing scope, even after the outer function has finished executing. In the make_greeter example above, the inner function greeter holds a reference to the greeting variable. That reference persists for the lifetime of the inner function, which is why casual("Alan") still knows the greeting is "Hey".
def make_counter():
count = 0
def increment():
nonlocal count
count += 1
return count
return increment
counter = make_counter()
print(counter()) # 1
print(counter()) # 2
print(counter()) # 3
The count variable lives in make_counter's local scope, but the inner function increment closes over it. Each call to counter() accesses and modifies the same count through the closure. The nonlocal keyword tells Python to look in the enclosing scope rather than creating a new local variable.
Closures are the reason decorators can carry configuration and state. When a decorator wraps a function, the wrapper is a closure that holds a reference to the original function.
The @ Symbol and What It Replaces
Before the @ syntax existed (it was added in Python 2.4 via PEP 318), decorating a function looked like this:
def my_decorator(func):
def wrapper(*args, **kwargs):
print(f"Calling {func.__name__}")
result = func(*args, **kwargs)
print(f"Finished {func.__name__}")
return result
return wrapper
def say_hello():
print("Hello, world")
# Manual decoration: pass function to decorator, reassign the name
say_hello = my_decorator(say_hello)
That last line is the decoration step. It passes say_hello into my_decorator, which returns wrapper. The name say_hello is then rebound to wrapper. Every subsequent call to say_hello() goes through the wrapper.
The problem with this approach is that the reassignment happens after the function body, which can be dozens or hundreds of lines away from the def statement. If you are reading the code from top to bottom, you have no idea the function is decorated until you reach the bottom. The @ syntax fixes this by placing the decoration at the point of definition:
@my_decorator
def say_hello():
print("Hello, world")
# This is exactly equivalent to:
# def say_hello():
# print("Hello, world")
# say_hello = my_decorator(say_hello)
The @ symbol is not an annotation, a comment, or metadata. It is executable code. When Python encounters @my_decorator above a def statement, it evaluates the decorator expression immediately after the function body is compiled, passes the function object to it, and rebinds the function name to the return value.
The *args, **kwargs signature in the wrapper is critical. It makes the wrapper accept any combination of positional and keyword arguments, so the decorator works regardless of the decorated function's parameter list. Without this, the decorator would only work on functions with a specific number of parameters.
Preserving Metadata with functools.wraps
There is a subtle but important problem with the basic decorator pattern. After decoration, the original function's identity is lost:
def my_decorator(func):
def wrapper(*args, **kwargs):
return func(*args, **kwargs)
return wrapper
@my_decorator
def calculate(x, y):
"""Add two numbers together."""
return x + y
print(calculate.__name__) # wrapper (not "calculate")
print(calculate.__doc__) # None (not "Add two numbers together.")
The name calculate now points to wrapper, so its __name__ is "wrapper" and its docstring is None. This breaks debuggers, logging output, documentation generators, and anything else that relies on function metadata.
The fix is functools.wraps, a decorator from the standard library that copies metadata from the original function onto the wrapper:
import functools
def my_decorator(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
return func(*args, **kwargs)
return wrapper
@my_decorator
def calculate(x, y):
"""Add two numbers together."""
return x + y
print(calculate.__name__) # calculate
print(calculate.__doc__) # Add two numbers together.
functools.wraps copies the __name__, __doc__, __qualname__, __annotations__, __module__, and __dict__ from the original function to the wrapper. It also adds a __wrapped__ attribute that points back to the original function, which is useful for bypassing the decorator during testing or introspection.
Make @functools.wraps(func) a habit in every decorator you write. It costs one line of code and prevents an entire class of bugs related to lost function identity. You can access the original, unwrapped function at any time through the __wrapped__ attribute: calculate.__wrapped__(3, 4) calls the original calculate directly.
Parameterized Decorators (Decorator Factories)
A basic decorator takes a single argument: the function it wraps. But what if you want to pass configuration to the decorator itself? For example, what if you want a timing decorator that lets you specify the time unit, or a retry decorator that accepts a maximum number of attempts?
The solution is a decorator factory—a function that takes configuration arguments and returns the actual decorator. This creates three layers of nesting: the factory, the decorator, and the wrapper:
import functools
import time
def timer(unit="seconds"):
"""Decorator factory that measures function execution time.
Args:
unit: Display time in 'seconds' or 'milliseconds'.
"""
def decorator(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
start = time.perf_counter()
result = func(*args, **kwargs)
elapsed = time.perf_counter() - start
if unit == "milliseconds":
print(f"{func.__name__} took {elapsed * 1000:.2f} ms")
else:
print(f"{func.__name__} took {elapsed:.4f} s")
return result
return wrapper
return decorator
@timer(unit="milliseconds")
def process_data(items):
"""Process a list of data items."""
return [item * 2 for item in items]
result = process_data(range(100_000))
# process_data took 4.23 ms
The key distinction is in the syntax. @timer(unit="milliseconds") calls timer() first, which returns decorator. Then Python applies decorator to process_data. This is different from @timer (without parentheses), which would try to pass process_data as the unit argument—a common mistake that produces confusing error messages.
Here is a more practical example—a retry decorator that accepts a configurable number of attempts and a delay between retries:
import functools
import time
def retry(max_attempts=3, delay=1.0):
"""Retry a function on failure up to max_attempts times."""
def decorator(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
last_exception = None
for attempt in range(1, max_attempts + 1):
try:
return func(*args, **kwargs)
except Exception as exc:
last_exception = exc
if attempt < max_attempts:
print(
f"{func.__name__} failed (attempt {attempt}/"
f"{max_attempts}), retrying in {delay}s..."
)
time.sleep(delay)
raise last_exception
return wrapper
return decorator
@retry(max_attempts=5, delay=0.5)
def fetch_data(url):
"""Fetch data from a remote endpoint."""
import random
if random.random() < 0.7:
raise ConnectionError("Server unreachable")
return {"status": "ok"}
A common mistake with parameterized decorators is forgetting the parentheses. @retry without parentheses passes the decorated function as the max_attempts argument, which produces a TypeError when the decorator tries to use it as an integer. Always include parentheses when using a decorator factory, even if you want the default arguments: @retry().
Class-Based Decorators
Functions are not the only callables in Python. Any object with a __call__ method is callable, which means classes can serve as decorators too. A class-based decorator receives the function in __init__ and executes the wrapper logic in __call__:
import functools
class CountCalls:
"""Decorator that counts how many times a function is called."""
def __init__(self, func):
functools.update_wrapper(self, func)
self.func = func
self.call_count = 0
def __call__(self, *args, **kwargs):
self.call_count += 1
print(f"{self.func.__name__} has been called {self.call_count} times")
return self.func(*args, **kwargs)
@CountCalls
def say_hello(name):
"""Greet someone by name."""
return f"Hello, {name}"
say_hello("Ada") # say_hello has been called 1 times
say_hello("Grace") # say_hello has been called 2 times
print(say_hello.call_count) # 2
The class-based approach has one clear advantage: instance attributes. The call_count attribute persists across calls because it lives on the class instance. With a function-based decorator, you would need to use nonlocal or attach attributes to the wrapper function manually.
Note that the class uses functools.update_wrapper(self, func) instead of @functools.wraps(func). They do the same thing—functools.wraps is just a convenience wrapper around update_wrapper—but update_wrapper is the correct form when the wrapper is a class instance rather than a function.
Class-Based Decorator with Parameters
When a class-based decorator needs to accept configuration parameters, the pattern shifts. The __init__ method receives the configuration, and the __call__ method receives the function being decorated and returns the wrapper:
import functools
class RateLimit:
"""Class-based rate limit decorator with configurable threshold."""
def __init__(self, max_calls=10):
self.max_calls = max_calls
self.call_count = 0
def __call__(self, func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
if self.call_count >= self.max_calls:
raise RuntimeError(
f"{func.__name__} exceeded {self.max_calls} calls"
)
self.call_count += 1
return func(*args, **kwargs)
wrapper.reset = lambda: setattr(self, 'call_count', 0)
return wrapper
@RateLimit(max_calls=3)
def send_email(to, subject):
"""Send an email message."""
print(f"Sending '{subject}' to {to}")
send_email("ada@example.com", "Test 1") # works
send_email("ada@example.com", "Test 2") # works
send_email("ada@example.com", "Test 3") # works
# send_email("ada@example.com", "Test 4") # RuntimeError
Stacking Multiple Decorators
Python allows you to stack multiple decorators on a single function by placing multiple @ lines above the def statement. Decorators apply from bottom to top—the one closest to the function wraps first, and the outermost one wraps last:
import functools
def bold(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
return f"{func(*args, **kwargs)}"
return wrapper
def italic(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
return f"{func(*args, **kwargs)}"
return wrapper
@bold
@italic
def greet(name):
return f"Hello, {name}"
print(greet("Ada"))
# Hello, Ada
# Equivalent to:
# greet = bold(italic(greet))
The execution order matters: italic wraps greet first, producing a function that returns italic text. Then bold wraps that result, producing a function that returns bold-wrapped italic text. When greet("Ada") is called, bold's wrapper runs first (outermost), which calls italic's wrapper (inner), which calls the original greet (innermost).
In production code, stacking order determines what behavior fires when. A typical pattern for a web application might look like this:
# Outermost executes first: logs every call attempt
@log_request
# Middle layer: checks authentication before proceeding
@require_auth
# Innermost: validates the request payload
@validate_payload
def create_user(request):
"""Create a new user account."""
# Only reached if all three decorators pass
return save_user(request.data)
Built-In Decorators and Modern Additions
Python's standard library includes several decorators that you will encounter frequently. Understanding their syntax helps reinforce how decorator patterns work in practice.
| Decorator | Module | Purpose |
|---|---|---|
@staticmethod |
builtins | Declares a method that does not receive self or cls |
@classmethod |
builtins | Declares a method that receives the class (cls) as its first argument |
@property |
builtins | Turns a method into a read-only attribute with getter/setter/deleter support |
@functools.lru_cache |
functools | Caches function return values with LRU eviction |
@functools.wraps |
functools | Copies metadata from the wrapped function to the wrapper |
@dataclasses.dataclass |
dataclasses | Generates __init__, __repr__, __eq__, and other methods for a class |
@warnings.deprecated |
warnings | Marks a function or class as deprecated with runtime warnings (Python 3.13+, PEP 702) |
The @warnings.deprecated decorator is the newest addition to this list. Added in Python 3.13 through PEP 702, it provides a standard way to mark classes, functions, and overloads as deprecated. When a deprecated callable is used, it can emit a DeprecationWarning at runtime and trigger diagnostics in static type checkers like mypy and Pyright:
from warnings import deprecated
@deprecated("Use parse_config_v2() instead")
def parse_config(path):
"""Parse a configuration file (legacy format)."""
with open(path) as f:
return f.read()
# Calling parse_config() emits:
# DeprecationWarning: Use parse_config_v2() instead
PEP 614: Relaxed Decorator Expressions
Prior to Python 3.9, the grammar restricted decorators to dotted names optionally followed by a single call. You could write @module.decorator or @factory(arg), but not @decorators[0] or @condition and decorator_a or decorator_b.
PEP 614, implemented in Python 3.9, relaxed this restriction to allow any valid expression after the @ symbol. This is a niche feature, but it matters when working with frameworks that store decorators in data structures:
# Python 3.9+ only — decorator expressions
import functools
decorators = {
"loud": lambda f: (
functools.wraps(f)(lambda *a, **kw: f(*a, **kw).upper())
),
"quiet": lambda f: (
functools.wraps(f)(lambda *a, **kw: f(*a, **kw).lower())
),
}
@decorators["loud"]
def announce(message):
return message
print(announce("attention please"))
# ATTENTION PLEASE
Before Python 3.9, you would have had to assign decorators["loud"] to a temporary variable first. The relaxed grammar removes that workaround and lets the expression appear directly after @.
Key Takeaways
- The
@symbol is syntactic sugar:@decoratorabovedef func()is equivalent tofunc = decorator(func)written after the function body. It places the decoration at the point of definition for readability. - Decorators depend on first-class functions and closures: Functions can be passed as arguments, returned from other functions, and carry references to their enclosing scope. These properties make the wrapper pattern possible.
- Always use
functools.wraps: Without it, the decorated function loses its__name__,__doc__,__qualname__, and__annotations__, breaking debugging tools, logging output, and documentation generators. - Parameterized decorators add one level of nesting: A decorator factory takes configuration arguments, returns the actual decorator, which takes the function and returns the wrapper. This produces three nested functions. Always include parentheses when using a factory, even for defaults.
- Class-based decorators store state naturally: Instance attributes persist across calls, making classes a clean choice when a decorator needs to track counters, accumulate results, or maintain configuration.
- Stacking order is bottom-to-top: The decorator closest to the function wraps first (innermost). The topmost decorator wraps last (outermost) and executes first when the function is called.
- Python 3.9+ allows any expression after
@: PEP 614 relaxed the grammar so that subscripts, conditionals, and other expressions are valid decorator syntax. Python 3.13 added@warnings.deprecatedas a standard decorator for marking deprecated code.
Decorator syntax is one of those features that looks complicated from the outside but follows a consistent, predictable pattern once you understand the building blocks. Every decorator—no matter how sophisticated—is built from the same parts: a callable that accepts a function, an inner function that wraps it, functools.wraps to preserve metadata, and the @ symbol to wire it all together at the point of definition.