Maintenance

Site is under maintenance — quizzes are still available.

Go to quizzes
Sponsored Reserved space — layout preview until AdSense is connected
Tutorial

A Practical Guide to Python's asyncio: Understanding Event Loops, Coroutines, and Concurrency Patterns

Learn how Python's asyncio library works through event loops, coroutines, and common concurrency patterns. This guide covers practical examples, pitfalls to avoid, and when to use async vs synchronous code.

July 2026 9 min read 1 views 0 hearts

If you’ve ever built a web scraper that spends most of its time waiting for network responses, or a chat server that needs to handle hundreds of connections at once, you’ve probably felt the pain of synchronous Python. That’s where asyncio steps in. It’s not magic—it’s a carefully designed library that lets your program juggle multiple tasks without the overhead of threads.

Let me tell you a story. At PythonSkillset, we once had a data pipeline that processed incoming API requests. Each request took about two seconds—most of that was waiting for external services. With a synchronous approach, handling 100 requests meant 200 seconds of wall time. After switching to asyncio, the same job finished in just over two seconds. That’s the kind of improvement that makes you wonder why you didn’t start earlier.

What Makes asyncio Tick?

At its core, asyncio is built around three concepts that work together like a well-oiled machine.

The Event Loop — The Brain of the Operation

Think of the event loop as a busy manager. It sits in a loop, constantly checking which tasks are ready to run, which are waiting for something (like a network response), and which have finished. It never sleeps—it just efficiently switches between tasks.

import asyncio

async def task(name, delay):
    await asyncio.sleep(delay)
    print(f"{name} completed after {delay} seconds")

async def main():
    # Create tasks and let the event loop handle scheduling
    task1 = asyncio.create_task(task("Task A", 2))
    task2 = asyncio.create_task(task("Task B", 1))

    await task1
    await task2

asyncio.run(main())  # This creates and runs the event loop

When you run this, you’ll see "Task B completed after 1 seconds" first, then "Task A completed after 2 seconds". The event loop didn’t block—it started both tasks, saw that Task B was ready sooner, and continued with it while Task A was still sleeping.

Coroutines — Your Asynchronous Functions

A coroutine is just a regular Python function with a special twist: it can pause itself. When you see async def, you’re defining a coroutine. The await keyword is where the magic happens—it says "I’m going to wait for this, but in the meantime, go do something else."

async def fetch_data(url):
    # Simulate network request
    await asyncio.sleep(1)  
    return f"Data from {url}"

Notice how this looks almost identical to a regular function. That’s the beauty of asyncio—you write simple code that the event loop makes concurrent.

Awaitables — Things You Can Wait On

Not everything can be awaited. You can only await three types of objects: - Coroutines (what you get from calling async def functions) - Tasks (wrappers that schedule coroutines on the event loop) - Futures (low-level objects representing a result that’s not ready yet)

Understanding this distinction saved me hours of debugging early on. If you try to await a regular function, Python will raise a TypeError.

Common Concurrency Patterns

Once you have the basics, you’ll want to apply patterns that match real-world problems.

Pattern 1: Gather — Run Multiple Tasks Together

The most straightforward pattern when you have independent tasks that all must complete.

async def main():
    results = await asyncio.gather(
        fetch_data("https://api.pythonskillset.com/users"),
        fetch_data("https://api.pythonskillset.com/posts"),
        fetch_data("https://api.pythonskillset.com/comments")
    )
    # All three started at the same time
    # Total time: about 1 second instead of 3
    print(results)

Pattern 2: Wait for Any Task to Complete

Sometimes you only need the fastest result. This is useful for redundant API calls or timeout scenarios.

async def main():
    # Race between these tasks
    done, pending = await asyncio.wait(
        [fast_response(), slow_response()],
        return_when=FIRST_COMPLETED
    )
    # Cancel the slower one
    for task in pending:
        task.cancel()

Pattern 3: Semaphore — Controlling Access to Limited Resources

Not all concurrency is equal. If you’re hitting an API that allows only 10 requests per second, you need a semaphore.

semaphore = asyncio.Semaphore(10)

async def safe_request(url):
    async with semaphore:
        return await fetch_data(url)

Pattern 4: Queues for Producer-Consumer Workloads

When you’re processing a stream of data—say, log entries or user actions—a queue keeps everything organized.

async def producer(queue):
    for i in range(20):
        await queue.put(f"Item {i}")
        await asyncio.sleep(0.1)

async def consumer(queue, name):
    while True:
        item = await queue.get()
        print(f"{name} processed {item}")
        queue.task_done()

async def main():
    queue = asyncio.Queue()
    await asyncio.gather(
        producer(queue),
        consumer(queue, "Worker A"),
        consumer(queue, "Worker B"),
    )

Common Pitfalls and How to Avoid Them

Even experienced Python developers hit these walls. Here’s what to watch for.

The Blocking Call Trap

Remember that await only helps with I/O-bound operations. If you call time.sleep(5) inside a coroutine, you’ll block the entire event loop. Always use asyncio.sleep().

Running CPU-Bound Tasks

If you need to process heavy data (like parsing 10,000 JSON files), asyncio won’t help directly. Use asyncio.to_thread() to offload to a thread pool:

import json

async def process_big_file(filename):
    loop = asyncio.get_running_loop()
    # This runs in a separate thread
    data = await loop.run_in_executor(None, lambda: json.load(open(filename)))
    return data

Forgetting to Await

This is the most common bug. If you write task = asyncio.create_task(some_coroutine()) but never await task, it’ll just disappear into the void. Python 3.12 added a warning for this, but it’s easy to miss.

Real World Example: Building a Simple Web Scraper

Let me show you how all these pieces fit together. This is a pattern I use at PythonSkillset almost daily.

import asyncio
import aiohttp

async def fetch_page(session, url):
    try:
        async with session.get(url) as response:
            return await response.text()
    except Exception as e:
        print(f"Error fetching {url}: {e}")
        return None

async def scrape_site(base_url, paths):
    async with aiohttp.ClientSession() as session:
        tasks = [fetch_page(session, f"{base_url}{path}") for path in paths]
        results = await asyncio.gather(*tasks)
        return results

async def main():
    urls = ["/", "/about", "/contact", "/blog"]
    pages = await scrape_site("https://pythonskillset.com", urls)
    # Process all pages here
    for url, content in zip(urls, pages):
        if content:
            print(f"{url}: {len(content)} characters")

asyncio.run(main())

This single script can fetch dozens of pages in the time it takes to fetch one synchronously.

When NOT to Use asyncio

It’s not always the right tool. Use asyncio when: - Your program spends most of its time waiting (network, disk, databases) - You need high concurrency (thousands of connections) - You want to avoid thread safety issues

Skip it when: - You’re doing heavy CPU work (use multiprocessing instead) - Your codebase is already deeply threaded and working fine - You only need to handle a few concurrent tasks (simple threading might be easier)

The Bottom Line

Asyncio isn’t optional anymore—it’s a core part of modern Python. The real learning comes not from memorizing the API, but from understanding that await is just a polite way of saying "I’ll wait, but you don’t have to wait with me."

Start small. Convert one blocking I/O call in your project to asyncio. See the difference. Then expand from there. Before you know it, you’ll wonder how you ever managed without it.

Comments

Questions, corrections, and tips stay visible for everyone reading this page.

0 in thread

Join the discussion

Shown next to your comment.

Up to 4,000 characters

No comments yet

Be the first to leave a note — it helps the next reader.