Python decorators are one of the most powerful and elegant features in Python — and one of the most searched topics by intermediate developers. If you've ever seen @staticmethod, @classmethod, or a mysterious @some_function sitting above a function definition and wondered what it does, this guide is for you. Python decorators let you wrap a function inside another function to extend or modify its behavior without changing the original function's code. That's the core idea. Let's dig into how python decorators work, how to write them from scratch, and when to reach for them.

What Are Python Decorators?

A python decorator is a callable (usually a function) that takes another function as input, adds some behavior around it, and returns a new callable. In plain English: a decorator wraps a function.

Here's the simplest possible example:

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

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

greet = my_decorator(greet)
greet()

Output:

Before the function runs
Hello!
After the function runs

What happened here? my_decorator is a python wrapper function — it wraps greet with extra behavior. Instead of manually reassigning greet = my_decorator(greet), Python gives you the @ syntax to do this cleanly.

@my_decorator
def greet():
    print("Hello!")

This is exactly equivalent to writing greet = my_decorator(greet). The @ symbol is just syntactic sugar.

The @ Syntax and How Python Applies It

When Python sees @my_decorator above a function definition, it:

  1. Defines the function below it (greet)
  2. Passes that function to my_decorator
  3. Replaces the original name (greet) with whatever my_decorator returns

This happens at definition time, not at call time. So the wrapping happens the moment your script loads the function — not when you call it. This is important because it means python function decorators are applied once and stay applied for the lifetime of the program.

def shout(func):
    def wrapper():
        result = func()
        return result.upper()
    return wrapper

@shout
def message():
    return "good morning"

print(message())  # GOOD MORNING

The wrapper inside shout is the actual python wrapper function doing the work. It calls the original func(), grabs the result, and transforms it before returning.

Decorators with Arguments

Real functions take arguments. Your decorator's wrapper needs to handle those too. Use *args and **kwargs to pass any arguments through:

def log_call(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_call
def multiply(x, y):
    return x * y

multiply(4, 6)

Output:

Calling multiply with args=(4, 6), kwargs={}
multiply returned 24

By using *args and **kwargs in the wrapper, the decorator works with any function regardless of its signature. This is the standard pattern for writing reusable python function decorators.

Preserving Metadata with functools.wraps

Here's a subtle bug that catches many developers. When you decorate a function, the wrapper replaces the original function — including its name and docstring:

def log_call(func):
    def wrapper(*args, **kwargs):
        return func(*args, **kwargs)
    return wrapper

@log_call
def calculate_tax(income):
    """Calculate income tax."""
    return income * 0.2

print(calculate_tax.__name__)   # wrapper  ← WRONG
print(calculate_tax.__doc__)    # None     ← WRONG

The function now thinks its name is wrapper. This breaks debugging, logging, and documentation tools. The fix is python functools wraps — a decorator that copies the original function's metadata onto the wrapper:

from functools import wraps

def log_call(func):
    @wraps(func)
    def wrapper(*args, **kwargs):
        return func(*args, **kwargs)
    return wrapper

@log_call
def calculate_tax(income):
    """Calculate income tax."""
    return income * 0.2

print(calculate_tax.__name__)   # calculate_tax  ✓
print(calculate_tax.__doc__)    # Calculate income tax.  ✓

Always use @wraps(func) from functools when writing python decorators. It's a small habit that prevents a lot of head-scratching later.

Decorators That Accept Their Own Arguments

Sometimes you want to configure a decorator itself, like @repeat(3) to run a function three times. This requires a decorator factory — a function that returns a decorator:

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(3)
def announce(message):
    print(message)

announce("Deploying to production...")

Output:

Deploying to production...
Deploying to production...
Deploying to production...

Notice the three-layer nesting: repeatdecoratorwrapper. The outermost layer captures the configuration (times), the middle layer captures the function, and the innermost does the actual work. This is the standard structure for configurable python decorator examples.

Stacking Multiple Decorators

Python lets you stack multiple decorators on a single function. They apply from bottom to top (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 get_title():
    return "Python Decorators Guide"

print(get_title())

Output:

<b><i>Python Decorators Guide</i></b>

@italic is applied first (wraps the original function in italic tags), then @bold wraps the result. Think of it as reading the decorators from bottom to top when reasoning about execution order.

The Python @property Decorator

The python @property decorator is a built-in decorator that turns a method into a managed attribute. It lets you define getter, setter, and deleter logic for an attribute while keeping a clean, attribute-style access syntax.

Without @property, you'd have to call a method explicitly: obj.get_price(). With it, you access it like a plain attribute: obj.price.

class Product:
    def __init__(self, name, price):
        self.name = name
        self._price = price

    @property
    def price(self):
        return self._price

    @price.setter
    def price(self, value):
        if value < 0:
            raise ValueError("Price cannot be negative")
        self._price = value

item = Product("Keyboard", 79.99)
print(item.price)      # 79.99

item.price = 89.99
print(item.price)      # 89.99

item.price = -10       # ValueError: Price cannot be negative

The @property decorator defines the getter. @price.setter defines what happens when you assign to item.price. This is one of the most common python decorator examples you'll encounter in real codebases because it enforces validation without breaking the clean attribute-access interface.

Python Class Decorators

So far, all decorators have been functions. But python class decorators work too — any callable can be a decorator, and classes are callable (you call them to create instances).

A class decorator wraps a function by storing it as an instance attribute, then using __call__ to make the instance callable:

from functools import wraps

class RateLimit:
    def __init__(self, max_calls):
        self.max_calls = max_calls
        self.call_count = 0

    def __call__(self, func):
        @wraps(func)
        def wrapper(*args, **kwargs):
            self.call_count += 1
            if self.call_count > self.max_calls:
                raise RuntimeError(f"Rate limit exceeded: max {self.max_calls} calls allowed")
            return func(*args, **kwargs)
        return wrapper

limiter = RateLimit(max_calls=3)

@limiter
def fetch_data(endpoint):
    return f"Data from {endpoint}"

print(fetch_data("/api/users"))    # Data from /api/users
print(fetch_data("/api/orders"))   # Data from /api/orders
print(fetch_data("/api/products")) # Data from /api/products
print(fetch_data("/api/stats"))    # RuntimeError: Rate limit exceeded

Python class decorators are useful when your decorator needs to maintain state (like a call counter here) across multiple invocations, because class instances naturally hold state in their attributes.

You can also use a class as a decorator that replaces the decorated function with an instance:

class Memoize:
    def __init__(self, func):
        self.func = func
        self.cache = {}
        wraps(func)(self)

    def __call__(self, *args):
        if args not in self.cache:
            self.cache[args] = self.func(*args)
        return self.cache[args]

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

print(fibonacci(10))  # 55 (fast, results cached)

Here, @Memoize replaces fibonacci with an instance of Memoize. The instance is callable because it defines __call__.

Decorating Methods Inside Classes

When you write python decorators for methods inside a class, pay attention to self. Your wrapper must pass self through correctly, which *args already handles:

from functools import wraps

def validate_positive(func):
    @wraps(func)
    def wrapper(self, value, *args, **kwargs):
        if value <= 0:
            raise ValueError(f"{func.__name__} requires a positive value, got {value}")
        return func(self, value, *args, **kwargs)
    return wrapper

class BankAccount:
    def __init__(self, balance=0):
        self.balance = balance

    @validate_positive
    def deposit(self, amount):
        self.balance += amount
        return self.balance

    @validate_positive
    def withdraw(self, amount):
        self.balance -= amount
        return self.balance

account = BankAccount(100)
print(account.deposit(50))    # 150
print(account.withdraw(30))   # 120
account.deposit(-20)          # ValueError: deposit requires a positive value, got -20

Full Working Example

Here's a complete program bringing together everything covered — wrapper functions, functools.wraps, configurable decorators, @property, and class decorators — applied to a realistic task manager:

import time
from functools import wraps


# --- Decorator 1: Execution Timer ---
def timer(func):
    """Logs how long a function takes to run."""
    @wraps(func)
    def wrapper(*args, **kwargs):
        start = time.perf_counter()
        result = func(*args, **kwargs)
        elapsed = time.perf_counter() - start
        print(f"[timer] {func.__name__} completed in {elapsed:.4f}s")
        return result
    return wrapper


# --- Decorator 2: Retry on Failure (configurable) ---
def retry(max_attempts=3, delay=0.5):
    """Retries a function up to max_attempts times if it raises an 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 Exception as exc:
                    last_error = exc
                    print(f"[retry] Attempt {attempt} failed: {exc}")
                    if attempt < max_attempts:
                        time.sleep(delay)
            raise RuntimeError(f"All {max_attempts} attempts failed") from last_error
        return wrapper
    return decorator


# --- Class with @property decorator ---
class Task:
    def __init__(self, title, priority):
        self.title = title
        self._priority = None
        self.priority = priority  # triggers the setter

    @property
    def priority(self):
        return self._priority

    @priority.setter
    def priority(self, value):
        allowed = {"low", "medium", "high"}
        if value not in allowed:
            raise ValueError(f"Priority must be one of {allowed}, got '{value}'")
        self._priority = value

    def __repr__(self):
        return f"Task(title={self.title!r}, priority={self.priority!r})"


# --- Class Decorator: Call Counter ---
class CallCounter:
    def __init__(self, func):
        wraps(func)(self)
        self.func = func
        self.count = 0

    def __call__(self, *args, **kwargs):
        self.count += 1
        print(f"[counter] {self.func.__name__} called {self.count} time(s)")
        return self.func(*args, **kwargs)


# --- Task Manager using all decorators ---
class TaskManager:
    def __init__(self):
        self.tasks = []

    @timer
    def load_tasks(self, raw_tasks):
        """Simulates loading tasks from a data source."""
        for t in raw_tasks:
            task = Task(title=t["title"], priority=t["priority"])
            self.tasks.append(task)
        return len(self.tasks)

    @retry(max_attempts=3, delay=0.1)
    def save_to_disk(self, filename):
        """Simulates saving with a flaky I/O operation."""
        if not filename.endswith(".json"):
            raise ValueError("Only .json files are supported")
        print(f"[save] Tasks saved to {filename}")
        return True

    @CallCounter
    def get_high_priority(self):
        """Returns all high-priority tasks."""
        return [t for t in self.tasks if t.priority == "high"]


# --- Main ---
if __name__ == "__main__":
    manager = TaskManager()

    raw = [
        {"title": "Fix login bug", "priority": "high"},
        {"title": "Update README", "priority": "low"},
        {"title": "Deploy v2.0", "priority": "high"},
        {"title": "Code review", "priority": "medium"},
    ]

    count = manager.load_tasks(raw)
    print(f"Loaded {count} tasks\n")

    try:
        manager.save_to_disk("backup.txt")   # will fail 3 times
    except RuntimeError as e:
        print(f"Save failed: {e}\n")

    manager.save_to_disk("tasks.json")
    print()

    high = manager.get_high_priority()
    high = manager.get_high_priority()
    print(f"\nHigh priority tasks: {high}")

Output:

[timer] load_tasks completed in 0.0001s
Loaded 4 tasks

[retry] Attempt 1 failed: Only .json files are supported
[retry] Attempt 2 failed: Only .json files are supported
[retry] Attempt 3 failed: Only .json files are supported
Save failed: All 3 attempts failed

[save] Tasks saved to tasks.json

[counter] get_high_priority called 1 time(s)
[counter] get_high_priority called 2 time(s)

High priority tasks: [Task(title='Fix login bug', priority='high'), Task(title='Deploy v2.0', priority='high')]

This example uses every pattern covered: @timer as a standard python wrapper function, @retry(...) as a configurable decorator factory using functools.wraps, @property and its setter for validated attributes, and @CallCounter as a python class decorator that maintains state. All of them work together cleanly in a real program structure.

For deeper reading, check out the Python official documentation on decorators and functools and the Python Data Model reference on __call__.


Ready to go deeper? This guide is part of a complete Python tutorial series covering everything from basics to advanced topics. Whether you're just starting out or looking to sharpen your skills, the full series has you covered. Learn the full Python tutorial here.