Decorators

Daniel Sarney
Python Intermediate

Decorators are one of Python's most elegant and powerful features. They allow you to modify or extend the behavior of functions without changing their code. Decorators are used throughout Python's standard library and popular frameworks, making them essential to understand.

In this lesson, you'll learn what decorators are, how to create your own, and how to use them for common tasks like timing functions, logging, validation, and caching. By the end, you'll be able to write decorators that make your code more maintainable and reusable.

What You'll Learn

  • Understanding decorators and their syntax
  • Creating simple decorators
  • Decorators with arguments
  • Multiple decorators
  • Built-in decorators (@property, @staticmethod, @classmethod)
  • Practical decorator examples (timing, logging, validation)

Understanding Decorators

A decorator is a function that takes another function and extends its behavior without modifying it. The @ symbol is syntactic sugar that makes decorators easy to use:

# Simple decorator example
def my_decorator(func):
    def wrapper():
        print("Something is happening before the function is called.")
        func()
        print("Something is happening after the function is called.")
    return wrapper

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

say_hello()
# Something is happening before the function is called.
# Hello!
# Something is happening after the function is called.

Without the @ syntax, you'd write:

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

say_hello = my_decorator(say_hello)
say_hello()

Creating Simple Decorators

Let's create a practical decorator that times function execution:

import time

def timer(func):
    """Decorator that times function execution."""
    def wrapper(*args, **kwargs):
        start = time.time()
        result = func(*args, **kwargs)
        end = time.time()
        print(f"{func.__name__} took {end - start:.4f} seconds")
        return result
    return wrapper

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

result = slow_function()  # slow_function took 1.0012 seconds

The *args and **kwargs ensure the decorator works with any function signature.

Preserving Function Metadata

When you create a decorator, the wrapped function loses its original metadata (name, docstring, etc.). Use functools.wraps to preserve it:

from functools import wraps

def timer(func):
    """Decorator that times function execution."""
    @wraps(func)
    def wrapper(*args, **kwargs):
        start = time.time()
        result = func(*args, **kwargs)
        end = time.time()
        print(f"{func.__name__} took {end - start:.4f} seconds")
        return result
    return wrapper

@timer
def calculate_sum(numbers):
    """Calculate the sum of numbers."""
    return sum(numbers)

print(calculate_sum.__name__)  # calculate_sum (not 'wrapper')
print(calculate_sum.__doc__)  # Calculate the sum of numbers.

Decorators with Arguments

Sometimes you need decorators that accept arguments. This requires an extra layer of nesting:

def repeat(times):
    """Decorator that repeats a function call."""
    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 greet(name):
    print(f"Hello, {name}!")

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

Here's another example with a retry decorator:

import time
from functools import wraps

def retry(max_attempts=3, delay=1):
    """Retry a function if it raises an exception."""
    def decorator(func):
        @wraps(func)
        def wrapper(*args, **kwargs):
            for attempt in range(max_attempts):
                try:
                    return func(*args, **kwargs)
                except Exception as e:
                    if attempt == max_attempts - 1:
                        raise
                    print(f"Attempt {attempt + 1} failed: {e}. Retrying...")
                    time.sleep(delay)
        return wrapper
    return decorator

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

Multiple Decorators

You can stack multiple decorators on a single function. They're applied from bottom to top:

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("Alice"))  # <b><i>Hello, Alice!</i></b>

Built-in Decorators

Python provides several useful built-in decorators:

@property

The @property decorator lets you define methods that are accessed like attributes:

class Circle:
    def __init__(self, radius):
        self._radius = radius

    @property
    def radius(self):
        """Get the radius."""
        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):
        """Calculate the area."""
        return 3.14159 * self._radius ** 2

circle = Circle(5)
print(circle.radius)  # 5 (accessed like attribute)
print(circle.area)    # 78.53975
circle.radius = 10
print(circle.area)     # 314.159

@staticmethod and @classmethod

These decorators define methods that don't require an instance:

class MathUtils:
    PI = 3.14159

    @staticmethod
    def add(a, b):
        """Static method - no access to class or instance."""
        return a + b

    @classmethod
    def get_pi(cls):
        """Class method - has access to class but not instance."""
        return cls.PI

# Can be called on class or instance
print(MathUtils.add(3, 4))        # 7
print(MathUtils.get_pi())          # 3.14159

utils = MathUtils()
print(utils.add(5, 6))             # 11
print(utils.get_pi())              # 3.14159

Practical Decorator Examples

Here are real-world decorator patterns:

# Example 1: Logging decorator
from functools import wraps
import logging

logging.basicConfig(level=logging.INFO)

def log_function_call(func):
    @wraps(func)
    def wrapper(*args, **kwargs):
        logging.info(f"Calling {func.__name__} with args={args}, kwargs={kwargs}")
        result = func(*args, **kwargs)
        logging.info(f"{func.__name__} returned {result}")
        return result
    return wrapper

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

result = add(3, 4)  # Logs function call and return value

# Example 2: Validation decorator
def validate_positive(func):
    @wraps(func)
    def wrapper(*args, **kwargs):
        for arg in args:
            if isinstance(arg, (int, float)) and arg < 0:
                raise ValueError("All arguments must be positive")
        return func(*args, **kwargs)
    return wrapper

@validate_positive
def divide(a, b):
    return a / b

# Example 3: Caching decorator (simple version)
from functools import wraps

def cache(func):
    cache_dict = {}
    @wraps(func)
    def wrapper(*args, **kwargs):
        key = str(args) + str(kwargs)
        if key not in cache_dict:
            cache_dict[key] = func(*args, **kwargs)
        return cache_dict[key]
    return wrapper

@cache
def expensive_calculation(n):
    print(f"Calculating for {n}...")
    return sum(i ** 2 for i in range(n))

print(expensive_calculation(1000))  # Calculates
print(expensive_calculation(1000))  # Uses cache (no calculation)

Try It Yourself

Practice creating decorators:

  1. Timing Decorator: Create a decorator that times function execution and only prints if it takes longer than a specified threshold.

  2. Rate Limiting: Create a decorator that limits how many times a function can be called per minute.

  3. Type Checking: Create a decorator that validates function arguments are of the correct type.

  4. Memoization: Improve the caching decorator to handle mutable arguments properly.

  5. Authorization: Create a decorator that checks if a user has permission to call a function.

Summary

Decorators are a powerful Python feature that let you modify function behavior without changing their code. You can create simple decorators, decorators with arguments, and stack multiple decorators. Built-in decorators like @property, @staticmethod, and @classmethod are essential for object-oriented programming.

Decorators appear throughout Python's ecosystem and are used for logging, timing, caching, validation, and more. Understanding them will help you write more maintainable, reusable code and better understand how Python frameworks work.

What's Next?

In the next lesson, we'll explore generators and iterators. You'll learn how to create memory-efficient iterators using the yield keyword, understand when generators are better than lists, and create custom iterable objects. These concepts are essential for working with large datasets efficiently.

Video Tutorial Coming Soon

The video tutorial for this lesson will be available soon. Check back later!

Continue Learning

12 lessons
Python For Beginners

Start your learning journey with Python For Beginners. This course includes comprehensive lessons covering everything you need to know.