Async and Await in Python Explained
SkillVeris Team
Engineering Team

Async and await let a single-threaded Python program handle many I/O-bound tasks at once by pausing a coroutine while it waits and running other work in the meantime.
In this guide, you'll learn:
- An async def function is a coroutine; calling it returns a coroutine object that does nothing until it is awaited or scheduled on the event loop.
- The await keyword yields control back to the event loop at points where the code would otherwise block, such as network or disk waits.
- asyncio.gather and asyncio.create_task run coroutines concurrently, which is where async delivers real speedups over sequential code.
- Async helps I/O-bound workloads, not CPU-bound ones — heavy computation still needs threads or processes to escape the GIL.
1What Are Async and Await in Python?
Async and await are Python keywords for writing concurrent code that runs on a single thread. You mark a function async def to make it a coroutine, and inside it you use await wherever the code would otherwise wait — for a network response, a database query, or a file read. At each await the coroutine hands control back to an event loop, which runs other coroutines until the awaited result is ready.
The result is concurrency without threads. One thread juggles hundreds of overlapping I/O operations by never sitting idle: while one coroutine waits, another makes progress. This is why async powers modern web servers, scrapers, and API clients.
2Coroutines and the Event Loop
A coroutine is what you get from an async def function. Crucially, calling that function does not run it — it returns a coroutine object that must be driven by an event loop. The event loop, provided by the asyncio module, is a scheduler that keeps a list of ready coroutines and resumes each one until it next awaits.
You start the loop with asyncio.run(main()), which runs your top-level coroutine to completion. Everything else is scheduled from inside it.
- import asyncio
- async def greet():
- print('hello')
- await asyncio.sleep(1) # yields control for 1 second
- print('world')
- asyncio.run(greet()) # starts the event loop and runs greet
3How Await Yields Control
The await keyword is the pause button. When a coroutine awaits something awaitable — another coroutine, a task, or a future — it suspends itself and returns control to the event loop. The loop is then free to run any other coroutine that is ready. Once the awaited operation completes, the loop resumes the suspended coroutine right where it paused.
This cooperative model is why async is efficient: nothing blocks the thread. But it is also why one badly behaved coroutine that never awaits can starve every other task.
💡Key Insight
await does not make code parallel by itself. It marks a suspension point. Concurrency happens only when multiple coroutines are scheduled together so the loop can switch between them.
4Running Tasks Concurrently
Awaiting coroutines one after another is still sequential. To get real overlap, schedule them together. asyncio.gather runs several coroutines concurrently and waits for all of them, while asyncio.create_task launches a coroutine in the background so it runs alongside your current code.
- async def fetch(url):
- ... # await an async HTTP call
- results = await asyncio.gather(fetch(a), fetch(b), fetch(c)) # all at once
- task = asyncio.create_task(fetch(a)) # starts running immediately
- value = await task # await later when you need the result
Sequential vs Concurrent
Three network calls that each take one second run in about three seconds if you await them in a row. Wrapped in asyncio.gather, they overlap and finish in roughly one second. That collapse of total wait time is the entire reason to reach for async.
5Async Helps I/O, Not CPU
Async is the right tool for I/O-bound work — waiting on networks, disks, and databases — because those waits are exactly the moments a coroutine can yield. It does nothing for CPU-bound work like image processing or number crunching, because heavy computation never awaits and so never gives the loop a chance to switch.
- I/O-bound (great fit): web requests, API calls, file reads, database queries, message queues.
- CPU-bound (poor fit): image resizing, encryption, parsing huge blobs, machine learning inference.
- For CPU work: use multiprocessing or run blocking calls in a thread pool executor.
- The GIL still applies: async does not run Python bytecode in parallel — it interleaves waits.
⚠️Common Trap
Calling a blocking library like requests or time.sleep inside a coroutine freezes the entire event loop. Use aiohttp and asyncio.sleep, or offload the blocking call with loop.run_in_executor.
6A Practical Pattern
Most real async programs follow one shape: define coroutines for each I/O task, gather them, and run everything from a single asyncio.run entry point. The example below fetches several URLs concurrently with an async HTTP client.
- import asyncio, aiohttp
- async def fetch(session, url):
- async with session.get(url) as resp:
- return await resp.text()
- async def main(urls):
- async with aiohttp.ClientSession() as session:
- return await asyncio.gather(*(fetch(session, u) for u in urls))
- asyncio.run(main(['https://a.com', 'https://b.com']))
7Best Practices for Async Python
A few habits keep async code correct and fast as it grows. Most problems come from mixing blocking and async code or forgetting to await something.
- Await every coroutine — an un-awaited coroutine raises a warning and never runs.
- Use async-native libraries (aiohttp, asyncpg, databases) instead of blocking ones.
- Offload unavoidable blocking calls with loop.run_in_executor or asyncio.to_thread.
- Prefer asyncio.gather for concurrency; use create_task for fire-and-await-later work.
- Keep one event loop per program and start it once with asyncio.run.
- Add timeouts with asyncio.wait_for so a stuck task cannot hang forever.
8Key Takeaways
The core of async Python fits into a few principles.
- async def defines a coroutine; it runs only when awaited or scheduled on the loop.
- await marks a suspension point where the coroutine yields control to the event loop.
- Concurrency comes from scheduling many coroutines together with gather or create_task.
- Async speeds up I/O-bound work, not CPU-bound work, which still needs threads or processes.
- Never call blocking code inside a coroutine — use async clients or an executor.
9Frequently Asked Questions
Q: What is the difference between async and threading in Python? A: Threading uses multiple OS threads managed by the operating system, while async runs many coroutines on one thread cooperatively. Async avoids thread overhead and race conditions for I/O work, but it needs async-aware libraries and cannot speed up CPU-bound tasks the way separate processes can.
Q: Does async make Python code run in parallel? A: No. Async provides concurrency, not parallelism — one thread interleaves tasks by switching at await points. For true parallel execution of CPU work you need multiprocessing, which sidesteps the Global Interpreter Lock.
Q: Why does my coroutine do nothing when I call it? A: Calling an async def function only creates a coroutine object; it does not execute. You must await it, wrap it in asyncio.create_task, or run it with asyncio.run. An un-awaited coroutine triggers a RuntimeWarning.
Q: Can I use await outside an async function? A: No. await is only valid inside an async def coroutine. At the top level of a script, start the async world with asyncio.run(main()), and put your await calls inside main.
Related Reading
Get The Print Version
Download a PDF of this article for offline reading.
About the Publisher
SkillVeris Team
Engineering Team
Our engineering writers turn abstract code concepts into hands-on, project-driven learning experiences.
View all postsRelated Posts
Never miss an update
Get the latest tutorials and guides delivered to your inbox.
No spam. Unsubscribe anytime.