async def and await — What a Coroutine Actually Is
An async def function, when called, doesn't run its code immediately; it simply creates a "recipe" or "plan" for execution, known as a coroutine object. This recipe must then be given to a chef (the event loop) to be cooked (executed) using await or by scheduling it.
The Setup
A developer transitions a legacy database client to an async alternative, writing code to call an async method, but notices that the database queries are never actually executed and no errors are raised.
What Does This Print?
import asyncio
async def fetch_db_status():
print("Database query started...")
await asyncio.sleep(0.5)
print("Database query complete!")
return "CONNECTED"
def check_connection():
# Attempting to call the async function directly
status = fetch_db_status()
print(f"Status variable value: {status}")
check_connection()
The Output
The code did not print "Database query started..." or "Database query complete!". Calling an async def function does not execute its body. Instead, it returns a coroutine object. This object remains suspended until it is scheduled on an active event loop using await or run via asyncio.run().
Why Python Does This
Behind the scenes, Python implements coroutines as a specialized extension of generator objects. When Python compiles a function defined with async def, it marks the code object with a CO_COROUTINE flag. Invoking this function does not execute its bytecode directly; it returns a suspended coroutine wrapper around a generator framework. The await keyword acts as a yield point, delegating execution back to the caller (usually the event loop). Without an active event loop driving the coroutine's generator-like send() methods, the code inside the coroutine is never executed. Python's garbage collector flags this omission with a RuntimeWarning when the unawaited coroutine object is deallocated.
The Fix
import asyncio
async def fetch_db_status():
print("Database query started...")
# Yield control to allow event loop to run other tasks
await asyncio.sleep(0.5)
print("Database query complete!")
return "CONNECTED"
async def check_connection():
# Correctly await the coroutine inside another coroutine
status = await fetch_db_status()
print(f"Status variable value: {status}")
# Run the entrypoint coroutine inside an event loop
asyncio.run(check_connection())
Awaiting a coroutine (using await) tells the event loop to execute that specific "recipe" and pause the current coroutine until the awaited one completes. Alternatively, scheduling it with asyncio.create_task() adds the recipe to the event loop's queue for execution without blocking the current coroutine.
How This Fails in Real Systems
A security monitoring daemon failed to send critical alerts during a cluster failure. The developer had refactored the notifier to utilize an async email client, but omitted the await keyword before the dispatch call. Because the code was executed inside a complex event-driven framework, the warning was swallowed, and the system failed silently for three days before a manual inspection of the logs revealed the unawaited coroutine warnings.
Key Takeaway
asyncio often treat async def functions like regular functions, calling them directly and expecting their body to execute immediately.