You aren’t bad at async. You’re just missing one rule.
You probably wrote something clean.
async def fetch_data():
print("Fetching...")
return {"data": 42}
fetch_data()
print("Done")PythonYou expected "Fetching..." to appear. Maybe you even expected it to run in the background. Instead? Nothing printed. Or worse it printed "Done" first and your fetch never happened.
This isn’t a bug in Python. And it’s not because you don’t understand concurrency.
It’s because async/await works differently than a normal function call. And most tutorials skip the single most important rule:
asyncdoes nothing on its own. You have to drive it.
Let’s fix that. Below are the three real reasons your async code is broken and how to make it work without rewriting everything.
1. You called an async function, but forgot to await it (the most common trap)
Look at the example above. fetch_data() returns a coroutine object, not your actual data.
>>> fetch_data()
<coroutine object fetch_data at 0x10a4b5c30>PythonPython runs the first line of your function (the print), sees an await inside, and pauses. Then it hands you back a coroutine and says: “Come back when you’re ready to actually run this.”
If you never await that coroutine, it simply disappears when the script ends.
The fix:
result = await fetch_data() # Works inside an async functionPythonBut wait you can’t just write await at the top level of a script (prior to Python 3.8+ you needed a workaround). That leads to our second problem.
2. You’re trying to run async code from a sync context (no event loop)
Async functions don’t run themselves. They need an event loop a thing that juggles tasks and says “okay, now you run, now you pause.”
When you call await fetch_data() inside another async function, you’re fine. The loop is already there.
But when you call it from normal synchronous code like a plain script or a Django view there’s no loop. So nothing runs.
The fix: Use asyncio.run() as your starting point.
import asyncio
async def fetch_data():
print("Fetching...")
return {"data": 42}
# One line. Always do this at your script's entry point.
result = asyncio.run(fetch_data())
print(result)Pythonasyncio.run() creates a loop, runs your coroutine until it finishes, then cleans up. Without it, your async code is like a car with no ignition.
Pro tip for Jupyter / notebooks: They already run a loop. Use await fetch_data() directly. Never call asyncio.run() twice in one session—that’s a separate error (the “event loop is closed” nightmare).
3. You created tasks but didn’t await them (so they got cancelled)
Here’s where people feel betrayed. You thought you were being smart:
async def slow_task():
await asyncio.sleep(2)
print("Slow done")
async def main():
task = asyncio.create_task(slow_task())
# Oh no. No await.
print("Main finished")
asyncio.run(main())PythonOutput:
Main finished
Where’s "Slow done"? Gone. The moment main() finished, the event loop saw that task was still pending and said: “Script’s over, cancel everything.”
Python doesn’t wait for background tasks unless you explicitly tell it to.
Three ways to fix it (pick one):
- Await the task directly (simplest):
await task - Wait for a group of tasks (clean for many):
await asyncio.gather(task1, task2) - Use
asyncio.wait()with a timeout (advanced):done, pending = await asyncio.wait([task], timeout=5)
The golden rule: Every create_task() deserves an await somewhere later in that same function. Otherwise you’re firing and forgetting—and “forgetting” is the default.
A quick decision tree for “Why isn’t my async code running?”
Ask yourself in order:
- Did I forget
await?
→ If you see<coroutine object...>in a print, yes. - Am I calling an async function from normal sync code?
→ Wrap it inasyncio.run(your_func()). - Did I create a task without awaiting or gathering it?
→ Addawait taskorawait asyncio.gather(task). - Is my event loop already running (Jupyter, Django Channels, FastAPI)?
→ Don’t useasyncio.run(). Useawaitdirectly orasyncio.create_task()and keep a reference.
If none of these apply, check if you’re accidentally blocking the loop with time.sleep() instead of await asyncio.sleep(). The first one freezes everything.
How to debug async failures without losing your mind
Most developers stare at async code like it’s a black box. Don’t do that. Use two simple techniques:
1. Add print statements with timestamps
import time
print(f"[{time.time()}] Before await")
await something()
print(f"[{time.time()}] After await")PythonYou’ll see exactly where execution pauses or skips.
2. Use asyncio.current_task() to see what’s running
import asyncio
print(asyncio.current_task()) # Shows you the task objectPythonIf you get None, you have no loop. That’s your answer.
The bottom line
async/await isn’t broken. It’s just explicit. Python refuses to guess whether you want to run a function now, later, or in parallel.
Most of the time, your problem is one of three things:
- You forgot
awaitentirely - You forgot
asyncio.run()at the top level - You created a task and walked away
Fix those, and async goes from “magic that fails” to “a tool that does exactly what you tell it to.”
Now go run your coroutines—for real this time.
What to read next (if you want to stop fighting Python)
asyncio.gather()vsasyncio.wait()– the short answer : usegatherunless you need fine-grained control- How to timeout an async function without breaking everything (hint:
asyncio.wait_for) - Why
asyncio.run()is different fromloop.run_until_complete()– and which one crashes your notebook




