
Writing an async def function in Python and expecting it to magically solve your concurrency problems is like buying a fancy sports car and then never learning how to drive stick. Just slapping async on a function declaration doesn’t make your code asynchronous in the way you probably think it does.
When you define a function with async def, what you’re actually doing is creating a coroutine function. Calling that function doesn’t run the code inside immediately; it returns a coroutine object, which is basically a promise of future work. You have to explicitly hand that coroutine off to something that knows how to drive the event loop, or else nothing happens.
Here’s the common trap: if you call an async function from a synchronous context without awaiting it or scheduling it, you’ll just get a coroutine object sitting there, unused:
async def fetch_data():
print("Starting fetch")
await asyncio.sleep(1)
print("Fetch complete")
return {"data": 123}
def main():
result = fetch_data() # This just creates a coroutine, doesn’t execute it
print(result) # <coroutine object fetch_data at 0x...>
main()
Nothing runs. No “Starting fetch” printed. To actually run this coroutine, you have to do one of three things:
1. Call asyncio.run() at the top level, which manages the event loop for you.
2. Use await inside another coroutine.
3. Schedule it with asyncio.create_task() or loop.create_task().
For example, wrapping your call in asyncio.run() works perfectly:
import asyncio
async def fetch_data():
print("Starting fetch")
await asyncio.sleep(1)
print("Fetch complete")
return {"data": 123}
def main():
result = asyncio.run(fetch_data())
print(result)
main()
But if you try to just call fetch_data() synchronously, nothing happens. This is a subtle gotcha for people coming from synchronous code that expect a function call to do the work immediately.
Even worse: mixing synchronous and asynchronous code without understanding the event loop leads to bugs that are really hard to track down. For instance, you might unintentionally create multiple event loops or fail to await a coroutine, silently dropping errors or failing to execute.
The bottom line is simple: async def is a promise, not the execution itself. You have to understand the machinery behind the scenes — the event loop — to actually get those coroutines to run. Without that, you’re just defining a function that never fires off.
Try to think of async def as defining a blueprint for a task. Calling it doesn’t build the house; it just hands you the blueprints. The event loop is the construction crew that actually builds the house, and you need to tell it when and how to start working.
One last thing: it’s tempting to sprinkle async everywhere to “speed things up,” but if your code is mostly CPU-bound or blocking on I/O that doesn’t support async, you’ll get no benefit. You might even degrade performance because context switching and overhead add up. Asynchronous programming shines when you have lots of waiting — network calls, disk I/O, timers — and you want to keep the CPU busy doing other things during those waits.
Here’s an example of how you can properly launch multiple coroutines and gather their results:
import asyncio
async def fetch_data(id):
print(f"Starting fetch {id}")
await asyncio.sleep(1)
print(f"Fetch {id} complete")
return {"data": id}
async def main():
tasks = [asyncio.create_task(fetch_data(i)) for i in range(3)]
results = await asyncio.gather(*tasks)
print(results)
asyncio.run(main())
This will print all the “Starting fetch” lines almost immediately, then after one second, all the “Fetch complete” lines, and finally the results. The magic is that the event loop switches between tasks during the await asyncio.sleep(1) calls, so time is used efficiently.
If you didn’t understand the event loop here, you’d be tempted to just write synchronous loops or blocking calls, losing all the concurrency benefits.
So, no, you can’t just write async def and call it a day. You need to understand how to start and manage the event loop, schedule your coroutines properly, and know when it actually makes sense to go async. Otherwise, you’re just writing fancy-looking functions that don’t do anything until someone else gets involved.
The event loop is your new overworked manager
The event loop is the heart of any asyncio application. It’s a single-threaded coordinator, a scheduler, an overworked manager that juggles all your asynchronous tasks. When you use asyncio.run(main()), you’re starting this manager up and handing it its first job: the main coroutine.
This manager can only do one thing at a time. It runs a task until the task hits an await expression. That await is the magic keyword. It’s the task telling the manager, “I’m about to do something that takes time, like a network request. I’m going to be idle. You can suspend me and go work on something else.” The event loop then puts the current task on pause, finds another task that’s ready to run, and switches to that one. This is called cooperative multitasking: tasks must *cooperate* by explicitly yielding control with await.
So what happens if a task doesn’t cooperate? What if it performs a long-running operation without using await? It blocks. And because the event loop is single-threaded, when one task blocks, *everything* blocks. The entire application freezes. The overworked manager is stuck watching one employee stare at a wall, unable to check on anyone else.
The most common culprit is using synchronous libraries in an asynchronous world. For example, using the standard time.sleep() instead of asyncio.sleep(). The former blocks the entire thread; the latter tells the event loop it can switch away.
Let’s see what happens when a task misbehaves and blocks the loop. We’ll create a “heartbeat” task that should print a message every second, and a “hog” task that blocks for three seconds.
import asyncio
import time
async def heartbeat():
# This task should print a message every second
# to show the event loop is running.
print("Heartbeat task started.")
while True:
await asyncio.sleep(1)
print("...lub-dub... event loop is alive")
async def cpu_hog():
print("Starting a task that blocks the event loop.")
# This is a stand-in for any long-running synchronous code.
# It uses the WRONG sleep, which freezes the entire program.
time.sleep(3)
print("Blocking task is finally done.")
async def main():
print("Starting the heartbeat task in the background.")
heartbeat_task = asyncio.create_task(heartbeat())
# Give the heartbeat a moment to start and print its first message
await asyncio.sleep(1.1)
print("--- Now, running the blocking task... ---")
await cpu_hog()
print("--- Blocking task finished. The heartbeat was silent. ---")
# Let the heartbeat run a couple more times to show it resumes.
await asyncio.sleep(2.1)
heartbeat_task.cancel() # Clean up the infinite task
try:
await heartbeat_task
except asyncio.CancelledError:
print("Heartbeat task cancelled.")
asyncio.run(main())
If you run this, you’ll see the “lub-dub” message appear once. Then the cpu_hog starts, and for three seconds, there is complete silence. The heartbeat task, which should be printing every second, is starved. The event loop is frozen by time.sleep(3). Only after the hog task finishes does the heartbeat resume. This is the cardinal sin of async programming.
This isn’t just about sleeping. Any synchronous, long-running function call will do the same damage. A CPU-bound calculation, a call to a synchronous database driver, or a standard file read on a large file—if it doesn’t have an await, it’s a potential loop-blocker. Your application will become unresponsive and sluggish, completely negating the benefits of asyncio.
The rule is simple: never call blocking code directly from a coroutine. You must use libraries designed for asyncio (like aiohttp for HTTP requests or asyncpg for database access). For your own CPU-bound code that you can’t break up with await, you need to run it in a separate thread or process to keep the event loop free. Python 3.9+ makes this easy with asyncio.to_thread(), which tells the event loop manager: “This task is going to take a while and won’t cooperate, so please delegate it to a different worker (a thread) and just notify me when it’s done.” This keeps the manager free to attend to other, more cooperative tasks.

