Published on

Python Decorators - The Magic Behind the @ Symbol

Authors

Introduction

Decorators are one of Python's most elegant features. They let you modify or enhance functions without changing their code. You've seen @app.route(), @staticmethod, @property — but do you know how they actually work?

In this guide, we'll go from basics to building real-world decorators.

Functions are First-Class Objects

To understand decorators, you first need to know that in Python, functions are objects. They can be passed as arguments, returned from other functions, and assigned to variables.

main.py
def greet():
    return "Hello!"

# Assigning a function to a variable
my_func = greet
print(my_func())  # "Hello!"

# Passing a function as an argument
def run(func):
    return func()

print(run(greet))  # "Hello!"

Your First Decorator

A decorator is a function that takes another function as input and returns a modified version:

main.py
def my_decorator(func):
    def wrapper():
        print("Before the function runs")
        func()
        print("After the function runs")
    return wrapper

def say_hello():
    print("Hello!")

# Manually decorating
say_hello = my_decorator(say_hello)
say_hello()

# Output:
# Before the function runs
# Hello!
# After the function runs

The @ syntax is just shorthand for the above:

main.py
@my_decorator
def say_hello():
    print("Hello!")

say_hello()  # Same result as above

Preserving Function Metadata with functools.wraps

Without functools.wraps, your decorated function loses its name and docstring:

main.py
from functools import wraps

def my_decorator(func):
    @wraps(func)  # Always add this!
    def wrapper(*args, **kwargs):
        print("Before")
        result = func(*args, **kwargs)
        print("After")
        return result
    return wrapper

@my_decorator
def add(a, b):
    """Adds two numbers."""
    return a + b

print(add.__name__)  # "add" (not "wrapper")
print(add.__doc__)   # "Adds two numbers."

Decorators with Arguments

main.py
from functools import wraps

def repeat(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(times=3)
def say_hi():
    print("Hi!")

say_hi()
# Hi!
# Hi!
# Hi!

Real-World Example: Timing Decorator

main.py
import time
from functools import wraps

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

@timer
def slow_function():
    time.sleep(1)
    return "Done"

slow_function()
# slow_function ran in 1.0012s

Real-World Example: Authentication Decorator

main.py
from functools import wraps

def require_auth(func):
    @wraps(func)
    def wrapper(user, *args, **kwargs):
        if not user.get("is_authenticated"):
            raise PermissionError("You must be logged in!")
        return func(user, *args, **kwargs)
    return wrapper

@require_auth
def get_dashboard(user):
    return f"Welcome, {user['name']}!"

# Test
user = {"name": "Alice", "is_authenticated": True}
print(get_dashboard(user))  # "Welcome, Alice!"

guest = {"name": "Guest", "is_authenticated": False}
get_dashboard(guest)  # Raises PermissionError

Real-World Example: Caching Decorator

main.py
from functools import wraps

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

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

print(fibonacci(50))  # Fast! Results are cached

Python's built-in functools.lru_cache does the same thing and is even more powerful.

Class-Based Decorators

You can also create decorators using classes:

main.py
from functools import wraps

class retry:
    def __init__(self, max_attempts=3):
        self.max_attempts = max_attempts

    def __call__(self, func):
        @wraps(func)
        def wrapper(*args, **kwargs):
            for attempt in range(self.max_attempts):
                try:
                    return func(*args, **kwargs)
                except Exception as e:
                    print(f"Attempt {attempt + 1} failed: {e}")
            raise Exception("All attempts failed")
        return wrapper

@retry(max_attempts=3)
def risky_call():
    import random
    if random.random() < 0.7:
        raise ValueError("Random error")
    return "Success!"

print(risky_call())

Stacking Multiple Decorators

main.py
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 get_text():
    return "Hello"

print(get_text())  # <b><i>Hello</i></b>

Decorators are applied bottom-up@italic is applied first, then @bold.

Conclusion

Decorators are one of the most powerful patterns in Python. They let you add logging, caching, authentication, timing, and more to any function — without touching its code. Master decorators and you'll write cleaner, more reusable Python code every time.