Skip to content
Python Async Programming — asyncio, async/await Explained

Python Async Programming — asyncio, async/await Explained

DodaTech Updated Jun 15, 2026 6 min read

Asynchronous programming in Python lets you write concurrent code that handles many I/O-bound tasks efficiently. Instead of waiting for one task to finish (like a network request), the event loop pauses that task and runs another.

What You’ll Learn

  • async/await syntax and how coroutines work
  • The event loop: how Python manages concurrency
  • Tasks, futures, and gathering results
  • aiohttp for async HTTP requests
  • When async helps vs hurts (performance trade-offs)

Why Async Matters

Durga Antivirus Pro scans hundreds of files simultaneously — each file read is I/O-bound, making async a perfect fit. DodaZIP compresses multiple archives concurrently. A web scraper that fetches 100 URLs sequentially takes 100 seconds (1 second each). With async, it completes in ~2 seconds. Async is for I/O-bound concurrency, not CPU-bound parallelism.

    flowchart LR
    A["Generators"] --> B["Context Managers"]
    B --> C["Async Programming"]
    C --> D["Type Hints"]
    D --> E["Testing"]
    A:::done --> B:::done --> C:::current
    style A fill:#2563eb,stroke:#2563eb,color:#fff
    style B fill:#2563eb,stroke:#2563eb,color:#fff
    style C fill:#2563eb,stroke:#2563eb,color:#fff
    style D fill:#dbeafe,stroke:#2563eb,color:#1e40af
    style E fill:#f1f5f9,stroke:#94a3b8,color:#64748b
  
Prerequisite: Understand Python generators (they’re the foundation of async) and Python context managers. Review https://tutorials.dodatech.com/programming-languages/python/py-generators/ first.

Coroutines and async/await

A coroutine is a function declared with async def. It can be paused with await and resumed later:

import asyncio

async def greet(name: str) -> str:
    await asyncio.sleep(1)  # Simulate I/O delay
    return f"Hello, {name}!"

# Run the coroutine
result = asyncio.run(greet("Alice"))
print(result)  # Hello, Alice! (after ~1 second)
  • async def makes a function a coroutine
  • await pauses the coroutine until the awaited operation completes
  • asyncio.run() starts the event loop and runs the top-level coroutine

The Event Loop

The event loop is the core of asyncio — it manages a queue of coroutines, running one until it hits an await, then switching to another:

async def task(name: str, delay: float):
    print(f"{name}: start")
    await asyncio.sleep(delay)
    print(f"{name}: end after {delay}s")
    return delay

async def main():
    # Run tasks concurrently
    results = await asyncio.gather(
        task("A", 2),
        task("B", 1),
        task("C", 0.5),
    )
    print(f"All done: {results}")

asyncio.run(main())

Output:

A: start
B: start
C: start
C: end after 0.5s
B: end after 1s
A: end after 2s
All done: [2, 1, 0.5]

Total time: ~2 seconds (not 3.5 seconds) — tasks ran concurrently.

Tasks vs Coroutines

A Task wraps a coroutine and schedules it on the event loop. Use asyncio.create_task() for fire-and-forget concurrency:

async def slow_operation(n: int) -> int:
    await asyncio.sleep(n)
    return n * 2

async def main():
    # Create tasks — they start running immediately
    task1 = asyncio.create_task(slow_operation(2))
    task2 = asyncio.create_task(slow_operation(1))

    # Await results — may return in any order
    print(await task2)  # 2 (finishes first)
    print(await task1)  # 4

asyncio.run(main())

Async HTTP with aiohttp

Install: pip install aiohttp

import asyncio
import aiohttp

async def fetch_url(session: aiohttp.ClientSession, url: str) -> dict:
    async with session.get(url) as response:
        return {"url": url, "status": response.status, "size": len(await response.text())}

async def fetch_all(urls: list[str]) -> list[dict]:
    async with aiohttp.ClientSession() as session:
        tasks = [fetch_url(session, url) for url in urls]
        return await asyncio.gather(*tasks)

def main():
    urls = [
        "https://httpbin.org/html" for _ in range(5)
    ]
    results = asyncio.run(fetch_all(urls))
    for r in results:
        print(f'{r["url"]}: status={r["status"]}, size={r["size"]}')

if __name__ == "__main__":
    main()

When Async Helps vs Hurts

ScenarioAsyncSync
100 web API calls✅ ~2s❌ ~100s
File I/O (100 files)✅ ~1s❌ ~50s
CPU-bound (image processing)❌ Same as sync
Simple script (3 operations)❌ Overhead✅ ~3s

Async excels at I/O-bound tasks — network requests, database queries, file reads where the program waits for external resources. Async never makes CPU-bound code faster.

Real-World Example: Async Web Scraper

import asyncio
import aiohttp
from typing import Optional

class AsyncScraper:
    def __init__(self, max_concurrent: int = 10):
        self.semaphore = asyncio.Semaphore(max_concurrent)

    async def fetch(self, session: aiohttp.ClientSession, url: str) -> Optional[str]:
        async with self.semaphore:  # Limit concurrent connections
            try:
                async with session.get(url, timeout=aiohttp.ClientTimeout(10)) as resp:
                    return await resp.text()
            except Exception as e:
                print(f"Failed {url}: {e}")
                return None

    async def scrape_many(self, urls: list[str]) -> list[Optional[str]]:
        async with aiohttp.ClientSession() as session:
            tasks = [self.fetch(session, url) for url in urls]
            return await asyncio.gather(*tasks)

    def run(self, urls: list[str]) -> list[Optional[str]]:
        return asyncio.run(self.scrape_many(urls))

scraper = AsyncScraper(max_concurrent=5)
results = scraper.run([f"https://httpbin.org/delay/{i}" for i in range(1, 4)])
print(f"Fetched {len([r for r in results if r])} pages successfully")

Common Mistakes

1. Using time.sleep() Instead of asyncio.sleep()

async def bad():
    time.sleep(1)  # Blocks the entire event loop!

Fix: Use await asyncio.sleep(1) — it yields control back to the event loop.

2. Forgetting to await a Coroutine

result = my_coroutine()  # Returns a coroutine object, not the result!

Fix: result = await my_coroutine()

3. Running Blocking CPU Code in Coroutines

Long-running CPU tasks block the event loop. Offload them to a thread pool:

import asyncio
import time

async def main():
    loop = asyncio.get_running_loop()
    result = await loop.run_in_executor(None, time.sleep, 2)
    print("Done")

4. Not Using asyncio.gather() for Concurrency

# Sequential — wrong
result1 = await fetch(url1)
result2 = await fetch(url2)

# Concurrent — correct
result1, result2 = await asyncio.gather(fetch(url1), fetch(url2))

5. Creating Too Many Connections

Opening 1000 concurrent connections can overwhelm your network or the target server. Use asyncio.Semaphore to limit concurrency.

Practice Questions

1. What’s the difference between a coroutine and a regular function?
A coroutine is defined with async def and can be paused/resumed with await. Regular functions run to completion in one go.

2. Why is time.sleep() bad in async code?
It blocks the entire event loop thread, preventing other coroutines from running.

3. What does asyncio.gather() do?
Runs multiple coroutines concurrently and returns results in the same order as the input.

4. When should you NOT use async?
For CPU-bound tasks, simple scripts, or when the overhead of the event loop outweighs the concurrency benefit.

Challenge: Write a program that fetches 10 URLs concurrently and measures the total time vs sequential fetching.

Solution
import asyncio
import aiohttp
import time

async def fetch_one(session, url):
    async with session.get(url) as resp:
        return await resp.text()

async def fetch_all_concurrent(urls):
    async with aiohttp.ClientSession() as session:
        tasks = [fetch_one(session, url) for url in urls]
        return await asyncio.gather(*tasks)

urls = ["https://httpbin.org/delay/1" for _ in range(5)]
start = time.time()
asyncio.run(fetch_all_concurrent(urls))
print(f"Concurrent: {time.time() - start:.2f}s")

Mini Project: Async File Downloader

import asyncio
import aiohttp
from pathlib import Path

async def download_file(session: aiohttp.ClientSession, url: str, dest: Path) -> bool:
    try:
        async with session.get(url) as response:
            content = await response.read()
            dest.write_bytes(content)
            print(f"Downloaded {url} -> {dest.name}")
            return True
    except Exception as e:
        print(f"Failed {url}: {e}")
        return False

async def download_all(urls: list[str], dest_dir: str = "/tmp/downloads"):
    Path(dest_dir).mkdir(parents=True, exist_ok=True)
    async with aiohttp.ClientSession() as session:
        tasks = []
        for i, url in enumerate(urls):
            dest = Path(dest_dir) / f"file_{i}.html"
            tasks.append(download_file(session, url, dest))
        return await asyncio.gather(*tasks)

urls = [
    f"https://httpbin.org/html" for _ in range(3)
]
successes = asyncio.run(download_all(urls))
print(f"Successfully downloaded: {sum(successes)}/{len(urls)}")

Expected output: Each file downloaded to /tmp/downloads/ concurrently.

What’s Next

Async programming completes your Python concurrency toolkit. Next, solidify your knowledge with type hints and testing.

TopicDescriptionLink
Python Type HintsStatic typing with mypyhttps://tutorials.dodatech.com/programming-languages/python/py-type-hints/
Python Testingpytest and mockinghttps://tutorials.dodatech.com/programming-languages/python/py-testing/
Python GeneratorsFoundation of asynchttps://tutorials.dodatech.com/programming-languages/python/py-generators/

Practice tip: Add async scanning to your BankAccount project — simulate concurrent transaction processing with asyncio.

Built by the developers of DodaTech

Doda Browser, DodaZIP & Durga Antivirus Pro