Skip to content

Python Decorators: A Complete Guide

Python Python 7 min read 1484 words Beginner ExcellentWiki Editorial Team

Decorators are one of Python’s most powerful features. At their core, decorators are functions that modify the behavior of other functions or classes without changing their source code. They enable aspect-oriented programming patterns — logging, timing, caching, authorization, validation — in a clean, reusable way.

Python’s @decorator syntax is syntactic sugar for function = decorator(function). Understanding this equivalence is the key to understanding how decorators work under the hood.

Function Decorators

The simplest decorator wraps a function to add pre- or post-processing:

def logger(func):
    def wrapper(*args, **kwargs):
        print(f"Calling {func.__name__}")
        result = func(*args, **kwargs)
        print(f"{func.__name__} returned {result}")
        return result
    return wrapper

@logger
def add(a, b):
    return a + b

print(add(3, 4))
# Calling add
# add returned 7
# 7

The @logger syntax is equivalent to add = logger(add). The wrapper function replaces the original add, captures all arguments, calls the original, and logs the call. The closure retains access to func even after logger returns.

Preserving Metadata with functools.wraps

A wrapped function loses its original name, docstring, and signature:

print(add.__name__)  # "wrapper" (not "add")

Use functools.wraps to copy metadata from the original function:

import functools

def logger(func):
    @functools.wraps(func)
    def wrapper(*args, **kwargs):
        print(f"Calling {func.__name__}")
        return func(*args, **kwargs)
    return wrapper

@logger
def add(a, b):
    """Add two numbers."""
    return a + b

print(add.__name__)  # "add"
print(add.__doc__)   # "Add two numbers."

Always use @functools.wraps on your wrapper functions. Without it, debugging becomes harder because stack traces and introspection tools show “wrapper” instead of the original function name.

Measuring Decorator Overhead

Decorators add function call overhead to every invocation of the wrapped function. For performance-critical code called millions of times, this overhead matters. The functools.wraps decorator is essential — without it, the decorated function loses its original __name__, __doc__, and __module__. Use functools.lru_cache for memoization and functools.singledispatch for type-based dispatch.

Decorators with Arguments

Sometimes a decorator needs configuration:

def repeat(times):
    def decorator(func):
        @functools.wraps(func)
        def wrapper(*args, **kwargs):
            for _ in range(times):
                result = func(*args, **kwargs)
            return result
        return wrapper
    return decorator

@repeat(times=3)
def greet(name):
    print(f"Hello, {name}!")

greet("Alice")
# Hello, Alice!
# Hello, Alice!
# Hello, Alice!

This is a decorator factory: repeat(times=3) returns the actual decorator, which then receives greet. The syntax @repeat(times=3) is equivalent to greet = repeat(times=3)(greet).

Optional Arguments Pattern

For decorators that work with or without arguments, check whether the argument is a function or a configuration value:

def debug(func=None, *, verbose=False):
    def decorator(fn):
        @functools.wraps(fn)
        def wrapper(*args, **kwargs):
            if verbose:
                print(f"DEBUG: {fn.__name__}({args}, {kwargs})")
            return fn(*args, **kwargs)
        return wrapper

    if func is not None:
        return decorator(func)  # used as @debug (no args)
    return decorator            # used as @debug(verbose=True)

Chaining Decorators

Decorators stack from bottom to top (closest to the function applies first):

@decorator_a
@decorator_b
@decorator_c
def my_function():
    pass

# Equivalent to:
# my_function = decorator_a(decorator_b(decorator_c(my_function)))

When chaining, the bottom decorator runs first. This matters when the order of operations is important — for example, authentication should wrap authorization because authorization depends on an authenticated identity.

Class Decorators

Decorators can also modify classes:

def singleton(cls):
    instances = {}
    @functools.wraps(cls)
    def get_instance(*args, **kwargs):
        if cls not in instances:
            instances[cls] = cls(*args, **kwargs)
        return instances[cls]
    return get_instance

@singleton
class Database:
    def __init__(self):
        print("Creating database connection")

db1 = Database()  # Creating database connection
db2 = Database()  # no output — returns existing instance
print(db1 is db2)  # True

Class decorators are useful for registering classes, adding methods, or enforcing patterns like singletons without metaclass complexity.

Real-World Decorator Examples

Timing Decorator

import time

def timer(func):
    @functools.wraps(func)
    def wrapper(*args, **kwargs):
        start = time.perf_counter()
        result = func(*args, **kwargs)
        elapsed = time.perf_counter() - start
        print(f"{func.__name__} took {elapsed:.4f}s")
        return result
    return wrapper

@timer
def slow_function():
    time.sleep(0.5)
    return "done"

slow_function()  # slow_function took 0.5001s

Retry Decorator

import time
import functools

def retry(max_attempts=3, delay=1):
    def decorator(func):
        @functools.wraps(func)
        def wrapper(*args, **kwargs):
            for attempt in range(1, max_attempts + 1):
                try:
                    return func(*args, **kwargs)
                except Exception as e:
                    if attempt == max_attempts:
                        raise
                    print(f"Attempt {attempt} failed: {e}. Retrying...")
                    time.sleep(delay)
            return None
        return wrapper
    return decorator

@retry(max_attempts=3, delay=0.5)
def fetch_data(url):
    # network call that may fail
    pass

Cache Decorator

import functools

def memoize(func):
    cache = {}
    @functools.wraps(func)
    def wrapper(*args):
        if args not in cache:
            cache[args] = func(*args)
        return cache[args]
    return wrapper

@memoize
def fibonacci(n):
    if n < 2:
        return n
    return fibonacci(n - 1) + fibonacci(n - 2)

print(fibonacci(100))  # fast — cached intermediate results

For production caching, use functools.lru_cache which has a size limit, TTL, and statistics:

@functools.lru_cache(maxsize=128)
def expensive_computation(n):
    return n ** n

Validation Decorator

def validate_types(**type_hints):
    def decorator(func):
        @functools.wraps(func)
        def wrapper(*args, **kwargs):
            # Simple positional argument validation
            for (name, expected_type), arg in zip(type_hints.items(), args):
                if not isinstance(arg, expected_type):
                    raise TypeError(
                        f"{name} must be {expected_type.__name__}, got {type(arg).__name__}"
                    )
            return func(*args, **kwargs)
        return wrapper
    return decorator

@validate_types(x=int, y=int)
def divide(x, y):
    return x / y

divide(10, 2)  # 5.0
divide("10", 2)  # TypeError: x must be int, got str

Authorization Decorator

def require_role(role):
    def decorator(func):
        @functools.wraps(func)
        def wrapper(*args, **kwargs):
            # Assume user info is available in context
            user = get_current_user()
            if role not in user.roles:
                raise PermissionError(f"User lacks {role} role")
            return func(*args, **kwargs)
        return wrapper
    return decorator

@require_role("admin")
def delete_user(user_id):
    print(f"Deleting user {user_id}")

Class-based Decorators

Decorators can also be implemented as classes with __call__. Class-based decorators are useful when you need to maintain complex state or provide methods on the decorated object. The __init__ method receives the function, and __call__ handles the invocation. This pattern appears in Django’s @login_required and Celery’s @task decorators.

Decorators in the Standard Library

Python’s standard library includes several built-in decorators:

# @property — make a method look like an attribute
class Circle:
    def __init__(self, radius):
        self._radius = radius

    @property
    def area(self):
        return 3.14159 * self._radius ** 2

    @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

# @staticmethod / @classmethod
class Utils:
    @staticmethod
    def is_valid_email(email):
        return "@" in email

    @classmethod
    def from_config(cls, config):
        return cls(config["name"], config["age"])

# @dataclass
from dataclasses import dataclass

@dataclass
class Point:
    x: float
    y: float

Decorators in Standard Library Beyond Basics

Python’s standard library includes several decorators beyond @property and @staticmethod. @functools.cache and @functools.lru_cache provide memoization with automatic cache invalidation. @functools.singledispatch enables single-dispatch generic functions where behavior depends on the first argument’s type. @contextlib.contextmanager converts a generator function into a context manager without defining a class. @dataclass from the dataclasses module automatically generates __init__, __repr__, __eq__, and __hash__ methods. @total_ordering from functools fills in missing comparison operators when you define __eq__ and one other. These standard decorators solve common problems and demonstrate idiomatic decorator patterns.

Summary

Decorators are a cornerstone of idiomatic Python. They allow you to wrap functions and classes with reusable behavior — logging, timing, caching, validation, authorization — without modifying the wrapped code. The @functools.wraps decorator preserves metadata on wrapped functions. Decorator factories accept arguments for configurable behavior. Chaining applies decorators from bottom to top. Class decorators modify classes without inheritance. Together, these patterns enable clean separation of cross-cutting concerns from core business logic.


Related: Learn about Python file handling and error handling.

Decorator Type Annotations

Type annotations on decorators require care because the wrapper function signature differs from the original. Use ParamSpec and TypeVar from typing (Python 3.10+) to preserve type information: P = ParamSpec('P'); R = TypeVar('R'). A properly typed decorator factory: def logger(func: Callable[P, R]) -> Callable[P, R]. This ensures mypy and Pyright correctly infer the decorated function’s parameter types and return type, making decorated functions type-safe throughout your codebase. Without these annotations, decorated functions lose their type information.

Best Practices for Decorator Design

Keep decorators single-purpose — each decorator should do one thing well and compose cleanly with others. Document the decorator’s behavior including any side effects and performance implications. Prefer functools.wraps always to preserve function metadata. For decorators that accept arguments, implement the optional-arguments pattern to allow both @decorator and @decorator(args) syntax. Test decorators independently with stub functions before using them in production code.

FAQ

What is the difference between a decorator and a decorator factory?

A decorator is a function that takes a function and returns a modified function. A decorator factory is a function that takes configuration arguments and returns a decorator. Use @decorator for simple cases and @factory(args) when you need configuration parameters.

Can I apply multiple decorators to one function?

Yes — stack them with @decorator_a above @decorator_b above the function definition. They apply from bottom to top: the bottommost runs first, then the next, and so on. The order matters when decorators have side effects or dependencies.

How do I test decorated functions?

Test the original function directly by accessing it through __wrapped__ if you used functools.wraps. Or test the decorated version and verify the behavioral modification. Unit test the decorator in isolation with a mock function. For complex decorators, test both the decorator and the decorated behavior.

Are decorators thread-safe?

Standard decorators using local function scope are generally safe. Decorators with shared mutable state (counters, caches) need locks if used concurrently. functools.lru_cache is thread-safe. For custom stateful decorators in multi-threaded code, add threading.Lock protection around state mutations.

Section: Python 1484 words 7 min read Beginner 756 articles in section Report inaccuracy Back to top