Skip to main content

Python Decorators

Introduction

Have you ever wanted to add functionality to a function without modifying its code? Python decorators provide an elegant solution to this problem. Decorators are a powerful feature in Python that allow you to modify or enhance functions and methods without changing their source code. They wrap a function, extending its behavior, while keeping the original function's signature intact.

In this tutorial, we'll explore Python decorators from the ground up, starting with the basic concepts and progressing to practical applications that you can use in your own projects.

Prerequisites

Before diving into decorators, you should have a good understanding of:

  • Python functions
  • Function arguments
  • The concept of functions as first-class objects

Understanding Functions as First-Class Objects

To understand decorators, we first need to recognize that in Python, functions are first-class objects. This means that functions can:

  1. Be assigned to variables
  2. Be passed as arguments to other functions
  3. Be returned from other functions

Let's see this in action:

python
def greet(name):
return f"Hello, {name}!"

# Assigning a function to a variable
greeting_function = greet

# Using the function through the variable
result = greeting_function("Alice")
print(result) # Output: Hello, Alice!

Nested Functions

Python allows you to define functions inside other functions. These are called nested functions:

python
def outer_function(message):
def inner_function():
print(message)

# Call the inner function
inner_function()

outer_function("Hello from the inner function!")
# Output: Hello from the inner function!

The inner function can access variables from the outer function's scope. This concept is called a closure.

Functions Returning Functions

A function in Python can also return another function:

python
def multiply_by(factor):
def multiplier(number):
return number * factor
return multiplier

# Create specific multiplier functions
double = multiply_by(2)
triple = multiply_by(3)

print(double(5)) # Output: 10
print(triple(5)) # Output: 15

In this example, multiply_by returns the multiplier function, which remembers the factor value from its parent function's scope.

Basic Decorator Syntax

Now that we understand functions as first-class objects, we can introduce decorators. A decorator is a function that takes another function as an argument, adds some functionality, and returns the modified function.

Here's the basic pattern:

python
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

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

# Apply the decorator manually
decorated_function = my_decorator(say_hello)
decorated_function()

Output:

Something is happening before the function is called.
Hello!
Something is happening after the function is called.

The @ Syntax

Python provides a more elegant way to apply decorators using the @ symbol:

python
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!")

# Now when we call say_hello(), it's actually calling the decorated version
say_hello()

Output:

Something is happening before the function is called.
Hello!
Something is happening after the function is called.

Using the @ syntax is equivalent to:

python
say_hello = my_decorator(say_hello)

Decorating Functions with Arguments

Our previous decorator only works with functions that don't take arguments. Let's modify it to work with any function:

python
def my_decorator(func):
def wrapper(*args, **kwargs):
print("Something is happening before the function is called.")
result = func(*args, **kwargs)
print("Something is happening after the function is called.")
return result
return wrapper

@my_decorator
def say_name(name):
print(f"My name is {name}")

say_name("John")

Output:

Something is happening before the function is called.
My name is John
Something is happening after the function is called.

By using *args and **kwargs in the wrapper function, we can pass any number of positional and keyword arguments to the original function.

Preserving Function Metadata

When you decorate a function, the metadata of the original function (like its name and docstring) gets lost:

python
@my_decorator
def add(a, b):
"""Add two numbers and return the result."""
return a + b

print(add.__name__) # Output: wrapper
print(add.__doc__) # Output: None

To fix this issue, we can use the @functools.wraps decorator from the standard library:

python
import functools

def my_decorator(func):
@functools.wraps(func) # This preserves metadata
def wrapper(*args, **kwargs):
print("Something is happening before the function is called.")
result = func(*args, **kwargs)
print("Something is happening after the function is called.")
return result
return wrapper

@my_decorator
def add(a, b):
"""Add two numbers and return the result."""
return a + b

print(add.__name__) # Output: add
print(add.__doc__) # Output: Add two numbers and return the result.

Practical Examples of Decorators

Now that we understand the basics, let's explore some practical applications of decorators.

1. Timing Functions

You can use decorators to measure the execution time of functions:

python
import functools
import time

def timer(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
start_time = time.time()
result = func(*args, **kwargs)
end_time = time.time()
print(f"{func.__name__} took {end_time - start_time:.4f} seconds to run")
return result
return wrapper

@timer
def slow_function():
"""A function that takes some time to execute."""
time.sleep(1)
return "Function completed"

slow_function()

Output:

slow_function took 1.0014 seconds to run

2. Logging Function Calls

Decorators can help with logging function calls and arguments:

python
import functools
import logging

logging.basicConfig(level=logging.INFO)

def log_function_call(func):
@functools.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 calculate_sum(a, b, multiply_by=1):
return (a + b) * multiply_by

calculate_sum(3, 5, multiply_by=2)

Output:

INFO:root:Calling calculate_sum with args: (3, 5), kwargs: {'multiply_by': 2}
INFO:root:calculate_sum returned: 16

3. Authorization and Authentication

Decorators are commonly used to check if a user is authorized to perform an action:

python
import functools

def require_auth(func):
@functools.wraps(func)
def wrapper(user, *args, **kwargs):
if not user.get('is_authenticated', False):
raise PermissionError("You must be logged in to perform this action")
return func(user, *args, **kwargs)
return wrapper

@require_auth
def view_protected_resource(user):
return "Protected content"

# Unauthorized user
try:
view_protected_resource({'name': 'Guest', 'is_authenticated': False})
except PermissionError as e:
print(e)

# Authorized user
result = view_protected_resource({'name': 'Admin', 'is_authenticated': True})
print(result)

Output:

You must be logged in to perform this action
Protected content

4. Caching Results (Memoization)

Decorators can cache the results of functions to avoid redundant calculations:

python
import functools

def memoize(func):
cache = {}

@functools.wraps(func)
def wrapper(*args):
if args not in cache:
cache[args] = func(*args)
print(f"Calculated result for {args}")
else:
print(f"Using cached result for {args}")
return cache[args]
return wrapper

@memoize
def fibonacci(n):
"""Calculate the Fibonacci number for n."""
if n <= 1:
return n
return fibonacci(n - 1) + fibonacci(n - 2)

# First call calculates the value
result = fibonacci(10)
print(f"Result: {result}")

# Second call uses the cached value
result = fibonacci(10)
print(f"Result: {result}")

Output shows how recursive calls are cached:

Calculated result for (0,)
Calculated result for (1,)
Calculated result for (2,)
...
Calculated result for (10,)
Result: 55
Using cached result for (10,)
Result: 55

Decorators with Arguments

We can also create decorators that accept arguments:

python
import functools

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

@repeat(n=3)
def say_hello(name):
print(f"Hello, {name}!")
return "Done"

say_hello("Alice")

Output:

Hello, Alice!
Hello, Alice!
Hello, Alice!

In this example, repeat is a function that returns a decorator. The decorator is configured with the value of n.

Class Decorators

Decorators aren't limited to functions. You can also create decorators using classes:

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

def __call__(self, *args, **kwargs):
self.count += 1
print(f"Call {self.count} to {self.func.__name__}")
return self.func(*args, **kwargs)

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

say_hi()
say_hi()

Output:

Call 1 to say_hi
Hi!
Call 2 to say_hi
Hi!

Chaining Multiple Decorators

You can apply multiple decorators to a single function. They are applied from innermost to outermost:

python
import functools

def bold(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
return "<b>" + func(*args, **kwargs) + "</b>"
return wrapper

def italic(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
return "<i>" + func(*args, **kwargs) + "</i>"
return wrapper

@bold
@italic
def format_text(text):
return text

print(format_text("Hello, World!"))

Output:

<b><i>Hello, World!</i></b>

The decorators are applied from bottom to top, so the text is first wrapped in <i> tags and then in <b> tags.

Summary

Python decorators are a powerful tool that allow you to:

  • Modify or enhance functions without changing their source code
  • Apply cross-cutting concerns like logging, timing, or authentication
  • Create reusable function wrappers
  • Implement design patterns elegantly

We've covered:

  • The concept of functions as first-class objects
  • The basic syntax and behavior of decorators
  • Preserving function metadata with functools.wraps
  • Practical examples of decorators in real-world applications
  • Creating decorators that accept arguments
  • Class-based decorators
  • Chaining multiple decorators

As you build more complex Python applications, decorators will become an invaluable tool in your programming toolkit.

Exercises

  1. Create a decorator that prints a warning when a deprecated function is called.
  2. Create a decorator that retries a function a specified number of times if it raises an exception.
  3. Write a decorator that enforces type checking for the arguments of a function.
  4. Create a decorator that limits how many times a function can be called.
  5. Implement a decorator that caches the results of a function with an expiry time.

Additional Resources



If you spot any mistakes on this website, please let me know at [email protected]. I’d greatly appreciate your feedback! :)