Abstract flowing gradient in deep indigo and blue tones, smooth and luminous, evoking a modern digital learning atmosphere

Computer Science and programming articles. We do not sell courses.

Asyncio in Python: a practical programming guide

Modern software rarely runs in isolation. Network requests, database queries, and user interactions all take time, and how a program waits for them defines its responsiveness. Python developers in Melbourne and Sydney increasingly reach for asyncio when they need to coordinate thousands of incoming connections without spinning up a thread per request. The library, included in the standard distribution since Python 3.4, lets a single thread juggle many operations by giving each one a chance to run while others rest.

The asynchronous model in Python is built on coroutines, an event loop, and a handful of primitives that look surprising to anyone trained on traditional synchronous code. Functions declared with async def do not execute immediately when called; they return a coroutine object that must be scheduled. That subtle change in behaviour unlocks cooperative multitasking, where a coroutine voluntarily yields control whenever it would otherwise block, letting siblings make progress.

For Australian data teams running nightly pipelines that scrape property listings or compare supermarket prices across Coles and Woolworths, asyncio can collapse the wall-clock time of hundreds of slow web calls. Local fintech startups in Brisbane have shown that micro-services reading from S3-compatible object stores also benefit, since most of the latency lives in network waits rather than CPU work.

The rest of this article walks through the event loop, coroutines, tasks, common pitfalls, and a side-by-side comparison with threads. By the end, you should be able to read existing asyncio code with confidence and write new async functions that handle I/O the right way.

Understanding the event loop

The event loop is the heart of any asyncio program. Think of it as a queue manager that registers coroutines, waits for things to happen on sockets and timers, and resumes the coroutines whose results have arrived. In a typical web scraper pulling menus from cafés across Perth, the loop would issue requests, suspend each coroutine while the response travels back, and wake it up only when bytes are ready.

You normally start the loop with asyncio.run(main()), a helper added in Python 3.7 that creates a fresh loop, runs the supplied coroutine to completion, and tears everything down cleanly. Inside Jupyter notebooks, the loop is already running, so asyncio.run is not appropriate there; instead, you await top-level coroutines directly or use nest_asyncio. Engineers working on machine-learning workflows often hit this difference first when they move from a script to an interactive session.

Long-running services, such as the API behind a Brisbane ride-sharing startup, use asyncio.run once at startup and then hand control to a server framework like aiohttp or uvicorn. The loop runs forever, dispatching new requests to coroutines inside the framework's routers. Stopping the loop with loop.stop() or returning from the entry coroutine propagates cancellation to every task, which is far tidier than killing threads with KeyboardInterrupt.

Coroutines, tasks and futures

A coroutine is a function declared with async def and called like my_coro(). It does not run until it is awaited or wrapped into a task. Wrapping matters because await my_coro() runs it inline within the current coroutine, while asyncio.create_task(my_coro()) schedules it independently on the same loop.

Tasks are how you launch concurrent work. Once scheduled, a task returns a Future-like object that you can await later to collect its result. This pattern is useful when fetching data from many Australian Bureau of Statistics feeds simultaneously; you can fire ten requests, then join them with await asyncio.gather(*tasks) to receive the responses in one batch.

The distinction between coroutines and tasks often confuses newcomers. Coroutines are passive; tasks are active. If you await a bare coroutine that internally calls another slow coroutine, you wait sequentially. If you create_task first and then await later, both proceed in parallel. That single decision shapes whether your code is truly concurrent or merely decorated with async keywords.

Async and await in practice

The async and await keywords were introduced in Python 3.5 to replace the older @asyncio.coroutine decorator and yield from syntax. A minimal example declares an async def fetch(...) that uses async with and returns await resp.text(). Calling fetch(...) produces a coroutine; awaiting it runs the network round-trip. The async with and await are syntactic sugar that the compiler rewrites into calls to __aenter__, __aexit__, and __await__.

Libraries built around asyncio expose async APIs for everything from PostgreSQL access via asyncpg to HTTP with aiohttp. When calling them from synchronous code, you cannot simply await; you need asyncio.run(coro) to drive the loop. Inside other async code, the same coroutine can be awaited directly. A small habit of checking whether a library is async-aware up front saves hours of debugging later.

For readers familiar with ensemble methods in machine learning, the pattern will feel familiar: many small workers, each handling a slice of the workload, returning results that the caller combines. The crucial difference is that asyncio workers cooperate by yielding the thread instead of being scheduled by the operating system — a topic explored in this overview of bagging and boosting methods.

Gathering tasks and managing concurrency

asyncio.gather runs several awaitables concurrently and returns their results in a list. Pair it with list comprehensions to handle dozens of URL fetches in a single line. Two flags often come in handy: return_exceptions=True turns raised errors into returned objects so a single failure does not cancel the rest. That pattern is ideal for collecting price quotes across multiple retailers without crashing halfway through.

When you need fine-grained control over how many jobs run at once, asyncio.Semaphore provides a familiar counter-based limit. A team crawling tens of thousands of Australian government dataset pages once used a semaphore of fifty to stay polite and avoid rate-limiting. Throttling protection like that is a common reason teams adopt asyncio in the first place.

Cancelling tasks is equally important. Calling task.cancel() raises CancelledError inside the target coroutine at the next await. Well-behaved coroutines catch it, release any resources, and re-raise so the cancellation propagates. Without that hygiene, you can leak file descriptors and database connections, especially in long-lived daemons.

Common pitfalls and error handling

Blocking the loop is the cardinal sin. A call to requests.get(...) inside an async function stalls the event loop for the full duration of the network round-trip, freezing every other task. The fix is straightforward — switch to aiohttp or another async client — but the mistake is easy to make when copying snippets from older synchronous tutorials.

CPU-bound work is a related trap. Running numpy matrix multiplications in an async handler leaves nothing else to run on the loop. Offloading such jobs to concurrent.futures.ProcessPoolExecutor via loop.run_in_executor keeps the loop responsive while the heavy work happens in another process. Data scientists training models on Australian housing data frequently combine this approach with asyncio for I/O-heavy prep work.

Another subtle issue arises with shared state. Because the loop is single-threaded, you rarely need locks, but you must still avoid statements that depend on interleaving between two await points. Incrementing a counter and reading it in the same coroutine twice without an await in between is safe; doing so across multiple awaits is not.

Asyncio in production stacks across Australia

Atlassian, headquartered in Sydney with a strong engineering presence in Melbourne, runs large async services that back Jira and Confluence. Their teams have published talks describing how aiohttp endpoints handle tens of thousands of webhooks from third-party apps every minute, with predictable latency even during traffic spikes.

Smaller Australian SaaS shops follow a similar pattern. A Perth-based logistics startup reported that switching their status-polling endpoints from threads to asyncio cut their memory usage by roughly two-thirds. Fewer threads meant less per-connection overhead, and the cooperative model made it easier to reason about fairness between customer accounts.

The Python community here reinforces the trend. PyCon Australia, which alternates between Sydney, Melbourne, and Brisbane, regularly schedules talks on asyncio, async ORMs, and structured concurrency. Local meetups such as the Melbourne Python Users Group often pair these talks with hands-on workshops that walk newcomers through their first async API client.

Comparing approaches and practical recommendations

The summary below covers how asyncio compares with threading and multiprocessing for typical Python workloads.

Characteristic Threading Multiprocessing Asyncio
Best for I/O with occasional blocking libs CPU-bound crunching Many network or file I/O operations
Concurrency model Pre-emptive, OS-scheduled Separate processes Cooperative, single thread
Memory overhead High per thread Highest per process Low, one thread
Risk of race conditions Common Common Rare, single-threaded
Sync library compatibility Excellent Excellent Limited but growing

For I/O-heavy Python services — which describes most web APIs, scrapers, and chat clients in the Australian market — asyncio generally offers the best mix of throughput, memory efficiency, and source-code clarity. Threads remain the right choice when depending on a sync-only library that cannot be replaced, and multiprocessing stays the answer for genuinely CPU-bound jobs.

Practical tips for working with asyncio