coroutine
decorator
Python
asynchronous programming
coding techniques

Using a coroutine as decorator

Master System Design with Codemia

Enhance your system design skills with over 120 practice problems, detailed solutions, and hands-on exercises.

Introduction

In Python async code, people often say they want to “use a coroutine as a decorator,” but the practical pattern is more specific than that. You usually write a normal decorator function that returns an async wrapper, because decoration happens at import time while coroutine execution happens later inside the event loop.

Core Sections

The key distinction: decorator definition versus coroutine execution

A decorator runs when the function is defined. An async def function does not execute immediately; calling it creates a coroutine object that must later be awaited. That means a bare coroutine object cannot directly act as a normal decorator in the usual way.

The common solution is:

  • define a regular decorator function
  • inside it, return an async wrapper
  • 'await the wrapped coroutine inside that wrapper'
python
1import asyncio
2from functools import wraps
3
4def log_calls(func):
5    @wraps(func)
6    async def wrapper(*args, **kwargs):
7        print(f"starting {func.__name__}")
8        result = await func(*args, **kwargs)
9        print(f"finished {func.__name__}")
10        return result
11    return wrapper
12
13@log_calls
14async def fetch_value():
15    await asyncio.sleep(0.1)
16    return 42
17
18async def main():
19    value = await fetch_value()
20    print(value)
21
22asyncio.run(main())

This is the idiomatic approach for decorating async functions.

Why a normal synchronous decorator is still correct

It can feel counterintuitive that the decorator itself is not async. The reason is timing. At decoration time, there is no running event loop requirement and nothing to await yet. The decorator only needs to build and return another callable.

That callable can absolutely be async, which is what preserves async behavior for the original function.

If you wrote this instead:

python
1async def broken_decorator(func):
2    async def wrapper(*args, **kwargs):
3        return await func(*args, **kwargs)
4    return wrapper

then @broken_decorator would not behave like a normal decorator. Python would call broken_decorator(func) during function definition and get a coroutine object back, not the wrapped function you intended.

Add timing, retries, or tracing around async work

Async decorators are useful when the extra behavior belongs around many coroutine calls. Timing is a common example.

python
1import asyncio
2import time
3from functools import wraps
4
5def time_async_call(func):
6    @wraps(func)
7    async def wrapper(*args, **kwargs):
8        start = time.perf_counter()
9        try:
10            return await func(*args, **kwargs)
11        finally:
12            elapsed = time.perf_counter() - start
13            print(f"{func.__name__} took {elapsed:.3f}s")
14    return wrapper
15
16@time_async_call
17async def slow_step():
18    await asyncio.sleep(0.2)
19
20asyncio.run(slow_step())

Because the wrapper awaits the original coroutine, the timing includes the real async execution rather than just the function call that created the coroutine object.

A retry decorator follows the same pattern.

python
1import asyncio
2from functools import wraps
3
4def retry_async(attempts, delay_seconds):
5    def decorator(func):
6        @wraps(func)
7        async def wrapper(*args, **kwargs):
8            last_error = None
9            for _ in range(attempts):
10                try:
11                    return await func(*args, **kwargs)
12                except ValueError as error:
13                    last_error = error
14                    await asyncio.sleep(delay_seconds)
15            raise last_error
16        return wrapper
17    return decorator

This is a decorator factory, which is the right pattern when you need configuration.

Preserve metadata and cancellation behavior

Use functools.wraps unless you have a strong reason not to. Without it, the decorated function loses its original name and docstring, which hurts debugging and introspection.

Cancellation is also important in async code. If the wrapped coroutine is cancelled, do not swallow that exception accidentally. In many async systems, cancellation is part of normal control flow and should propagate.

python
1import asyncio
2from functools import wraps
3
4def safe_logger(func):
5    @wraps(func)
6    async def wrapper(*args, **kwargs):
7        try:
8            return await func(*args, **kwargs)
9        except asyncio.CancelledError:
10            print(f"{func.__name__} was cancelled")
11            raise
12    return wrapper

That preserves cooperative cancellation instead of converting it into a misleading success path.

Keep sync and async decorators separate when needed

A decorator written for coroutines should generally decorate coroutines only. If you apply it to a synchronous function, callers must now await the wrapper, which changes the API. If you need both modes, either create separate decorators or inspect the target with inspect.iscoroutinefunction and branch deliberately.

Clarity matters more than cleverness here. A small explicit decorator is easier to maintain than a generic wrapper that surprises callers.

Common Pitfalls

  • Defining the decorator itself as async def, which returns a coroutine object at decoration time instead of a callable wrapper.
  • Decorating a synchronous function with an async wrapper and accidentally changing how callers must invoke it.
  • Forgetting @wraps, which makes logs, stack traces, and documentation point to wrapper instead of the original function.
  • Catching broad exceptions in the wrapper and swallowing asyncio.CancelledError, which breaks normal cancellation flow.
  • Performing blocking work inside the async wrapper, which defeats the purpose of using coroutines in the first place.

Summary

  • The practical pattern is a normal decorator that returns an async wrapper.
  • Decoration happens at definition time, while coroutine execution happens later in the event loop.
  • Async decorators are useful for logging, timing, retries, and tracing around coroutine calls.
  • Use functools.wraps and let cancellation propagate unless you have a strong reason not to.
  • Keep sync and async decoration behavior explicit so callers are not surprised.

Course illustration
Course illustration

All Rights Reserved.