Python’s async/await enables concurrent I/O, running multiple network requests, database queries, or file operations at the same time without blocking. It’s essential for FastAPI, aiohttp, and modern high-performance Python.
This tutorial covers everything from basic asyncio to advanced patterns.

What async solves
Synchronous (blocking): - Send HTTP request → wait 2 seconds - Send another HTTP request → wait 2 seconds - Total: 4 seconds Asynchronous (concurrent): - Send HTTP request → yield while waiting - Send another HTTP request → yield while waiting - Both complete in ~2 seconds For I/O-bound work (network, DB, file), async is 5-100x faster.
Basic async function
import asyncio
async def hello():
print("Start")
await asyncio.sleep(1) # non-blocking wait
print("End")
# Run
asyncio.run(hello())
# Output:
# Start
# (1 second pause)
# EndConcurrent execution with gather
import asyncio
async def task(n):
print(f"Task {n} start")
await asyncio.sleep(2)
print(f"Task {n} end")
return n * 2
async def main():
# Run 3 tasks concurrently
results = await asyncio.gather(
task(1),
task(2),
task(3),
)
print(f"Results: {results}")
asyncio.run(main())
# Total time: ~2 seconds (not 6)
# Results: [2, 4, 6]Async HTTP requests with httpx
import asyncio
import httpx
async def fetch(url: str):
async with httpx.AsyncClient() as client:
response = await client.get(url)
return response.json()
async def main():
urls = [
"https://api.github.com/users/torvalds",
"https://api.github.com/users/gvanrossum",
"https://api.github.com/users/tj",
]
# Fetch all URLs concurrently
results = await asyncio.gather(*[fetch(url) for url in urls])
for user in results:
print(user["name"])
asyncio.run(main())
# 3 requests, total time ~200ms (not 600ms)Tasks (start and run later)
import asyncio
async def worker(id: int):
await asyncio.sleep(1)
return f"Worker {id} done"
async def main():
# Create tasks (start immediately, don't await)
task1 = asyncio.create_task(worker(1))
task2 = asyncio.create_task(worker(2))
# Do other work here
print("Tasks running in background")
await asyncio.sleep(0.5)
print("Halfway there")
# Wait for tasks to complete
result1 = await task1
result2 = await task2
print(result1, result2)
asyncio.run(main())Error handling in async
import asyncio
async def risky_task(n):
if n == 2:
raise ValueError(f"Bad number {n}")
return n * 2
async def main():
# Method 1: gather with return_exceptions
results = await asyncio.gather(
risky_task(1),
risky_task(2),
risky_task(3),
return_exceptions=True # exceptions become results
)
print(results) # [2, ValueError(...), 6]
# Method 2: try/except around await
try:
result = await risky_task(2)
except ValueError as e:
print(f"Handled: {e}")Async context managers
import asyncio
import aiofiles # uv add aiofiles
async def read_file(path: str):
async with aiofiles.open(path, mode='r') as f:
contents = await f.read()
return contents
# Async with (aclose is called on exit)
# Works with httpx, aiohttp, databases, etc.Async iteration
import asyncio
async def get_pages():
for page in range(5):
await asyncio.sleep(0.1) # simulate fetching
yield f"Page {page}"
async def main():
async for page in get_pages():
print(page)
asyncio.run(main())TaskGroup (Python 3.11+)
import asyncio
async def worker(id: int):
await asyncio.sleep(1)
return f"Worker {id}"
async def main():
async with asyncio.TaskGroup() as tg:
task1 = tg.create_task(worker(1))
task2 = tg.create_task(worker(2))
# If any raises, all get cancelled
# Tasks complete when exiting the with block
print(task1.result(), task2.result())
asyncio.run(main())
# TaskGroup: cleaner than gather, handles cancellation betterTimeouts
import asyncio
async def slow_task():
await asyncio.sleep(10)
return "done"
async def main():
try:
result = await asyncio.wait_for(slow_task(), timeout=3.0)
except asyncio.TimeoutError:
print("Task took too long, cancelled")
asyncio.run(main())Async with databases (asyncpg for PostgreSQL)
import asyncio
import asyncpg # uv add asyncpg
async def get_users():
conn = await asyncpg.connect(
"postgresql://user:pass@localhost/db"
)
rows = await conn.fetch("SELECT * FROM users LIMIT 10")
await conn.close()
return rows
users = asyncio.run(get_users())
for user in users:
print(user['name'])Common async mistakes
- Forgetting await:
result = fetch(url)returns coroutine, not result. Useresult = await fetch(url). - Blocking calls in async functions: don’t use requests.get in async, use httpx. Don’t use time.sleep, use asyncio.sleep.
- Calling asyncio.run inside async function: only call at top level.
- Running CPU-bound work in async: async doesn’t help. Use multiprocessing.
- Not closing connections: use async context managers.
When to use async
- HTTP requests (many concurrent).
- Database queries (with async driver).
- File I/O (large files).
- Web scraping.
- Real-time chat, notifications.
- FastAPI endpoints.
- WebSockets.
When NOT to use async
- CPU-bound work (use multiprocessing).
- Simple scripts with no I/O.
- When you can’t find async libraries for your dependencies.
- When team unfamiliar and you’re prototyping.
Common python mistakes to avoid
- Skipping the “why” before adopting a new tool. Modern Python tools (uv, Ruff, Polars) are fast and clean. But adopting them without understanding what problem they solve wastes time. Read the tool’s motivation section first.
- Migrating everything at once. Legacy code bases have too many surprises. Migrate one module at a time and test after each step.
- Ignoring backwards compatibility. New Python tools sometimes break with older Python versions. Verify your target Python version supports the tool before committing.
- Trusting benchmarks blindly. “10x faster than X” often means “10x faster for one specific workload”. Test with YOUR data before assuming the benchmark generalizes.
- Not reading the CHANGELOG. Every dependency upgrade may break something. Skimming the changelog for breaking changes takes 2 minutes and saves hours.
Adoption path for python
- Read official docs cover-to-cover for the core concepts. Yes, cover-to-cover.
- Complete the official tutorial project. Follow along exactly, no shortcuts.
- Adapt one small piece of your existing project to the new tool. Ship it.
- If it survives 2 weeks in production, expand adoption.
- If it caused issues, rollback and reassess whether the tool fits your use case.
When to use vs when to stick with what you know
Modern Python tools offer real improvements over their predecessors, but “better” is not the same as “necessary for you.” Consider adopting when:
- Your current tool is slow enough to block productivity (uv/Ruff speedups matter here).
- You are starting a new project with no legacy constraints.
- Your team has bandwidth to learn and support the new tool.
- The tool has been stable and community-adopted for 12+ months.
Stick with your existing tools when: production stability is critical and you have working infrastructure; your team is small and cannot afford learning curves; the tool is early-stage and API may change.
Ecosystem integration considerations
Every new Python tool interacts with the broader ecosystem. Before committing, check:
- Does your CI/CD pipeline support it? Most tools have GitHub Actions and GitLab CI templates.
- Does your IDE support it? VS Code and PyCharm have first-class support for most modern tools. Older editors may lag.
- Do dependency scanners (Snyk, Dependabot) recognize it? Ensures security updates get flagged.
- Is there production monitoring integration? Datadog, Sentry, New Relic support matters for production.
For teams shipping production Python code, the ecosystem answer is often more important than the tool answer. A slightly-worse tool that plays well with your stack beats a slightly-better tool that fights it.
Best practices summary
- Read the docs before Stack Overflow. Official docs are cleaner and more accurate than forum answers.
- Pin versions in production. Locked dependencies prevent surprise breakage. Update deliberately, not accidentally.
- Test after every dependency upgrade. Run the full test suite. Catch regressions before deployment.
- Contribute back. Report bugs, submit fixes, write tutorials. The tools improve when users engage.
- Reassess yearly. The Python ecosystem moves fast. What was best last year may be second-best today.
Official documentation
Recommended async Python resources
The links below are affiliate links. We may earn a commission at no extra cost to you when you buy or sign up. See our affiliate disclosure.
Frequently Asked Questions
When should I use async instead of threads?
async for I/O bound work with many concurrent tasks (100+). Threads for I/O with fewer tasks or when you need blocking libraries. async has less overhead per task.
Can I use requests library in async code?
Technically yes but it blocks the event loop. Use httpx or aiohttp for async HTTP. Wrapping requests in asyncio.to_thread also works.
Does async make my code faster?
For I/O bound work: dramatically yes. For CPU bound work: no. async doesn’t help pure computation, use multiprocessing.
Should I always use async in FastAPI?
Use async when you’re doing I/O (DB, HTTP, files). Sync is fine for CPU work or simple returns. FastAPI handles both automatically.
Is async worth learning?
Yes. Required for FastAPI, modern web APIs, AI applications. Not needed for scripts or CPU tasks. Growing in importance year over year.
