Python Decorators

Master Python decorators: function decorators, @wraps, decorators with arguments, class decorators, and built-in decorators.

Intermediate 13 min read 10 examples

Decorator Basics

A decorator is a function that wraps another function to add behavior. @decorator is syntactic sugar for func = decorator(func).

Python
import time

# Simple decorator - adds timing
def timer(func):
    def wrapper(*args, **kwargs):
        start  = time.perf_counter()
        result = func(*args, **kwargs)
        end    = time.perf_counter()
        print(f"{func.__name__} took {end - start:.4f}s")
        return result
    return wrapper

# Apply with @ syntax
@timer
def slow_add(a, b):
    time.sleep(0.1)
    return a + b

result = slow_add(3, 4)     # slow_add took 0.1001s
print(result)               # 7

# Equivalent without @ syntax:
# slow_add = timer(slow_add)

# Decorator for logging
def log_calls(func):
    def wrapper(*args, **kwargs):
        print(f"Calling {func.__name__}({args}, {kwargs})")
        result = func(*args, **kwargs)
        print(f"{func.__name__} returned {result!r}")
        return result
    return wrapper

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

add(3, 4)
# Calling add((3, 4), {})
# add returned 7

# Decorator that validates arguments
def positive_args(func):
    def wrapper(*args, **kwargs):
        if any(a <= 0 for a in args if isinstance(a, (int, float))):
            raise ValueError(f"All arguments to {func.__name__} must be positive")
        return func(*args, **kwargs)
    return wrapper

@positive_args
def sqrt(n):
    return n ** 0.5

print(sqrt(9))      # 3.0
try:
    sqrt(-1)        # ValueError: All arguments to sqrt must be positive
except ValueError as e:
    print(e)

@wraps and functools

Without @wraps, decorated functions lose their name and docstring. Always use it.

Python
from functools import wraps, lru_cache, cache

# Without @wraps - metadata is lost
def bad_decorator(func):
    def wrapper(*args, **kwargs):
        return func(*args, **kwargs)
    return wrapper

@bad_decorator
def my_function():
    """This is my function."""
    pass

print(my_function.__name__)     # wrapper  (wrong!)
print(my_function.__doc__)      # None     (lost!)

# With @wraps - metadata preserved
def good_decorator(func):
    @wraps(func)
    def wrapper(*args, **kwargs):
        return func(*args, **kwargs)
    return wrapper

@good_decorator
def my_function():
    """This is my function."""
    pass

print(my_function.__name__)     # my_function  (correct)
print(my_function.__doc__)      # This is my function.

# --- functools.lru_cache ---
# Cache results of expensive function calls (memoization)

@lru_cache(maxsize=128)
def fibonacci(n):
    if n < 2:
        return n
    return fibonacci(n - 1) + fibonacci(n - 2)

print(fibonacci(50))    # fast because results are cached
print(fibonacci.cache_info())
# CacheInfo(hits=48, misses=51, maxsize=128, currsize=51)
fibonacci.cache_clear()     # clear the cache

# functools.cache - Python 3.9+ unbounded cache (same as lru_cache(maxsize=None))
@cache
def factorial(n):
    return 1 if n == 0 else n * factorial(n - 1)

print(factorial(10))    # 3628800

# --- functools.cached_property ---
# Compute expensive property once and cache on the instance
from functools import cached_property

class DataSet:
    def __init__(self, data):
        self._data = data

    @cached_property
    def statistics(self):
        """Computed once and cached."""
        print("Computing statistics...")
        return {
            "mean": sum(self._data) / len(self._data),
            "min":  min(self._data),
            "max":  max(self._data),
        }

ds = DataSet([1, 2, 3, 4, 5])
print(ds.statistics)    # Computing statistics... {mean: 3.0, ...}
print(ds.statistics)    # (no "Computing" - returned from cache)

Decorators with Arguments

Python
from functools import wraps
import time

# Decorator factory - returns a decorator
def repeat(times):
    """Repeat the decorated function `times` times."""
    def decorator(func):
        @wraps(func)
        def wrapper(*args, **kwargs):
            for _ in range(times):
                result = func(*args, **kwargs)
            return result
        return wrapper
    return decorator

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

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

# Decorator with retry logic
def retry(max_attempts=3, delay=1.0, exceptions=(Exception,)):
    def decorator(func):
        @wraps(func)
        def wrapper(*args, **kwargs):
            last_error = None
            for attempt in range(1, max_attempts + 1):
                try:
                    return func(*args, **kwargs)
                except exceptions as e:
                    last_error = e
                    if attempt < max_attempts:
                        print(f"Attempt {attempt} failed: {e}. Retrying...")
                        # time.sleep(delay)  # uncomment in real use
            raise last_error
        return wrapper
    return decorator

import random
@retry(max_attempts=3, exceptions=(ValueError,))
def unreliable():
    if random.random() < 0.7:
        raise ValueError("Random failure")
    return "Success!"

# Decorator with optional arguments (works with or without parentheses)
def debug(_func=None, *, prefix="DEBUG"):
    """Can be used as @debug or @debug(prefix='INFO')."""
    def decorator(func):
        @wraps(func)
        def wrapper(*args, **kwargs):
            print(f"[{prefix}] {func.__name__}({args}, {kwargs})")
            result = func(*args, **kwargs)
            print(f"[{prefix}] -> {result!r}")
            return result
        return wrapper

    if _func is not None:       # called as @debug (no parentheses)
        return decorator(_func)
    return decorator            # called as @debug(prefix=...)

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

@debug(prefix="INFO")
def multiply(a, b): return a * b

add(2, 3)
multiply(4, 5)

Class Decorators

A class can be used as a decorator if it implements __call__. Classes are also often decorated themselves.

Python
from functools import wraps

# Class used as a decorator (has __call__)
class CountCalls:
    def __init__(self, func):
        wraps(func)(self)           # copy metadata
        self.func       = func
        self.call_count = 0

    def __call__(self, *args, **kwargs):
        self.call_count += 1
        print(f"Call #{self.call_count} to {self.func.__name__}")
        return self.func(*args, **kwargs)

@CountCalls
def say_hello(name):
    print(f"Hello, {name}!")

say_hello("Alice")      # Call #1 to say_hello
say_hello("Bob")        # Call #2 to say_hello
print(say_hello.call_count)     # 2

# Decorator applied to a class
def singleton(cls):
    """Ensure only one instance of the class exists."""
    instances = {}
    @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 Config:
    def __init__(self):
        self.debug = False
        self.port  = 8080

c1 = Config()
c2 = Config()
print(c1 is c2)         # True - same object

# Add methods to a class with a decorator
def add_method(method):
    """Decorator to add a method to all instances via the class."""
    def decorator(cls):
        setattr(cls, method.__name__, method)
        return cls
    return decorator

class Point:
    def __init__(self, x, y):
        self.x = x
        self.y = y

@add_method
def distance(self):
    return (self.x**2 + self.y**2) ** 0.5

p = Point(3, 4)
# p.distance() works now because add_method added it

Built-in Decorators

Python
class Temperature:
    def __init__(self, celsius):
        self._celsius = celsius

    # @property - getter
    @property
    def celsius(self):
        return self._celsius

    # @property.setter - setter with validation
    @celsius.setter
    def celsius(self, value):
        if value < -273.15:
            raise ValueError("Below absolute zero")
        self._celsius = value

    # Computed read-only property
    @property
    def fahrenheit(self):
        return self._celsius * 9/5 + 32

class Circle:
    PI = 3.14159265

    def __init__(self, radius):
        self.radius = radius

    # @classmethod - receives cls, not self
    # Typical use: alternative constructors
    @classmethod
    def from_diameter(cls, diameter):
        return cls(diameter / 2)

    @classmethod
    def unit_circle(cls):
        return cls(1)

    # @staticmethod - no self or cls
    # Typical use: utility functions related to the class
    @staticmethod
    def is_valid_radius(r):
        return isinstance(r, (int, float)) and r > 0

    def area(self):
        return self.PI * self.radius ** 2

# Usage
t = Temperature(25)
print(t.celsius)        # 25
print(t.fahrenheit)     # 77.0
t.celsius = 100

c1 = Circle.from_diameter(10)   # classmethod
print(c1.radius)                # 5.0

c2 = Circle.unit_circle()       # classmethod
print(c2.radius)                # 1

print(Circle.is_valid_radius(5))    # True - staticmethod

Stacking Decorators

Python
from functools import wraps
import time

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

def log(func):
    @wraps(func)
    def wrapper(*args, **kwargs):
        print(f"[LOG] Calling {func.__name__}")
        result = func(*args, **kwargs)
        print(f"[LOG] {func.__name__} done")
        return result
    return wrapper

# Decorators apply bottom-up
@timer          # applied second (outermost)
@log            # applied first (innermost)
def process(n):
    return sum(range(n))

process(1000)
# [LOG] Calling process
# [LOG] process done
# [TIMER] process: 0.0001s

# Equivalent: process = timer(log(process))
# Execution order: timer's wrapper -> log's wrapper -> process

# Real-world: authentication + rate limiting
def require_auth(func):
    @wraps(func)
    def wrapper(request, *args, **kwargs):
        if not getattr(request, "user", None):
            raise PermissionError("Authentication required")
        return func(request, *args, **kwargs)
    return wrapper

def rate_limit(calls_per_minute=60):
    def decorator(func):
        @wraps(func)
        def wrapper(*args, **kwargs):
            return func(*args, **kwargs)    # simplified - real impl tracks time
        return wrapper
    return decorator

# Applied outermost first: rate_limit wraps require_auth wraps get_data
@rate_limit(calls_per_minute=100)
@require_auth
def get_data(request, item_id):
    return f"Data for {item_id}"

Frequently Asked Questions

A decorator is a function that takes another function as input, adds behavior, and returns a modified function (or callable). The @decorator syntax is syntactic sugar for func = decorator(func). Decorators implement cross-cutting concerns - logging, timing, caching, authentication, retry logic - without modifying the original function's code.

@functools.wraps(func) copies the original function's __name__, __doc__, __module__, and other attributes onto the wrapper. Without it, all decorated functions appear as "wrapper" in stack traces and their docstrings are lost. Always use @wraps when writing decorators - it takes one line and prevents confusing debugging sessions.

@staticmethod defines a method that receives neither self nor cls - it is just a regular function in the class namespace. @classmethod receives cls (the class itself) as the first argument. Use @classmethod for alternative constructors and factory methods. Use @staticmethod for utility functions that relate to the class conceptually but don't need access to instance or class state.

You need an extra level of nesting - a function that takes the arguments and returns a decorator: def repeat(times): def decorator(func): def wrapper(*args, **kwargs): ... return wrapper return decorator. Call it as @repeat(3). Alternatively, use functools.partial or a class with __call__ to create configurable decorators more cleanly.