What is a Python Decorator

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.

Note

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.

Pro Tip

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.
Warning

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

  1. 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 for func = decorator(func).
  2. 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.
  3. Use *args and **kwargs in 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.
  4. 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.
  5. 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.
  6. Python's built-in decorators handle common patterns. The @property, @staticmethod, @classmethod, @functools.lru_cache, and @dataclasses.dataclass decorators 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.