Getting Started with asyncio

· 5 min read · Updated March 13, 2026 · intermediate
asyncio async await coroutines concurrency event-loop

Asynchronous programming lets your code handle multiple operations without waiting for each to finish. When you’re fetching data from APIs, reading files, or waiting for database queries, your program can work on other things instead of sitting idle. The asyncio module is Python’s built-in tool for this kind of work.

This guide covers the core concepts you need to start writing async code: coroutines, the event loop, and the async/await syntax.

Why Asyncio?

Traditional Python code runs synchronously—one line finishes before the next starts. If a line takes time (like waiting for a network response), everything stops:

import time

def fetch_data():
    time.sleep(2)  # Blocks for 2 seconds
    return "data"

def main():
    start = time.time()
    result1 = fetch_data()
    result2 = fetch_data()
    print(f"Total time: {time.time() - start:.2f}s")

main()
# Output: Total time: 4.00s

With async code, both fetches happen “at the same time”:

import asyncio
import time

async def fetch_data():
    await asyncio.sleep(2)  # Non-blocking pause
    return "data"

async def main():
    start = time.time()
    # Run both concurrently
    result1, result2 = await asyncio.gather(
        fetch_data(),
        fetch_data()
    )
    print(f"Total time: {time.time() - start:.2f}s")

asyncio.run(main())
# Output: Total time: 2.00s

The key difference: asyncio.sleep() doesn’t block the entire program. While one coroutine waits, others can run.

Coroutines: async def Functions

A coroutine is a function defined with async def. Calling it doesn’t run the code—it returns a coroutine object that represents “work to be done”:

async def say_hello():
    print("Hello")

# This doesn't print anything yet
coro = say_hello()
print(coro)  # <coroutine object say_hello at ...>

To actually run the code inside a coroutine, you need to await it or pass it to the event loop.

The await Keyword

await pauses the current coroutine until the awaited coroutine finishes:

import asyncio

async def step_one():
    await asyncio.sleep(1)
    print("Step 1 done")

async def step_two():
    await asyncio.sleep(1)
    print("Step 2 done")

async def main():
    await step_one()
    await step_two()

asyncio.run(main())
# Output:
# Step 1 done
# Step 2 done

These run sequentially (2 seconds total). To run them concurrently, use asyncio.gather().

Running Coroutines Concurrently

asyncio.gather() runs multiple coroutines at the same time:

import asyncio

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

async def main():
    results = await asyncio.gather(
        fetch("api.example.com"),
        fetch("api2.example.com"),
        fetch("api3.example.com"),
    )
    print(results)

asyncio.run(main())
# Output: ['Data from api.example.com', 'Data from api2.example.com', 'Data from api3.example.com']

All three “requests” complete in 1 second instead of 3.

The Event Loop

The event loop is the engine that runs async code. It schedules coroutines and handles I/O operations. You rarely need to interact with it directly—asyncio.run() handles everything:

asyncio.run(main())

This creates a new event loop, runs main() until it completes, then closes the loop.

For more control, you can manage the loop yourself:

loop = asyncio.new_event_loop()
try:
    loop.run_until_complete(main())
finally:
    loop.close()

But for most cases, asyncio.run() is all you need.

Creating Tasks

A task schedules a coroutine to run on the event loop. Use asyncio.create_task() when you want to start something running without waiting for it:

import asyncio

async def background_task():
    await asyncio.sleep(3)
    print("Background task done!")

async def main():
    task = asyncio.create_task(background_task())
    print("Task created, not waiting...")
    await asyncio.sleep(1)
    print("Doing other work...")
    await task  # Now wait for it
    print("All done")

asyncio.run(main())
# Output:
# Task created, not waiting...
# Doing other work...
# Background task done!
# All done

Tasks let you run background work while the main coroutine continues.

Waiting for Multiple Tasks

Sometimes you want to wait for several tasks but handle them as they complete:

import asyncio

async def task_with_duration(name, seconds):
    await asyncio.sleep(seconds)
    return f"{name} done"

async def main():
    done, pending = await asyncio.wait(
        [
            asyncio.create_task(task_with_duration("fast", 1)),
            asyncio.create_task(task_with_duration("medium", 2)),
            asyncio.create_task(task_with_duration("slow", 3)),
        ],
        return_when=asyncio.FIRST_COMPLETED
    )
    print(f"Completed: {[t.result() for t in done]}")
    print(f"Still pending: {len(pending)}")

asyncio.run(main())

asyncio.wait() returns when the first task completes (or all complete, depending on return_when).

Async Context Managers

Just like regular context managers (with statements), async ones use __aenter__ and __aexit__:

import asyncio

class AsyncResource:
    async def __aenter__(self):
        await asyncio.sleep(0.1)  # Simulate setup
        print("Acquired resource")
        return self
    
    async def __aexit__(self, exc_type, exc_val, exc_tb):
        await asyncio.sleep(0.1)  # Simulate cleanup
        print("Released resource")

async def main():
    async with AsyncResource():
        print("Using resource")

asyncio.run(main())
# Output:
# Acquired resource
# Using resource
# Released resource

Async Iterators and Generators

For streaming data, use async iterators:

import asyncio

class AsyncCounter:
    def __init__(self, limit):
        self.limit = limit
        self.current = 0
    
    def __aiter__(self):
        return self
    
    async def __anext__(self):
        await asyncio.sleep(0.1)
        if self.current >= self.limit:
            raise StopAsyncIteration
        self.current += 1
        return self.current

async def main():
    async for number in AsyncCounter(5):
        print(number)

asyncio.run(main())
# Output: 1, 2, 3, 4, 5

Common Async Patterns

Running Sync Code in Async Context

If you have blocking synchronous code, run it in a thread pool:

import asyncio
from concurrent.futures import ThreadPoolExecutor

def blocking_io():
    # This would block if it did real I/O
    import time
    time.sleep(1)
    return "done"

async def main():
    loop = asyncio.get_event_loop()
    result = await loop.run_in_executor(None, blocking_io)
    print(result)

asyncio.run(main())

Timeout Handling

Set a timeout for operations:

import asyncio

async def slow_operation():
    await asyncio.sleep(10)
    return "finished"

async def main():
    try:
        result = await asyncio.wait_for(slow_operation(), timeout=2.0)
    except asyncio.TimeoutError:
        print("Operation timed out!")

asyncio.run(main())
# Output: Operation timed out!

Summary

Asyncio provides a powerful way to handle concurrent operations without threads. The core concepts are:

  • async def defines a coroutine
  • await pauses until another coroutine finishes
  • asyncio.gather() runs multiple coroutines concurrently
  • asyncio.run() is the entry point for async programs
  • Tasks schedule coroutines to run in the background

Start with these basics, then explore asyncio.StreamReader, queues, and synchronization primitives as needed.

See Also