Build intermediate Python Fundamentals

Python Decorators: A Complete Guide

· 3 min read

What Are Decorators?

A decorator is a function that takes another function as an argument and extends its behavior without explicitly modifying it. If you have used @property or @staticmethod, you have already used decorators.

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(name):
    print(f"Hello, {name}!")

say_hello("World")

Output:

Before the function call
Hello, World!
After the function call

Why Use Decorators?

Decorators are powerful because they let you add behavior to functions and classes without modifying their source code. Common use cases include:

  • Logging — Track when functions are called and with what arguments
  • Authentication — Check permissions before executing sensitive operations
  • Caching — Store results of expensive computations with @functools.lru_cache
  • Timing — Measure function execution time for profiling
  • Retry logic — Automatically retry failed operations

Building Your First Decorator

Let us start with the simplest possible decorator — one that logs every time a function is called.

def log_calls(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

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

add(3, 5)

The @ Syntax Is Just Sugar

Writing @log_calls above a function definition is identical to:

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

add = log_calls(add)

The @ syntax is purely convenience. Understanding this equivalence is key to understanding decorators.

Preserving Function Metadata

One problem with the simple wrapper above: add.__name__ now returns "wrapper" instead of "add". Fix this with functools.wraps:

from functools import wraps

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

Always use @wraps(func) in your decorators. It preserves __name__, __doc__, and other metadata from the original function.

Decorators with Arguments

Sometimes you want to pass arguments to the decorator itself. This requires an extra layer of nesting:

from functools import wraps

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

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

greet("World")
# Prints "Hello, World!" three times

The pattern is: repeat(3) returns decorator, which is then applied to greet.

Class-Based Decorators

You can also implement decorators as classes using __call__:

class CountCalls:
    def __init__(self, func):
        self.func = func
        self.count = 0

    def __call__(self, *args, **kwargs):
        self.count += 1
        print(f"{self.func.__name__} has been called {self.count} times")
        return self.func(*args, **kwargs)

@CountCalls
def say_hi():
    print("Hi!")

say_hi()
say_hi()

Class-based decorators are useful when you need the decorator to maintain state between calls.

Real-World Example: Timing Decorator

Here is a practical decorator you might actually use:

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

Key Takeaways

  1. Decorators wrap functions to extend their behavior
  2. The @decorator syntax is sugar for func = decorator(func)
  3. Always use @functools.wraps to preserve metadata
  4. Decorators with arguments need an extra nesting level
  5. Class-based decorators use __call__ and can maintain state
decorators functions advanced-python