
Decorators in Python are a powerful and expressive tool that allows you to modify or extend the behavior of functions or methods. At their core, decorators are just functions that take another function as an argument and return a new function that usually extends the behavior of the original function.
To understand how decorators work, let’s start with a simple example. Imagine you have a function that prints a message, and you want to add some additional functionality to it, such as logging. You can create a decorator for this purpose.
def log_decorator(func):
def wrapper(*args, **kwargs):
print(f"Calling function: {func.__name__}")
result = func(*args, **kwargs)
print(f"{func.__name__} returned: {result}")
return result
return wrapper
@log_decorator
def say_hello(name):
return f"Hello, {name}!"
say_hello("Alice")
In this example, the log_decorator function takes func as an argument, which is the function being decorated. The wrapper function adds logging before and after the original function call. When you decorate say_hello with @log_decorator, every time you call say_hello, you are actually calling the wrapper function inside log_decorator, which adds that extra logging behavior.
Now that we’ve covered the basics of how decorators work, let’s explore how you can use them in a more practical context. Consider a situation where you need to enforce some preconditions before executing a function, such as checking user permissions or validating input.
def require_admin(func):
def wrapper(*args, **kwargs):
if not is_user_admin():
raise PermissionError("User must be an admin to perform this action.")
return func(*args, **kwargs)
return wrapper
@require_admin
def delete_user(user_id):
print(f"User {user_id} has been deleted.")
delete_user(42)
Here, the require_admin decorator checks whether the user has admin privileges before allowing the delete_user function to execute. If the check fails, it raises a PermissionError. That is a clean and reusable way to enforce access control without cluttering the core logic of your functions.
Decorators can also be stacked, meaning you can apply multiple decorators to a single function. This is particularly useful when different aspects of behavior need to be applied independently.
@log_decorator
@require_admin
def update_user(user_id, new_data):
print(f"User {user_id} updated with {new_data}.")
update_user(42, {"name": "Bob"})
Amazon eGift Card | Christmas | Christmas
$50.00 (as of July 15, 2026 15:40 GMT +00:00 - More infoProduct prices and availability are accurate as of the date/time indicated and are subject to change. Any price and availability information displayed on [relevant Amazon Site(s), as applicable] at the time of purchase will apply to the purchase of this product.)Exploring the asyncio library for asynchronous programming
When it comes to asynchronous programming in Python, the asyncio library is the go-to solution. It provides a framework to write concurrent code using the async and await syntax introduced in Python 3.5. Instead of blocking the execution while waiting for I/O operations, asyncio lets your program handle other tasks, improving efficiency and responsiveness.
At the heart of asyncio are coroutines, which are special functions defined with async def. These coroutines can pause their execution at await points, allowing the event loop to run other coroutines in the meantime.
import asyncio
async def say_after(delay, what):
await asyncio.sleep(delay)
print(what)
async def main():
print("Started")
await say_after(1, "Hello")
await say_after(2, "World")
print("Finished")
asyncio.run(main())
In this example, say_after is a coroutine that waits for a given delay before printing a message. The main coroutine awaits two calls to say_after sequentially. The asyncio.run() function is used to start the event loop and run the main coroutine until it completes.
You can run coroutines at the same time using asyncio.create_task() or asyncio.gather(). This lets your program perform multiple asynchronous operations at the same time without blocking.
async def main():
task1 = asyncio.create_task(say_after(1, "Hello"))
task2 = asyncio.create_task(say_after(2, "World"))
print("Tasks started")
await task1
await task2
print("Tasks completed")
asyncio.run(main())
Here, the two say_after coroutines are launched at the same time. The program prints “Tasks started” immediately, then waits for both tasks to finish. That is more efficient than running them sequentially, especially when I/O-bound operations are involved.
Another important concept in asyncio is the event loop itself. It schedules and manages the execution of asynchronous tasks. Normally, you don’t need to interact with the event loop directly, but understanding it helps when you want to build advanced asynchronous patterns or integrate asyncio with other concurrency frameworks.
For example, if you want to run multiple coroutines and collect their results, asyncio.gather() is your friend:
async def fetch_data(n):
await asyncio.sleep(n)
return f"Data after {n} seconds"
async def main():
results = await asyncio.gather(
fetch_data(1),
fetch_data(2),
fetch_data(3)
)
print(results)
asyncio.run(main())
This will print ['Data after 1 seconds', 'Data after 2 seconds', 'Data after 3 seconds'] after roughly 3 seconds total, as all three coroutines run at once.
Understanding these building blocks is important before you dive into writing asynchronous decorators. Because decorators wrap functions, when those functions are async, your decorators must be async-aware. A synchronous decorator wrapping an asynchronous function will not work correctly – the wrapper must also be declared async and properly await the wrapped coroutine.
For instance, a naive synchronous decorator applied to an async function might look like this:
def sync_decorator(func):
def wrapper(*args, **kwargs):
print("Before call")
result = func(*args, **kwargs)
print("After call")
return result
return wrapper
@sync_decorator
async def async_func():
await asyncio.sleep(1)
print("Inside async_func")
asyncio.run(async_func())
This will cause problems because func(*args, **kwargs) returns a coroutine object that isn’t awaited inside the synchronous wrapper. The correct approach is to make the wrapper asynchronous:
def async_decorator(func):
async def wrapper(*args, **kwargs):
print("Before async call")
result = await func(*args, **kwargs)
print("After async call")
return result
return wrapper
@async_decorator
async def async_func():
await asyncio.sleep(1)
print("Inside async_func")
asyncio.run(async_func())
In this version, the wrapper is declared with async def and uses await when calling the wrapped coroutine. This preserves the asynchronous behavior and ensures the decorator behaves correctly with async functions.
Once you grasp these principles of asyncio and asynchronous decorators, you can start building your own decorators that add functionality like timing, caching, or error handling to async functions without blocking the event loop or breaking concurrency.
Next, we’ll break down the process of creating such asynchronous decorators step by step, showing how to combine the power of decorators with the flexibility of asyncio coroutines to write clean, efficient, and reusable async code. But before that, it’s worth noting…
Creating your own asynchronous decorators step by step
To create an asynchronous decorator, you need to follow a few steps that mirror the process of creating a standard decorator, but with the added requirement of handling asynchronous functions. First, define your decorator function, which will accept an asynchronous function as its argument.
def async_timing_decorator(func):
async def wrapper(*args, **kwargs):
start_time = time.monotonic()
result = await func(*args, **kwargs)
end_time = time.monotonic()
print(f"{func.__name__} executed in {end_time - start_time:.4f} seconds")
return result
return wrapper
In the above example, the async_timing_decorator measures the execution time of the asynchronous function it wraps. The wrapper function is defined as an async function, allowing it to use await when calling the wrapped function. This ensures that the execution time is measured accurately.
Next, let’s see how to apply this decorator to an asynchronous function. We can create a simple asynchronous function that simulates a delay to demonstrate the timing decorator’s functionality.
import asyncio
import time
@async_timing_decorator
async def simulate_long_task(seconds):
await asyncio.sleep(seconds)
return f"Completed after {seconds} seconds"
async def main():
result = await simulate_long_task(2)
print(result)
asyncio.run(main())
When you run this code, you will see output indicating how long the simulate_long_task function took to execute, along with the result of the function itself. That is a simpler way to add performance monitoring to your asynchronous functions.
Another common use case for asynchronous decorators is error handling. You might want to catch exceptions thrown by an asynchronous function and handle them gracefully. Here’s an example of how you can create an error-handling decorator:
def async_error_handler(func):
async def wrapper(*args, **kwargs):
try:
return await func(*args, **kwargs)
except Exception as e:
print(f"An error occurred: {e}")
return None
return wrapper
In this async_error_handler, any exception raised within the wrapped asynchronous function will be caught, and an error message will be printed instead of crashing the program. This allows for more robust error management in applications where reliability is critical.
Let’s apply the async_error_handler to an asynchronous function that purposely raises an exception:
@async_error_handler
async def risky_operation():
await asyncio.sleep(1)
raise ValueError("Something went wrong!")
async def main():
await risky_operation()
asyncio.run(main())
When you run this code, it will print the error message instead of stopping execution, demonstrating how the decorator effectively catches exceptions in asynchronous contexts.
As you can see, the power of decorators combined with asynchronous programming can greatly enhance the functionality and maintainability of your code. You can create decorators that log execution times, handle errors, enforce access control, and much more, all while keeping your code clean and focused on its primary responsibilities.
Now that you have a grasp of how to create asynchronous decorators, you can start building more complex functionalities tailored to your specific needs. The combination of asyncio and decorators opens up a world of possibilities for writing efficient and elegant asynchronous code in Python.

