← Python Code Async Python
Browse Python Concepts

async def and await — What a Coroutine Actually Is

Mental Model

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.

Rule: Always await your coroutines or schedule them on an active event loop to guarantee their execution.

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?

Broken code
Python
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()
Predict what the code prints to the console when check_connection is executed.

The Output

What actually happens
Status variable value: <coroutine object fetch_db_status at 0x...> sys:1: RuntimeWarning: coroutine 'fetch_db_status' was never awaited

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

Corrected pattern
Python
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

Always await your coroutines or schedule them on an active event loop to guarantee their execution.
Common mistake: Developers new to asyncio often treat async def functions like regular functions, calling them directly and expecting their body to execute immediately.