Python & AI Tutorials Logo
Python Programming

38. Decorators: Adding Behavior to Functions

Decorators are one of Python's most powerful features for writing clean, reusable code. They allow you to modify or enhance the behavior of functions without changing their actual code. In this chapter, we'll build on your understanding of first-class functions and closures from Chapter 23 to explore how decorators work and how to use them effectively.

38.1) What Decorators Are and Why They're Useful

A decorator is a function that takes another function as input and returns a modified version of that function. This is possible because, as we learned in Chapter 23, functions in Python are first-class objects—they can be passed as arguments and returned from other functions. Decorators let you "wrap" additional behavior around existing functions, making it easy to add common functionality like logging, timing, validation, or access control without cluttering your core logic.

Why Decorators Matter

Imagine you have several functions in your program, and you want to log when each one is called. Without decorators, you might write something like this:

python
# Without decorators - duplicated logging code
def calculate_total(prices):
    print("Calling calculate_total")
    result = sum(prices)
    print(f"calculate_total returned: {result}")
    return result
 
def find_average(numbers):
    print("Calling find_average")
    result = sum(numbers) / len(numbers)
    print(f"find_average returned: {result}")
    return result
 
def process_order(order_id):
    print("Calling process_order")
    result = f"Order {order_id} processed"
    print(f"process_order returned: {result}")
    return result
 
# Using the functions
calculate_total([10, 20, 30])
# Output:
# Calling calculate_total
# calculate_total returned: 60

This approach has several problems:

  1. Code duplication: The logging lines are repeated in every function
  2. Mixing concerns: Logging code is mixed with business logic
  3. Hard to maintain: If you want to change the logging format, you must update every function
  4. Easy to forget: New functions might not include logging

Decorators solve these problems by letting you separate the logging behavior from your core functions:

python
# With decorators - clean and maintainable
# (We'll learn how to create @log_calls in this chapter)
 
@log_calls
def calculate_total(prices):
    return sum(prices)
 
@log_calls
def find_average(numbers):
    return sum(numbers) / len(numbers)
 
@log_calls
def process_order(order_id):
    return f"Order {order_id} processed"
 
# Using the functions produces the same output
calculate_total([10, 20, 30])
# Output:
# Calling calculate_total
# calculate_total returned: 60

The difference? The logging behavior is defined once in the @log_calls decorator and reused everywhere. Your core functions stay clean and focused on their primary purpose.

Common Use Cases for Decorators

Decorators are particularly useful for:

  • Logging: Recording when functions are called and what they return
  • Timing: Measuring how long functions take to execute
  • Validation: Checking that function arguments meet certain requirements
  • Caching: Storing results of expensive function calls for reuse
  • Access control: Checking permissions before allowing function execution
  • Retry logic: Automatically retrying failed operations
  • Type checking: Validating argument and return types

The key advantage is that you write the decorator once and can apply it to many functions with a single line of code.

38.2) Functions as Objects: The Foundation of Decorators

Before we can understand decorators, we need to review and expand on the concept that functions are first-class objects in Python. As we learned in Chapter 23, this means functions can be assigned to variables, passed as arguments, and returned from other functions.

Functions Can Be Assigned to Variables

When you define a function, Python creates a function object and binds it to a name:

python
def greet(name):
    return f"Hello, {name}!"
 
# The function object can be assigned to another variable
say_hello = greet
 
# Both names refer to the same function object
print(greet("Alice"))      # Output: Hello, Alice!
print(say_hello("Bob"))    # Output: Hello, Bob!

The names greet and say_hello both refer to the same function object. This is fundamental to how decorators work.

Functions Can Be Passed as Arguments

You can pass functions to other functions just like any other value:

python
def apply_twice(func, value):
    """Apply a function to a value twice."""
    result = func(value)
    result = func(result)
    return result
 
def add_five(x):
    return x + 5
 
result = apply_twice(add_five, 10)
print(result)  # Output: 20 (10 + 5 = 15, then 15 + 5 = 20)

Here, apply_twice receives the add_five function as an argument and calls it twice.

Functions Can Return Other Functions

A function can create and return a new function:

python
def make_multiplier(factor):
    """Create a function that multiplies by a specific factor."""
    def multiply(x):
        return x * factor
    return multiply
 
times_three = make_multiplier(3)
times_five = make_multiplier(5)
 
print(times_three(10))  # Output: 30
print(times_five(10))   # Output: 50

The make_multiplier function returns a new function that "remembers" the factor value through closure (as we learned in Chapter 23).

Wrapping Functions: The Core Decorator Pattern

The decorator pattern combines these concepts: a function that takes a function as input, creates a wrapper function that adds behavior, and returns the wrapper:

python
def simple_wrapper(original_func):
    """Wrap a function with additional behavior."""
    def wrapper():
        print("Before calling the function")
        result = original_func()
        print("After calling the function")
        return result
    return wrapper
 
def say_hello():
    print("Hello!")
    return "greeting"
 
# Manually wrap the function
wrapped_hello = simple_wrapper(say_hello)
return_value = wrapped_hello()
# Output:
# Before calling the function
# Hello!
# After calling the function
 
print(f"Returned: {return_value}")
# Output: Returned: greeting

Let's trace what happens:

  1. simple_wrapper receives say_hello as original_func
  2. It creates a new function wrapper that:
    • Prints "Before calling the function"
    • Calls original_func() (which is say_hello)
    • Prints "After calling the function"
    • Returns the result
  3. simple_wrapper returns the wrapper function
  4. When we call wrapped_hello(), we're actually calling wrapper, which calls the original say_hello inside

This is the core pattern behind all decorators.

Handling Functions with Arguments

The above wrapper only works with functions that take no arguments. To make it work with any function, we need *args and **kwargs:

python
def flexible_wrapper(original_func):
    """Wrap a function that can accept any arguments."""
    def wrapper(*args, **kwargs):
        # *args captures positional arguments
        # **kwargs captures keyword arguments
        print("Before calling the function")
        result = original_func(*args, **kwargs)
        print("After calling the function")
        return result
    return wrapper
 
def greet(name, greeting="Hello"):
    return f"{greeting}, {name}!"
 
# Manually wrap the function
greet = flexible_wrapper(greet)
 
result = greet("Alice")
# Output:
# Before calling the function
# After calling the function
 
print(result)
# Output: Hello, Alice!
 
result = greet("Bob", greeting="Hi")
# Output:
# Before calling the function
# After calling the function
 
print(result)
# Output: Hi, Bob!

How *args and **kwargs work:

As we learned in Chapter 20, *args and **kwargs allow functions to accept a variable number of arguments:

  • *args collects all positional arguments into a tuple
  • **kwargs collects all keyword arguments into a dictionary
  • When we call original_func(*args, **kwargs), we unpack them back as arguments to the original function

This pattern allows our wrapper to work with any function, regardless of how many arguments it takes.

Moving to Cleaner Syntax

This pattern is the foundation of decorators. The decorator syntax we'll learn next is just a cleaner way to apply this pattern. Instead of writing:

python
greet = flexible_wrapper(greet)

We'll use the @ syntax:

python
@flexible_wrapper
def greet(name, greeting="Hello"):
    return f"{greeting}, {name}!"

Both do exactly the same thing—the @ syntax is just syntactic sugar that makes the code cleaner and more readable.

38.3) The @decorator Syntax: Cleaner Application

Writing function_name = decorator(function_name) works, but it's verbose and easy to forget. Python provides the @decorator syntax as a cleaner way to apply decorators.

Using the @ Symbol

Instead of manually wrapping a function, you can place @decorator_name on the line immediately before the function definition:

python
def log_call(func):
    """Decorator that logs function calls."""
    def wrapper(*args, **kwargs):
        print(f"Calling {func.__name__}")
        result = func(*args, **kwargs)
        print(f"{func.__name__} returned: {result}")
        return result
    return wrapper
 
@log_call
def calculate_total(prices):
    return sum(prices)
 
@log_call
def find_average(numbers):
    return sum(numbers) / len(numbers)
 
# Use the decorated functions
total = calculate_total([10, 20, 30])
# Output:
# Calling calculate_total
# calculate_total returned: 60
 
print(f"Total: {total}")
# Output: Total: 60
 
average = find_average([10, 20, 30])
# Output:
# Calling find_average
# find_average returned: 20.0
 
print(f"Average: {average}")
# Output: Average: 20.0

The @log_call syntax is exactly equivalent to writing:

python
def calculate_total(prices):
    return sum(prices)
 
calculate_total = log_call(calculate_total)

But the @ syntax is much cleaner and makes it immediately obvious that the function is decorated.

Stacking Multiple Decorators

You can apply multiple decorators to the same function by stacking them:

python
import time
 
def log_call(func):
    """Decorator that logs function calls."""
    def wrapper(*args, **kwargs):
        print(f"Calling {func.__name__}")
        result = func(*args, **kwargs)
        print(f"{func.__name__} returned: {result}")
        return result
    return wrapper
 
def timer(func):
    """Decorator that times function execution."""
    def wrapper(*args, **kwargs):
        start_time = time.time()
        result = func(*args, **kwargs)
        elapsed = time.time() - start_time
        print(f"{func.__name__} took {elapsed:.4f} seconds")
        return result
    return wrapper
 
@timer
@log_call
def process_data(items):
    total = sum(items)
    return total * 2
 
result = process_data([1, 2, 3, 4, 5])
# Output:
# Calling process_data
# process_data returned: 30
# process_data took 0.0001 seconds
 
print(f"Final result: {result}")
# Output: Final result: 30

When decorators are stacked, they're applied from bottom to top (closest to the function first):

python
@timer          # Applied second (outermost layer)
@log_call       # Applied first (closest to function)
def process_data(items):
    pass

This is equivalent to:

python
process_data = timer(log_call(process_data))

Application order (bottom to top):

  1. @log_call wraps the original function first
  2. @timer wraps the result (wraps the already-wrapped function)

Execution order (top to bottom, outermost to innermost):

  1. timer wrapper starts (outermost, executes first)
  2. log_call wrapper starts (inner wrapper)
  3. Original function executes
  4. log_call wrapper finishes
  5. timer wrapper finishes (outermost, finishes last)

Think of decorators like layers of wrapping paper—you apply them from the inside out, but when you unwrap (execute), you go from the outside in.

Decorator Application:

Original Function
process_data

Step 1: @log_call(bottom decorator)

log_call wraps original

Step 2: @timer(top decorator)

timer wraps log_call wrapper

Final: timer wraps log_call wraps original

Execution Flow:

Call process_data

1. timer wrapper starts
2. log_call wrapper starts
3. Original function executes
4. log_call wrapper finishes
5. timer wrapper finishes

Returns result

38.4) Practical Decorator Examples (Logging, Timing, Validation)

Now let's explore several practical decorators you might use in real programs. These examples demonstrate common patterns and show how decorators solve real-world problems.

Example 1: Enhanced Logging Decorator

A more sophisticated logging decorator that includes timestamps and handles exceptions:

python
import time
 
def log_with_timestamp(func):
    """Decorator that logs function calls with timestamps."""
    def wrapper(*args, **kwargs):
        timestamp = time.strftime("%Y-%m-%d %H:%M:%S")
        print(f"[{timestamp}] Calling {func.__name__}")
        
        try:
            result = func(*args, **kwargs)
            print(f"[{timestamp}] {func.__name__} completed successfully")
            return result
        except Exception as e:
            print(f"[{timestamp}] {func.__name__} raised {type(e).__name__}: {e}")
            raise
    
    return wrapper
 
@log_with_timestamp
def divide(a, b):
    return a / b
 
@log_with_timestamp
def process_user(user_id):
    # Simulate processing
    if user_id < 0:
        raise ValueError("User ID must be positive")
    return f"Processed user {user_id}"
 
# Test successful execution
result = divide(10, 2)
# Output:
# [2025-12-31 10:30:45] Calling divide
# [2025-12-31 10:30:45] divide completed successfully
 
print(f"Result: {result}")
# Output: Result: 5.0
 
# Test successful execution with validation
user = process_user(42)
# Output:
# [2025-12-31 10:30:45] Calling process_user
# [2025-12-31 10:30:45] process_user completed successfully
 
print(user)
# Output: Processed user 42
 
# Test exception handling
try:
    divide(10, 0)
    # Output:
    # [2025-12-31 10:30:45] Calling divide
    # [2025-12-31 10:30:45] divide raised ZeroDivisionError: division by zero
except ZeroDivisionError:
    print("Handled division by zero")
    # Output: Handled division by zero
 
try:
    process_user(-5)
    # Output:
    # [2025-12-31 10:30:45] Calling process_user
    # [2025-12-31 10:30:45] process_user raised ValueError: User ID must be positive
except ValueError:
    print("Handled invalid user ID")
    # Output: Handled invalid user ID

This decorator:

  • Adds timestamps to all log messages
  • Logs both successful completions and exceptions
  • Re-raises exceptions after logging them (using raise without an argument)
  • Uses a try/except block to catch and log any exception

Example 2: Performance Timing Decorator

A decorator that measures and reports function execution time:

python
import time
 
def measure_time(func):
    """Decorator that measures and reports execution time."""
    def wrapper(*args, **kwargs):
        start = time.time()
        result = func(*args, **kwargs)
        elapsed = time.time() - start
        
        # Format time appropriately
        if elapsed < 0.001:
            time_str = f"{elapsed * 1000000:.2f} microseconds"
        elif elapsed < 1:
            time_str = f"{elapsed * 1000:.2f} milliseconds"
        else:
            time_str = f"{elapsed:.2f} seconds"
        
        print(f"{func.__name__} executed in {time_str}")
        return result
    
    return wrapper
 
@measure_time
def find_primes(limit):
    """Find all prime numbers up to limit."""
    primes = []
    for num in range(2, limit):
        is_prime = True
        for divisor in range(2, int(num ** 0.5) + 1):
            if num % divisor == 0:
                is_prime = False
                break
        if is_prime:
            primes.append(num)
    return primes
 
@measure_time
def calculate_factorial(n):
    """Calculate factorial of n."""
    result = 1
    for i in range(1, n + 1):
        result *= i
    return result
 
# Test the decorated functions
primes = find_primes(1000)
# Output: find_primes executed in 15.23 milliseconds
 
print(f"Found {len(primes)} primes")
# Output: Found 168 primes
 
factorial = calculate_factorial(100)
# Output: calculate_factorial executed in 45.67 microseconds
 
print(f"Factorial has {len(str(factorial))} digits")
# Output: Factorial has 158 digits

This decorator automatically formats the time measurement appropriately (microseconds, milliseconds, or seconds) based on the duration.

Example 3: Input Validation Decorator

A decorator that validates function arguments before execution:

python
def validate_positive(func):
    """Decorator that ensures all numeric arguments are positive."""
    def wrapper(*args, **kwargs):
        # Check positional arguments
        for i, arg in enumerate(args):
            if isinstance(arg, (int, float)) and arg <= 0:
                raise ValueError(
                    f"Argument {i} to {func.__name__} must be positive, got {arg}"
                )
        
        # Check keyword arguments
        for key, value in kwargs.items():
            if isinstance(value, (int, float)) and value <= 0:
                raise ValueError(
                    f"Argument '{key}' to {func.__name__} must be positive, got {value}"
                )
        
        return func(*args, **kwargs)
    
    return wrapper
 
@validate_positive
def calculate_area(width, height):
    """Calculate area of a rectangle."""
    return width * height
 
@validate_positive
def calculate_discount(price, discount_percent):
    """Calculate discounted price."""
    discount = price * (discount_percent / 100)
    return price - discount
 
# Test valid inputs
area = calculate_area(10, 5)
print(f"Area: {area}")
# Output: Area: 50
 
discounted = calculate_discount(100, 20)
print(f"Discounted price: ${discounted:.2f}")
# Output: Discounted price: $80.00
 
# Test invalid inputs
try:
    calculate_area(-5, 10)
except ValueError as e:
    print(f"Validation error: {e}")
    # Output: Validation error: Argument 0 to calculate_area must be positive, got -5
 
try:
    calculate_discount(100, discount_percent=-10)
except ValueError as e:
    print(f"Validation error: {e}")
    # Output: Validation error: Argument 'discount_percent' to calculate_discount must be positive, got -10

This decorator:

  • Checks all numeric arguments (both positional and keyword)
  • Raises a descriptive error if any are not positive
  • Provides clear error messages indicating which argument failed validation

38.5) (Optional) Decorators with Arguments

So far, all our decorators have been simple functions that take a function as input. But what if you want to configure a decorator's behavior? For example, you might want a retry decorator where you can specify the number of attempts, or a logging decorator where you can specify the log level.

Decorators with arguments require an extra level of function nesting. Instead of a decorator being a function that takes a function, it becomes a function that takes arguments and returns a decorator.

The Pattern: Decorator Factories

A decorator with arguments is actually a decorator factory - a function that creates and returns a decorator. The key to understanding this is knowing what Python does with the @ symbol.

The Key Principle: Python Evaluates @ First

Python always evaluates whatever comes after @ first, then uses the result to decorate your function.

Let's compare:

A) Basic Decorator:

Based on this example:

python
def log_call(func):
    """Decorator that logs function calls."""
    def wrapper(*args, **kwargs):
        print(f"Calling {func.__name__}")
        result = func(*args, **kwargs)
        print(f"{func.__name__} returned: {result}")
        return result
    return wrapper
 
@log_call
def greet(name):
    return f"Hello, {name}!"

What Python does:

  1. Evaluate @log_call → Result: log_call itself (the function object)
  2. Apply to greet: greet = log_call(greet)

B) Decorator Factory:

Based on this example:

python
def repeat(times):
    """Level 1: Factory - receives configuration"""
    def decorator(func):
        """Level 2: Decorator - receives the function to decorate"""
        def wrapper(*args, **kwargs):
            """Level 3: Wrapper - executes when decorated function is called"""
            for i in range(times):
                result = func(*args, **kwargs)
            return result
        return wrapper
    return decorator
 
@repeat(3)
def greet(name):
    print(f"Hello, {name}!")
 
greet("Alice")
# Output:
# Hello, Alice!
# Hello, Alice!
# Hello, Alice!

What Python does:

  1. Evaluate @repeat(3) → Result: repeat(3) is called, returns a decorator function
  2. Apply that decorator to greet: greet = decorator(greet)

The difference: @log_call gives you the function itself, but @repeat(3) calls a function (repeat) that returns a decorator.

Understanding the Three Levels

A decorator factory has three nested functions, each with a specific role:

python
def repeat(times):                      # Level 1: Factory
    def decorator(func):                # Level 2: Decorator  
        def wrapper(*args, **kwargs):   # Level 3: Wrapper
            for i in range(times):
                result = func(*args, **kwargs)
            return result
        return wrapper
    return decorator

Level 1 - Factory (repeat):

  • Takes: Configuration (times)
  • Returns: A decorator function
  • Called: When Python evaluates @repeat(3)

Level 2 - Decorator (decorator):

  • Takes: The function to decorate (func)
  • Returns: A wrapper function
  • Called: Immediately after Level 1, as part of the @ syntax

Level 3 - Wrapper (wrapper):

  • Takes: The function's arguments when called (*args, **kwargs)
  • Returns: The result
  • Called: Every time you call the decorated function

Step-by-Step Execution

Let's trace what happens with @repeat(3):

python
# What you write:
@repeat(3)
def greet(name):
    print(f"Hello, {name}!")

Step 1: Python evaluates repeat(3)

python
decorator = repeat(3)  # Factory returns a decorator (times=3 is captured)

Step 2: Python applies the decorator to greet

python
def greet(name):
    print(f"Hello, {name}!")
 
greet = decorator(greet)  # Decorator returns a wrapper (func=greet is captured)

Note: At this point, greet now refers to the wrapper function. The original greet is captured in func.

Step 3: When you call greet("Alice"), the wrapper executes

python
greet("Alice")  # Actually calls wrapper("Alice")
# wrapper uses the captured 'times' and 'func'

Why Three Levels?

Each level captures different information through closures:

python
def repeat(times):                      # Captures: times
    def decorator(func):                # Captures: func (and remembers times)
        def wrapper(*args, **kwargs):   # Captures: times, func, and receives args
            for i in range(times):      # Uses captured 'times'
                result = func(*args, **kwargs)  # Uses captured 'func' and 'args'
            return result
        return wrapper
    return decorator
  • Level 1 captures the configuration (times)
  • Level 2 captures the function to decorate (func)
  • Level 3 receives the arguments when called (args, kwargs)

Without all three levels, we couldn't have a configurable decorator that remembers both its settings and the function it's decorating.

Example 1: A Configurable Logging Decorator

Here's a practical example of a logging decorator that accepts configuration:

python
def log_with_prefix(prefix="LOG"):
    """Decorator factory that creates a logging decorator with a custom prefix."""
    def decorator(func):
        def wrapper(*args, **kwargs):
            print(f"[{prefix}] Calling {func.__name__}")
            result = func(*args, **kwargs)
            print(f"[{prefix}] {func.__name__} returned: {result}")
            return result
        return wrapper
    return decorator
 
@log_with_prefix(prefix="INFO")
def calculate_total(prices):
    return sum(prices)
 
@log_with_prefix()  # Use default prefix
def get_average(numbers):
    return sum(numbers) / len(numbers)
 
# Test the decorated functions
total = calculate_total([10, 20, 30])
# Output:
# [INFO] Calling calculate_total
# [INFO] calculate_total returned: 60
 
print(f"Total: {total}")
# Output: Total: 60
 
average = get_average([10, 20, 30])
# Output:
# [LOG] Calling get_average
# [LOG] get_average returned: 20.0
 
print(f"Average: {average}")
# Output: Average: 20.0

Notice that:

  • @log_with_prefix(prefix="INFO") uses a custom prefix
  • @log_with_prefix() uses the default prefix "LOG"
  • You must include parentheses even when using defaults

Example 2: A Decorator with Multiple Arguments

Here's a decorator that validates numeric ranges:

python
def validate_range(min_value=None, max_value=None):
    """
    Decorator factory that validates numeric arguments are within a range.
    
    Args:
        min_value: Minimum allowed value (inclusive)
        max_value: Maximum allowed value (inclusive)
    """
    def decorator(func):
        def wrapper(*args, **kwargs):
            # Check all numeric arguments
            all_args = list(args) + list(kwargs.values())
            
            for arg in all_args:
                if isinstance(arg, (int, float)):
                    if min_value is not None and arg < min_value:
                        raise ValueError(
                            f"{func.__name__} received {arg}, "
                            f"which is below minimum {min_value}"
                        )
                    if max_value is not None and arg > max_value:
                        raise ValueError(
                            f"{func.__name__} received {arg}, "
                            f"which is above maximum {max_value}"
                        )
            
            return func(*args, **kwargs)
        return wrapper
    return decorator
 
@validate_range(min_value=0, max_value=100)
def calculate_percentage(value, total):
    """Calculate percentage."""
    return (value / total) * 100
 
@validate_range(min_value=0)
def calculate_age(birth_year, current_year):
    """Calculate age from birth year."""
    return current_year - birth_year
 
# Test valid inputs
percentage = calculate_percentage(25, 100)
print(f"Percentage: {percentage}%")
# Output: Percentage: 25.0%
 
age = calculate_age(1990, 2025)
print(f"Age: {age}")
# Output: Age: 35
 
# Test invalid inputs
try:
    calculate_percentage(150, 100)
except ValueError as e:
    print(f"Validation error: {e}")
    # Output: Validation error: calculate_percentage received 150, which is above maximum 100
 
try:
    calculate_age(-5, 2025)
except ValueError as e:
    print(f"Validation error: {e}")
    # Output: Validation error: calculate_age received -5, which is below minimum 0

When to Use Decorators with Arguments

Use decorators with arguments when:

  • You need to configure the decorator's behavior
  • The same decorator should work differently in different contexts
  • You want to make decorators more reusable and flexible

Common examples include:

  • Retry decorators with configurable attempts and delays
  • Logging decorators with configurable log levels or formats
  • Validation decorators with configurable rules
  • Caching decorators with configurable cache sizes or expiration times
  • Rate limiting decorators with configurable limits

A Note on Complexity

Decorators with arguments add an extra level of complexity. When writing them:

  • Use clear, descriptive parameter names
  • Provide sensible default values
  • Include docstrings explaining the parameters
  • Consider whether the added flexibility is worth the complexity

For simple cases, a decorator without arguments is often clearer and easier to understand.


Decorators are a powerful tool for writing clean, maintainable Python code. They let you separate cross-cutting concerns (like logging, timing, and validation) from your core business logic, making your code easier to read, test, and modify. As you continue programming in Python, you'll find decorators used extensively in frameworks and libraries, and you'll discover many opportunities to write your own decorators to solve common problems elegantly.

© 2025. Primesoft Co., Ltd.
support@primesoft.ai