Python Decorators
A decorator is a function that takes another function as input, adds extra functionality to it, and returns a new function without modifying the original function’s code.
Basic Structure
def my_decorator(func):
def wrapper():
# Code before the function
print("Before function")
func() # Call the original function
# Code after the function
print("After function")
return wrapper
@my_decorator
def say_hello():
print("Hello")
# Calling the function
say_hello()
Output:
Before function
Hello
After function
In the above code, we define a function my_decorator. This function is a decorator, which means it takes another function as its argument and adds extra behavior to it without modifying the original function’s code.
Inside my_decorator, we define another function wrapper(). This inner function wraps around the original function (func). It contains code that runs before and after the original function is called.
The wapper() function first prints "Before function", then calls the original function using func(), and finally prints "After function".
When we use the @my_decorator syntax above the say_hello() function, Python automatically passes the say_hello function as an argument to my_decorator. The decorator then returns the wrapper() function, which replaces the original say_hello function.
Therefore, when say_hello() is called, it actually executes the code inside wrapper(), which runs additional code before and after printing "Hello".
Note: First, you define a decorator and apply it using the @ symbol directly above the function definition.
Handling Function Parameters in Decorators
To allow a function to accept any number of arguments, you can use *args and **kwargs in the decorator’s wrapper function.
For example:
def my_decorator(func):
def wrapper(*args, **kwargs):
print("Before function")
result = func(*args, **kwargs)
print("After function")
return result
return wrapper
@my_decorator
def greet(name, age):
print(f"My name is {name} and I am {age} years old.")
greet("James", 30)
Output:
Before function
My name is James and I am 30 years old.
After function
Decorator With Arguments
Sometimes you may want a decorator that itself accepts arguments, allowing you to customize its behavior each time it is applied.
Unlike simple decorators, a decorator with arguments requires three nested functions. The outer function accepts the decorator’s arguments, the middle function receives the function being decorated, and the inner function handles the actual function call.
Here is an example that logs each time a function runs, using a custom tag of your choosing:
def log_calls(label):
"""Outer function that takes decorator arguments"""
def decorator(func):
"""The actual decorator that takes the function"""
def wrapper(*args, **kwargs):
"""The wrapper that executes the function"""
print(f"[{label}] Calling {func.__name__}")
result = func(*args, **kwargs)
print(f"[{label}] Finished {func.__name__}")
return result
return wrapper
return decorator
# Using the decorator
@log_calls("DATABASE")
def save_data():
print("Saving data...")
@log_calls("NETWORK")
def fetch_data():
print("Fetching data...")
save_data()
fetch_data()
Output:
[DATABASE] Calling save_data
Saving data...
[DATABASE] Finished save_data
[NETWORK] Calling fetch_data
Fetching data...
[NETWORK] Finished fetch_data
Preserving Function Metadata
When you create a decorator in Python, it wraps the original function, which causes the original function to loose its metadata like name (__name__), docstring (__doc__), and annotations, replaced by the metadata of the wrapper function.
Here is what I mean by it:
def my_decorator(func):
def wrapper(*args, **kwargs):
"""This is the wrapper function"""
result = func(*args, **kwargs)
return result
return wrapper
@my_decorator
def add(a, b):
"""Add two numbers"""
return a + b
print(add(2, 3)) # 5
print(add.__name__) # wrapper
print(add.__doc__) # This is the wrapper function
Note: The __name__ attribute stores the name of the function, while the __doc__ attribute stores the function’s docstring.
As you can see in the above code example, the metadata of the add() function, such as its name and docstring, are replaced by the metadata of the wrapper() function.
To preserve metadata of the original function, you should use functools.wraps.
The functools module provides the @wraps decorator, which you apply to the wrapper function inside your decorator. It automatically copies the original function’s metadata to the wrapper.
Here is how to implement it:
from functools import wraps
def my_decorator(func):
@wraps(func) # This preserves metadata
def wrapper(*args, **kwargs):
"""This is the wrapper function"""
result = func(*args, **kwargs)
return result
return wrapper
@my_decorator
def add(a, b):
"""Add two numbers"""
return a + b
print(add(2, 3)) # 5
print(add.__name__) # add
print(add.__doc__) # Add two numbers
Note: Always use functools.wraps when writing decorators.
You can also import the entire functools module and use wraps as functools.wraps(func).
import functools
def my_decorator(func):
@functools.wraps(func) # This preserves metadata
def wrapper(*args, **kwargs):
"""This is the wrapper function"""
result = func(*args, **kwargs)
return result
return wrapper
@my_decorator
def add(a, b):
"""Add two numbers"""
return a + b
print(add(2, 3)) # 5
print(add.__name__) # add
print(add.__doc__) # Add two numbers
Chaining Decorators
You can apply more than one decorator to the same function. They are applied from the bottom up (the one closest to the function runs first).
Basic Syntax
@decorator_a
@decorator_b
def my_function():
print("Hello")
This is equivalent to:
my_function = decorator_a(decorator_b(my_function))
So the call order is:
decorator_bwrapsmy_functiondecorator_awraps the result
Basic Example:
import functools
def bold(func):
@functools.wraps(func) # This preserves metadata of the original function
def wrapper():
return f"<b>{func()}</b>"
return wrapper
def italic(func):
@functools.wraps(func) # This preserves metadata of the original function
def wrapper():
return f"<i>{func()}</i>"
return wrapper
@bold
@italic
def greet():
return "Hello"
print(greet()) # Output: <b><i>Hello</i></b>
Common Use Cases of Decorators
We will discuss only the most common and practical use cases of Python decorators:
(1) Measuring Execution Time
You can use a decorator to measure a function’s execution time and analyze its performance.
For example:
import time
from functools import wraps
def measure_time(func):
@wraps(func) # This preserves the metadata
def wrapper(*args, **kwargs):
start_time = time.perf_counter()
result = func(*args, **kwargs)
end_time = time.perf_counter()
execution_time = end_time - start_time
print(f"{func.__name__} took {execution_time:.4f} seconds")
return result
return wrapper
@measure_time
def slow_function():
time.sleep(2)
slow_function() # slow_function took 2.0001 seconds
(2) Authentication and Authorization
You can use a decorator to restrict access to certain functions in web frameworks like Django and Flask.
Here is a decorator that checks whether a user is logged in before allowing access to a function and raises an error if the user is not logged in:
import functools
def login_required(func):
@functools.wraps(func) # This preserves the metadata
def wrapper(user, *args, **kwargs):
# If the user is not authenticated, this line raises a PermissionError
if not user.get("is_authenticated"):
raise PermissionError("Authentication Required")
# This line only runs if the user is authenticated
return func(user, *args, **kwargs)
return wrapper
@login_required
def view_profile(user):
return f"Hello, {user['name']}! Welcome to LivingWithCode.com"
# Authenticated User
user1 = {"name": "James", "is_authenticated": True}
# Non-Authenticated User
user2 = {"name": "Logan Paul", "is_authenticated": False}
print(view_profile(user1))
print(view_profile(user2))
Output:
Hello, James! Welcome to LivingWithCode.com
Traceback (most recent call last):
File "<main.py>", line 22, in <module>
File "<main.py>", line 7, in wrapper
PermissionError: Authentication Required
Here is an another decorator that checks if a user has the required role before allowing access to a function and raises an error if the user doesn’t have the necessary role:
import functools
def role_required(required_role):
def my_decorator(func):
@functools.wraps(func) # This preseves the metadata of the original function
def wrapper(user, *args, **kwargs):
# Check if the user has the required role
if user.get("role") != required_role:
raise PermissionError(f"Authorization failed: {required_role} role required")
# If role matches, call the original function
return func(user, *args, **kwargs)
return wrapper
return my_decorator
@role_required("admin")
def delete_user(user, user_to_delete):
return f"{user_to_delete} has been deleted!"
admin = {"role": "admin"}
guest = {"role": "guest"}
print(delete_user(admin, "test_user")) # Works fine
print(delete_user(guest, "test_user")) # Raises PermissionError
Output:
test_user has been deleted!
Traceback (most recent call last):
File "<main.py>", line 24, in <module>
File "<main.py>", line 9, in wrapper
PermissionError: Authorization failed: admin role required
(3) Logging
Python decorators are often used to automatically log function calls, arguments, return values, or execution details. This is useful for debugging, monitoring, and auditing applications.
For example:
import functools
import logging
# Set up logging configuration
logging.basicConfig(level=logging.INFO, format="%(asctime)s - %(message)s")
# Decorator for logging
def log_function_call(func):
@functools.wraps(func) # This preseves the metadata of the original function
def wrapper(*args, **kwargs):
# Log function calls with arguments
logging.info(f"Calling function '{func.__name__}' with arguments: {args}, {kwargs}")
# Call the function and capture the result
result = func(*args, **kwargs)
# Log the return value
logging.info(f"Function '{func.__name__}' returned {result}")
return result
return wrapper
@log_function_call
def add(a, b):
return a + b
@log_function_call
def greet():
return "Hello"
# Test the decorated functions
add(2, 3)
greet()
Output:
2026-01-04 13:16:52,799 - Calling function 'add' with arguments: (2, 3), {}
2026-01-04 13:16:52,799 - Function 'add' returned 5
2026-01-04 13:16:52,799 - Calling function 'greet' with arguments: (), {}
2026-01-04 13:16:52,799 - Function 'greet' returned Hello
(4) Caching / Memoization
Caching improves performance by storing the results of expensive function calls and reusing them when the same inputs occur again, instead of recomputing the result.
Python provides a ready-made caching decorator: lru_cache.
For example:
from functools import lru_cache
@lru_cache(maxsize=None)
def fibonacci(n):
if n <= 1:
return n
return fibonacci(n - 1) + (n - 2)
print(fibonacci(20)) # Output: 172
Limiting Cache Size
You can limit the number of cached results by setting the mazsize parameter.
@lru_cache(maxsize=128)
def fibonacci(n):
if n <= 1:
return n
return fibonacci(n - 1) + (n - 2)
Only the 128 most recent results are cached, and older values are removed automatically.
Clearing The Cache
You can also clear the outdated cache data when it is no longer needed.
fibonacci.cache_clear()