Skip to content

Python Decorators: The Complete Guide with Practical Examples

Updated on

You write a function. It works. Then you need logging. You add print() calls inside it. Then you need timing. More lines. Then authentication checks. Even more lines. Soon the actual logic is buried under layers of cross-cutting concerns, and every new function needs the same boilerplate pasted in. Python decorators solve this problem. They let you wrap reusable behavior around any function -- without touching the function's own code.

📚

This guide covers Python decorators from the ground up: what they are, how they work internally, the @ syntax, decorators with arguments, functools.wraps, class-based decorators, built-in decorators, stacking, and real-world patterns you will use in production code. Every concept includes runnable code examples.

Prerequisites: First-Class Functions

Before decorators make sense, you need one concept: functions are objects in Python. You can assign them to variables, pass them as arguments, and return them from other functions. This is what "first-class functions" means.

def greet(name):
    return f"Hello, {name}"
 
# Assign a function to a variable
say_hello = greet
print(say_hello("Alice"))
# Hello, Alice
 
# Pass a function as an argument
def call_twice(func, arg):
    return func(arg) + " " + func(arg)
 
print(call_twice(greet, "Bob"))
# Hello, Bob Hello, Bob
 
# Return a function from a function
def make_greeter(greeting):
    def greeter(name):
        return f"{greeting}, {name}"
    return greeter
 
hi = make_greeter("Hi")
print(hi("Charlie"))
# Hi, Charlie

The third pattern -- a function that returns a function -- is the foundation of every decorator. The inner function "closes over" the outer function's variables (a closure). If this feels unfamiliar, re-read the make_greeter example until it clicks. Everything else in this guide builds on it.

What Is a Python Decorator?

A decorator is a function that takes another function as input and returns a new function that usually extends or modifies the original's behavior. That is the entire idea. No magic, no special syntax required -- just a function that wraps another function.

def my_decorator(func):
    def wrapper(*args, **kwargs):
        print("Before the function call")
        result = func(*args, **kwargs)
        print("After the function call")
        return result
    return wrapper
 
def say_hello():
    print("Hello!")
 
# Manually decorating
decorated = my_decorator(say_hello)
decorated()
# Before the function call
# Hello!
# After the function call

Here, my_decorator receives say_hello, wraps it inside wrapper, and returns wrapper. When you call decorated(), the wrapper runs its "before" logic, calls the original function, runs its "after" logic, and returns the result. The original function is untouched.

The @ Syntax Sugar

Writing decorated = my_decorator(say_hello) every time is verbose. Python provides the @ syntax to apply a decorator at function definition time:

def my_decorator(func):
    def wrapper(*args, **kwargs):
        print("Before the function call")
        result = func(*args, **kwargs)
        print("After the function call")
        return result
    return wrapper
 
@my_decorator
def say_hello():
    print("Hello!")
 
say_hello()
# Before the function call
# Hello!
# After the function call

@my_decorator above def say_hello() is exactly equivalent to say_hello = my_decorator(say_hello). The @ symbol is purely syntactic sugar -- it makes the intent visible at a glance.

Why functools.wraps Matters

There is a subtle problem with the basic decorator pattern. After decoration, the function loses its identity:

def my_decorator(func):
    def wrapper(*args, **kwargs):
        return func(*args, **kwargs)
    return wrapper
 
@my_decorator
def add(a, b):
    """Add two numbers."""
    return a + b
 
print(add.__name__)    # wrapper  (not "add")
print(add.__doc__)     # None     (not "Add two numbers.")

The decorated function's __name__, __doc__, and __module__ all point to the wrapper, not the original. This breaks debugging, logging, help output, and any framework that inspects function metadata (Flask route registration, pytest markers, API documentation generators).

The fix is functools.wraps:

from functools import wraps
 
def my_decorator(func):
    @wraps(func)
    def wrapper(*args, **kwargs):
        return func(*args, **kwargs)
    return wrapper
 
@my_decorator
def add(a, b):
    """Add two numbers."""
    return a + b
 
print(add.__name__)    # add
print(add.__doc__)     # Add two numbers.

@wraps(func) copies the original function's metadata onto the wrapper. Always use it. Every decorator example from this point forward includes it.

Decorators with Arguments

Sometimes you need a decorator that accepts configuration. For example, a @repeat(n=3) decorator that calls a function multiple times. This requires one more level of nesting -- a decorator factory:

from functools import wraps
 
def repeat(n=2):
    def decorator(func):
        @wraps(func)
        def wrapper(*args, **kwargs):
            result = None
            for _ in range(n):
                result = func(*args, **kwargs)
            return result
        return wrapper
    return decorator
 
@repeat(n=3)
def say_hi():
    print("Hi!")
 
say_hi()
# Hi!
# Hi!
# Hi!

The structure is three layers deep:

  1. repeat(n=3) -- the factory, called with arguments. Returns decorator.
  2. decorator(func) -- the actual decorator. Receives the function. Returns wrapper.
  3. wrapper(*args, **kwargs) -- the replacement function that runs at call time.

When Python sees @repeat(n=3), it first calls repeat(n=3), which returns decorator. Then it applies decorator to say_hi. The parentheses in @repeat(n=3) are the key difference from @my_decorator (no parentheses).

Class-Based Decorators

Any callable can be a decorator. Since classes with a __call__ method are callable, you can build decorators as classes. This is useful when the decorator needs to maintain state across multiple calls:

from functools import wraps
 
class CountCalls:
    """Decorator that counts how many times a function is called."""
 
    def __init__(self, func):
        wraps(func)(self)
        self.func = func
        self.count = 0
 
    def __call__(self, *args, **kwargs):
        self.count += 1
        print(f"Call {self.count} of {self.func.__name__}")
        return self.func(*args, **kwargs)
 
@CountCalls
def greet(name):
    print(f"Hello, {name}")
 
greet("Alice")
# Call 1 of greet
# Hello, Alice
 
greet("Bob")
# Call 2 of greet
# Hello, Bob
 
print(greet.count)
# 2

The __init__ method receives the function (just like a function-based decorator). The __call__ method replaces the wrapped function's invocation. The instance itself becomes the "decorated function," so you can attach attributes like count directly to it.

Built-in Decorators

Python ships with several decorators you will encounter constantly. Understanding them is non-negotiable for reading production code.

@property

Turns a method into a read-only attribute. Paired with @name.setter for write access:

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

Declares a method that does not receive the instance (self) or the class (cls). It is just a regular function namespaced inside the class:

class MathUtils:
    @staticmethod
    def add(a, b):
        return a + b
 
print(MathUtils.add(3, 4))  # 7

@classmethod

Receives the class itself (cls) as the first argument instead of an instance. Commonly used for alternative constructors:

class Date:
    def __init__(self, year, month, day):
        self.year = year
        self.month = month
        self.day = day
 
    @classmethod
    def from_string(cls, date_string):
        year, month, day = map(int, date_string.split("-"))
        return cls(year, month, day)
 
    def __repr__(self):
        return f"Date({self.year}, {self.month}, {self.day})"
 
d = Date.from_string("2026-02-11")
print(d)  # Date(2026, 2, 11)

Decorator Pattern Comparison

PatternUse CaseMaintains State?Accepts Arguments?Complexity
Simple function decoratorAdd behavior before/after a functionNoNoLow
Decorator with arguments (factory)Configurable behavior (retry count, log level)No (per call)YesMedium
Class-based decoratorCall counting, rate limiting, cachingYes (instance attrs)Via __init__ paramsMedium
@propertyComputed attributes, validation on setVia instanceNoLow
@staticmethodUtility functions grouped in a classNoNoLow
@classmethodAlternative constructors, class-level opsVia classNoLow
Stacked decoratorsComposing multiple behaviorsDepends on eachDepends on eachHigh

Stacking Multiple Decorators

You can apply multiple decorators to a single function. They execute bottom-up (closest to the function first):

from functools import wraps
 
def bold(func):
    @wraps(func)
    def wrapper(*args, **kwargs):
        return f"<b>{func(*args, **kwargs)}</b>"
    return wrapper
 
def italic(func):
    @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("World"))
# <b><i>Hello, World</i></b>

The execution order: greet is first wrapped by @italic (producing italic output), then that result is wrapped by @bold. Think of it as bold(italic(greet)). The bottom decorator is applied first.

Real-World Decorator Patterns

These are the patterns you will actually use in production. Each one is copy-paste ready.

Timer Decorator

Measure how long a function takes to execute:

import time
from functools import wraps
 
def timer(func):
    @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(1)
    return "done"
 
slow_function()
# slow_function took 1.0012s

This pattern is invaluable for profiling data science workflows. If you work in Jupyter notebooks, tools like RunCell (opens in a new tab) can help you profile and optimize cell execution with AI-powered agents, but a simple @timer decorator gives you instant visibility into function-level performance.

Retry Decorator

Automatically retry a function on failure with exponential backoff:

import time
from functools import wraps
 
def retry(max_attempts=3, delay=1, backoff=2):
    def decorator(func):
        @wraps(func)
        def wrapper(*args, **kwargs):
            attempts = 0
            current_delay = delay
            while attempts < max_attempts:
                try:
                    return func(*args, **kwargs)
                except Exception as e:
                    attempts += 1
                    if attempts == max_attempts:
                        raise
                    print(f"{func.__name__} failed (attempt {attempts}), "
                          f"retrying in {current_delay}s: {e}")
                    time.sleep(current_delay)
                    current_delay *= backoff
        return wrapper
    return decorator
 
@retry(max_attempts=3, delay=0.5, backoff=2)
def fetch_data(url):
    import random
    if random.random() < 0.7:
        raise ConnectionError("Network timeout")
    return {"status": "ok"}
 
# Will retry up to 3 times with 0.5s, 1s delays
result = fetch_data("https://api.example.com/data")

Caching / Memoization Decorator

Cache function results to avoid redundant computation. Python 3.9+ includes @functools.cache for this, but here is a manual version to show the mechanics:

from functools import wraps
 
def memoize(func):
    cache = {}
    @wraps(func)
    def wrapper(*args):
        if args in cache:
            print(f"Cache hit for {args}")
            return cache[args]
        result = func(*args)
        cache[args] = result
        return result
    return wrapper
 
@memoize
def fibonacci(n):
    if n < 2:
        return n
    return fibonacci(n - 1) + fibonacci(n - 2)
 
print(fibonacci(10))  # 55 (computed)
print(fibonacci(10))  # 55 (cache hit)

For production code, prefer @functools.lru_cache(maxsize=128) or @functools.cache (Python 3.9+). They handle edge cases and provide cache statistics:

from functools import lru_cache
 
@lru_cache(maxsize=256)
def expensive_computation(x, y):
    # Simulate heavy work
    return x ** y
 
print(expensive_computation(2, 10))       # 1024
print(expensive_computation.cache_info())  # CacheInfo(hits=0, misses=1, ...)

Logging Decorator

Log function calls with arguments and return values:

import logging
from functools import wraps
 
logging.basicConfig(level=logging.INFO)
 
def log_calls(func):
    @wraps(func)
    def wrapper(*args, **kwargs):
        args_repr = [repr(a) for a in args]
        kwargs_repr = [f"{k}={v!r}" for k, v in kwargs.items()]
        signature = ", ".join(args_repr + kwargs_repr)
        logging.info(f"Calling {func.__name__}({signature})")
        result = func(*args, **kwargs)
        logging.info(f"{func.__name__} returned {result!r}")
        return result
    return wrapper
 
@log_calls
def calculate_total(prices, tax_rate=0.08):
    return sum(prices) * (1 + tax_rate)
 
calculate_total([10, 20, 30], tax_rate=0.1)
# INFO:root:Calling calculate_total([10, 20, 30], tax_rate=0.1)
# INFO:root:calculate_total returned 66.0

Authentication / Authorization Decorator

Common in web frameworks like Flask and Django:

from functools import wraps
 
def require_auth(role="user"):
    def decorator(func):
        @wraps(func)
        def wrapper(user, *args, **kwargs):
            if not user.get("authenticated"):
                raise PermissionError("Authentication required")
            if user.get("role") != role and role != "user":
                raise PermissionError(f"Role '{role}' required")
            return func(user, *args, **kwargs)
        return wrapper
    return decorator
 
@require_auth(role="admin")
def delete_record(user, record_id):
    return f"Record {record_id} deleted by {user['name']}"
 
admin = {"name": "Alice", "authenticated": True, "role": "admin"}
guest = {"name": "Bob", "authenticated": True, "role": "user"}
 
print(delete_record(admin, 42))
# Record 42 deleted by Alice
 
try:
    delete_record(guest, 42)
except PermissionError as e:
    print(e)
# Role 'admin' required

Validation Decorator

Enforce argument types or value ranges:

from functools import wraps
 
def validate_types(**expected_types):
    def decorator(func):
        @wraps(func)
        def wrapper(*args, **kwargs):
            # Check positional args
            param_names = func.__code__.co_varnames[:func.__code__.co_argcount]
            for name, value in zip(param_names, args):
                if name in expected_types:
                    if not isinstance(value, expected_types[name]):
                        raise TypeError(
                            f"{name} must be {expected_types[name].__name__}, "
                            f"got {type(value).__name__}"
                        )
            # Check keyword args
            for name, value in kwargs.items():
                if name in expected_types:
                    if not isinstance(value, expected_types[name]):
                        raise TypeError(
                            f"{name} must be {expected_types[name].__name__}, "
                            f"got {type(value).__name__}"
                        )
            return func(*args, **kwargs)
        return wrapper
    return decorator
 
@validate_types(x=int, y=int)
def multiply(x, y):
    return x * y
 
print(multiply(3, 4))     # 12
 
try:
    multiply("3", 4)
except TypeError as e:
    print(e)
# x must be int, got str

Common Mistakes with Python Decorators

1. Forgetting to call the original function

# WRONG: returns None
def bad_decorator(func):
    def wrapper(*args, **kwargs):
        print("before")
        func(*args, **kwargs)  # result is discarded!
        print("after")
    return wrapper
 
# CORRECT: return the result
def good_decorator(func):
    def wrapper(*args, **kwargs):
        print("before")
        result = func(*args, **kwargs)
        print("after")
        return result  # pass the result through
    return wrapper

2. Missing functools.wraps

As shown earlier, without @wraps(func), the decorated function loses its __name__, __doc__, and other metadata. This causes confusing tracebacks and breaks introspection tools.

3. Wrong stacking order

Decorators execute bottom-up but apply top-down. If @auth must run before @cache, put @auth on top:

@auth     # runs first at call time
@cache    # applied first, runs second
def get_data():
    ...

4. Decorating with arguments but forgetting the factory layer

# WRONG: @repeat without () when it expects arguments
# TypeError: repeat() missing 1 required positional argument: 'func'
 
# CORRECT:
@repeat(n=3)   # parentheses call the factory
def say_hi():
    print("Hi!")

5. Not using *args and **kwargs

Hardcoding specific parameters makes the decorator work only for functions with that exact signature. Always use *args, **kwargs for the wrapper:

# WRONG: only works for functions with no arguments
def bad_decorator(func):
    def wrapper():
        return func()
    return wrapper
 
# CORRECT: works for any function signature
def good_decorator(func):
    @wraps(func)
    def wrapper(*args, **kwargs):
        return func(*args, **kwargs)
    return wrapper

Decorators in Data Science Workflows

Decorators are particularly useful in data science and machine learning pipelines:

  • @timer -- profile data loading, feature engineering, and model training steps
  • @retry -- handle flaky API calls and database connections
  • @lru_cache -- cache expensive feature computations or repeated API responses
  • @log_calls -- track experiment parameters and results for reproducibility
  • @validate_types -- catch type errors early in data pipelines before they cascade

If you build data pipelines in Jupyter notebooks, consider pairing decorators with an AI-powered notebook environment like RunCell (opens in a new tab), which can help you debug decorator behavior, profile wrapped functions, and optimize notebook cell execution automatically.

Frequently Asked Questions

What is a decorator in Python?

A decorator is a function that takes another function as an argument and returns a new function, typically extending or modifying the original function's behavior. The @ symbol above a function definition is syntactic sugar for applying the decorator. Decorators enable clean separation of cross-cutting concerns like logging, caching, and authentication from business logic.

What does functools.wraps do?

functools.wraps is a decorator applied to the wrapper function inside your decorator. It copies the __name__, __doc__, __module__, __qualname__, and __dict__ from the original function to the wrapper, preserving the function's identity. Without it, debugging tools, documentation generators, and frameworks that inspect function metadata will see the wrapper's details instead of the original function's.

Can you stack multiple decorators on one function?

Yes. Place multiple @decorator lines above the function definition. They apply bottom-up: the decorator closest to the def line wraps the function first, and the topmost decorator wraps last. At call time, the outermost decorator's logic runs first. Think of @a @b def f() as a(b(f)).

What is the difference between @staticmethod and @classmethod?

@staticmethod creates a method that receives neither the instance (self) nor the class (cls). It is essentially a regular function namespaced inside the class. @classmethod receives the class as its first argument (cls), making it useful for alternative constructors and class-level operations that need access to the class itself.

When should I use a class-based decorator instead of a function-based one?

Use a class-based decorator when you need to maintain state across multiple calls to the decorated function -- for example, counting calls, implementing rate limiting, or accumulating results. Function-based decorators can technically maintain state via closures, but class-based decorators make the state explicit and accessible as instance attributes.

Are decorators slow? Do they add overhead?

Decorators add a minimal function call overhead -- one extra function call per invocation. For most applications, this is negligible. The exception is extremely hot loops where microseconds matter. In those cases, consider applying the decorator only during development (e.g., @timer) and removing it in production, or using the decorator conditionally.

Conclusion

Python decorators are functions that wrap other functions. That single idea powers logging, caching, authentication, retry logic, validation, and dozens of other cross-cutting patterns. The mechanics come down to three concepts:

  1. Functions are objects -- you can pass them around and return them.
  2. Closures -- inner functions capture variables from their enclosing scope.
  3. The @ syntax -- shorthand for func = decorator(func).

The practical rules to remember:

  • Always use @functools.wraps(func) on your wrapper to preserve metadata.
  • Use *args, **kwargs in the wrapper to support any function signature.
  • Use a decorator factory (three-layer nesting) when you need configurable decorators.
  • Use class-based decorators when you need to track state across calls.
  • Stack decorators carefully -- bottom one applies first, top one runs first.

Start with the simple patterns (@timer, @log_calls) and build up to parameterized decorators as your codebase demands them. Once decorators click, you will wonder how you ever wrote Python without them.

📚