A Python decorator is a function that takes another function as its argument, adds behavior to it, and returns a modified version. The @ symbol you see above function definitions is the syntax that makes this happen. Decorators are one of the most powerful patterns in Python, and understanding them unlocks cleaner code for logging, timing, caching, access control, and more.
This article starts with the prerequisite concepts that make decorators possible -- first-class functions and closures -- then walks through writing custom decorators from scratch, preserving function metadata, passing arguments to decorators, and stacking them. It finishes with the built-in decorators that Python provides out of the box and practical examples you can apply to real projects immediately.
Functions Are Objects in Python
Before decorators make sense, one concept needs to be clear: in Python, functions are objects. They can be assigned to variables, stored in data structures, passed as arguments to other functions, and returned from functions. This is what the term "first-class functions" means.
def greet(name):
return f"Hello, {name}"
# Assign the function to a variable -- no parentheses, no call
say_hello = greet
print(say_hello("Alice")) # Hello, Alice
print(type(say_hello)) # <class 'function'>
The variable say_hello now points to the same function object as greet. Nothing was copied -- both names reference the exact same object in memory. This ability to treat functions as regular values is the foundation that makes decorators possible.
A function that accepts another function as an argument or returns a function as its result is called a higher-order function. Python's built-in map(), filter(), and sorted() (via its key parameter) are all higher-order functions. Decorators are higher-order functions with a specific pattern: they receive a function, wrap it, and return the wrapped version.
def shout(func):
"""A higher-order function that wraps another function."""
def wrapper():
original_result = func()
return original_result.upper()
return wrapper
def whisper():
return "hello, world"
loud_whisper = shout(whisper)
print(loud_whisper()) # HELLO, WORLD
The function shout takes whisper as input, defines a new function wrapper that calls whisper and uppercases the result, then returns wrapper. This is a decorator in everything but name. The only thing missing is the @ syntax.
How the @ Syntax Works
Writing loud_whisper = shout(whisper) manually every time is tedious and separates the wrapping from the function definition. Python introduced the @ syntax in PEP 318 to solve both problems. Placing @decorator_name directly above a function definition is equivalent to reassigning the function through the decorator immediately after defining it.
@shout
def whisper():
return "hello, world"
print(whisper()) # HELLO, WORLD
This single line -- @shout -- replaces the manual reassignment. When Python encounters the @ symbol, it reads the function definition below it, passes that function to the decorator (shout), and binds the result back to the original function name (whisper). After decoration, calling whisper() calls whatever shout returned, which is the wrapper function.
The @ syntax is purely syntactic sugar. It does not introduce any new capability that was not already possible with manual function reassignment. Its purpose is readability -- placing the transformation at the declaration site where it is immediately visible.
Writing Your First Custom Decorator
A decorator follows a consistent structure: an outer function that receives the target function, an inner function that wraps the call, and a return statement that sends the inner function back. Here is a decorator that prints a message before and after the decorated function runs:
def trace(func):
def wrapper():
print(f"Calling {func.__name__}")
result = func()
print(f"{func.__name__} returned {result}")
return result
return wrapper
@trace
def compute():
return 42
compute()
# Calling compute
# compute returned 42
The wrapper function is a closure. It captures the variable func from the enclosing scope of trace and retains access to it even after trace has finished executing. This is how the wrapper knows which function to call -- the reference is baked into the closure at decoration time.
Handling Arguments with *args and **kwargs
The wrapper function above works only for functions that take zero arguments. To create a decorator that works with any function signature, the wrapper needs to accept and forward arbitrary positional and keyword arguments using *args and **kwargs:
def trace(func):
def wrapper(*args, **kwargs):
print(f"Calling {func.__name__} with args={args}, kwargs={kwargs}")
result = func(*args, **kwargs)
print(f"{func.__name__} returned {result}")
return result
return wrapper
@trace
def add(a, b):
return a + b
add(3, 5)
# Calling add with args=(3, 5), kwargs={}
# add returned 8
The *args syntax collects all positional arguments into a tuple. The **kwargs syntax collects all keyword arguments into a dictionary. When the wrapper calls func(*args, **kwargs), it unpacks those collections and passes them through to the original function exactly as they were received. This pattern makes the decorator transparent -- it works on any function regardless of its parameter list.
Always use *args and **kwargs in your wrapper functions, even if you know the decorated function's exact signature. This makes the decorator reusable across different functions without modification.
Preserving Metadata with functools.wraps
When a function gets decorated, the original function object is replaced by the wrapper. This means the decorated function's __name__, __doc__, and __module__ attributes all belong to the wrapper, not the original function. This creates problems for debugging, logging, and documentation tools:
def trace(func):
def wrapper(*args, **kwargs):
return func(*args, **kwargs)
return wrapper
@trace
def add(a, b):
"""Add two numbers together."""
return a + b
print(add.__name__) # wrapper (should be "add")
print(add.__doc__) # None (should be "Add two numbers together.")
The fix is functools.wraps, a decorator from the standard library designed specifically for this problem. Applying @functools.wraps(func) to the wrapper copies the original function's metadata onto the wrapper:
import functools
def trace(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
print(f"Calling {func.__name__}")
result = func(*args, **kwargs)
print(f"{func.__name__} returned {result}")
return result
return wrapper
@trace
def add(a, b):
"""Add two numbers together."""
return a + b
print(add.__name__) # add
print(add.__doc__) # Add two numbers together.
Every custom decorator you write should include @functools.wraps(func) on the inner wrapper. Omitting it is a common mistake that causes subtle bugs in logging systems, serialization libraries, and testing frameworks that rely on function metadata.
Decorators That Accept Parameters
Sometimes a decorator needs its own configuration. For example, you might want a decorator that runs a function a specified number of times, or one that only logs messages above a certain severity level. This requires an additional layer of nesting -- a function that accepts the parameters and returns the actual decorator:
import functools
def repeat(n):
"""Run the decorated function n times."""
def decorator(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
result = None
for _ in range(n):
result = func(*args, **kwargs)
return result
return wrapper
return decorator
@repeat(3)
def say_hello(name):
print(f"Hello, {name}")
say_hello("Alice")
# Hello, Alice
# Hello, Alice
# Hello, Alice
There are three layers here. The outermost function repeat(n) captures the parameter. The middle function decorator(func) is the actual decorator that receives the target function. The innermost function wrapper(*args, **kwargs) contains the loop logic and replaces the original function in the namespace.
When Python encounters @repeat(3), it first calls repeat(3), which returns decorator. Then it applies decorator to say_hello, which returns wrapper. The end result is that say_hello now points to wrapper, with n=3 captured in the closure.
Stacking Multiple Decorators
Multiple decorators can be applied to a single function by stacking them above the definition. They execute from bottom to top -- the decorator closest to the def keyword wraps the function first, and each decorator above wraps the result of the one below:
import functools
def bold(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
return f"<b>{func(*args, **kwargs)}</b>"
return wrapper
def italic(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
return f"<i>{func(*args, **kwargs)}</i>"
return wrapper
@bold
@italic
def greet(name):
return f"Hello, {name}"
print(greet("Alice")) # <b><i>Hello, Alice</i></b>
Reading from the bottom up: italic wraps greet first, producing a function that returns italic-wrapped text. Then bold wraps that result, producing a function that bold-wraps whatever italic returns. The equivalent without @ syntax would be greet = bold(italic(greet)).
Built-in Decorators You Already Use
Python ships with several built-in decorators that are used so frequently they deserve individual attention.
@property
The @property decorator turns a method into a read-only attribute. Instead of calling circle.area(), you access circle.area as if it were a regular attribute. Combined with @attribute.setter, it allows controlled read/write access with validation:
class Circle:
def __init__(self, radius):
self._radius = radius
@property
def radius(self):
return self._radius
@radius.setter
def radius(self, value):
if value < 0:
raise ValueError("Radius cannot be negative")
self._radius = value
@property
def area(self):
return 3.14159 * self._radius ** 2
c = Circle(5)
print(c.radius) # 5
print(c.area) # 78.53975
c.radius = 10
print(c.area) # 314.159
@staticmethod and @classmethod
@staticmethod defines a method that belongs to the class namespace but does not receive the instance (self) or the class (cls) as its first argument. It behaves like a plain function that happens to live inside a class. @classmethod receives the class itself as its first argument, making it useful for factory methods and alternative constructors:
class Temperature:
def __init__(self, celsius):
self.celsius = celsius
@classmethod
def from_fahrenheit(cls, fahrenheit):
"""Factory method: create a Temperature from Fahrenheit."""
return cls((fahrenheit - 32) * 5 / 9)
@staticmethod
def is_boiling(celsius):
"""Utility: check if a temperature is at boiling point."""
return celsius >= 100
# Using the class method as an alternative constructor
temp = Temperature.from_fahrenheit(212)
print(temp.celsius) # 100.0
print(Temperature.is_boiling(100)) # True
@functools.lru_cache
@functools.lru_cache is a memoization decorator that caches the results of function calls based on their arguments. Subsequent calls with the same arguments return the cached result instead of recomputing:
import functools
@functools.lru_cache(maxsize=128)
def fibonacci(n):
if n < 2:
return n
return fibonacci(n - 1) + fibonacci(n - 2)
print(fibonacci(50)) # 12586269025 -- computed instantly
Without the cache, computing fibonacci(50) would require trillions of recursive calls. The lru_cache decorator stores previously computed results in a dictionary keyed by the arguments, reducing the time complexity from exponential to linear.
| Decorator | Purpose | Receives |
|---|---|---|
@property |
Turn a method into a readable attribute with optional getter/setter | Instance (self) |
@staticmethod |
Define a method with no access to instance or class state | Nothing (plain function) |
@classmethod |
Define a method that receives the class as its first argument | Class (cls) |
@functools.lru_cache |
Cache function results based on arguments | N/A (caching wrapper) |
@functools.wraps |
Preserve original function metadata in custom decorators | N/A (metadata copier) |
@dataclasses.dataclass |
Auto-generate __init__, __repr__, __eq__ for data-holding classes | Class definition |
Practical Decorator Examples
Execution Timer
Measuring how long a function takes to run is one of the simplest and most useful decorator applications:
import time
import functools
def timer(func):
"""Print the execution time of the decorated function."""
@functools.wraps(func)
def wrapper(*args, **kwargs):
start = time.perf_counter()
result = func(*args, **kwargs)
elapsed = time.perf_counter() - start
print(f"{func.__name__} executed in {elapsed:.4f}s")
return result
return wrapper
@timer
def process_data(records):
total = sum(r ** 2 for r in records)
return total
process_data(range(1_000_000))
# process_data executed in 0.0823s
Call Logger
A logging decorator records every function call with its arguments and return value, providing an audit trail without modifying the function body:
import logging
import functools
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
def log_calls(func):
"""Log each call to the decorated function."""
@functools.wraps(func)
def wrapper(*args, **kwargs):
logger.info("Called %s with args=%s kwargs=%s", func.__name__, args, kwargs)
result = func(*args, **kwargs)
logger.info("%s returned %s", func.__name__, result)
return result
return wrapper
@log_calls
def calculate_discount(price, percentage):
return price * (percentage / 100)
calculate_discount(200, 15)
# INFO:__main__:Called calculate_discount with args=(200, 15) kwargs={}
# INFO:__main__:calculate_discount returned 30.0
Access Control
Decorators can enforce preconditions before a function executes. This example checks whether a user has the required role before granting access:
import functools
def require_role(role):
"""Only allow access if the user dict has the required role."""
def decorator(func):
@functools.wraps(func)
def wrapper(user, *args, **kwargs):
if user.get("role") != role:
raise PermissionError(
f"User '{user.get('name')}' lacks the '{role}' role"
)
return func(user, *args, **kwargs)
return wrapper
return decorator
@require_role("admin")
def delete_record(user, record_id):
print(f"Record {record_id} deleted by {user['name']}")
admin_user = {"name": "Kandi", "role": "admin"}
guest_user = {"name": "Guest", "role": "viewer"}
delete_record(admin_user, 42) # Record 42 deleted by Kandi
delete_record(guest_user, 42) # PermissionError raised
Key Takeaways
- A decorator is a function that wraps another function. It takes a function as input, defines an inner wrapper that adds behavior before or after the original call, and returns the wrapper. The
@syntax is shorthand forfunc = decorator(func). - Functions are first-class objects in Python. This means they can be assigned to variables, passed as arguments, and returned from other functions. This property is what makes the entire decorator pattern possible.
- Use
*argsand**kwargsin every wrapper. This makes the decorator universal -- it works on any function regardless of how many parameters it accepts, without requiring the decorator to know the function's signature in advance. - Always apply
@functools.wraps(func)to the wrapper. Without it, the decorated function loses its original name, docstring, and module metadata, which breaks debugging and introspection tools. - Parameterized decorators require three layers of nesting. The outer function captures the decorator's own arguments, the middle function receives the target function, and the inner function replaces the target in the namespace.
- Python's built-in decorators handle common patterns. The
@property,@staticmethod,@classmethod,@functools.lru_cache, and@dataclasses.dataclassdecorators cover attribute access, method types, caching, and class generation without requiring custom implementations.
Decorators reduce repetition by extracting cross-cutting concerns -- logging, timing, access control, caching -- into reusable wrappers that can be applied with a single line. Understanding how they work under the hood, from first-class functions through closures to the three-layer parameterized pattern, gives you the ability to write your own decorators for any behavior that needs to wrap a function call.