Trio vs asyncio: Structured Concurrency in Python

· 5 min read · Updated March 15, 2026 · advanced
trio asyncio async structured-concurrency concurrency

Python developers often default to asyncio for async programming, but Trio offers a fundamentally different approach built around structured concurrency. This guide compares both libraries, explains when each shines, and helps you choose the right tool for your project.

The Problem with Raw asyncio

Before understanding Trio, you need to see what it’s solving. asyncio gives you powerful primitives—coroutines, tasks, events—but leaves many responsibilities to you:

import asyncio

async def fetch_data():
    await asyncio.sleep(1)
    return "data"

async def main():
    # You create tasks but must manage their lifecycle
    task = asyncio.create_task(fetch_data())
    # What if main() exits before task completes?
    # The task gets cancelled silently in Python 3.11+
    # but you never knew it happened
    
asyncio.run(main())

The core issue: tasks can outlive their parent. If a parent coroutine exits, child tasks may continue running, get cancelled, or raise unexpected errors. You have to manually track and coordinate them.

What Is Structured Concurrency?

Structured concurrency treats groups of related tasks as a single unit. The key principle:

A task cannot outlive its parent. When the parent exits, all child tasks must complete (or be cancelled) before the parent returns.

This mirrors how regular function calls work—a function doesn’t return until all its work is done. Structured concurrency extends this to async tasks.

Trio implements this by design. asyncio supports it partially (Python 3.11+ added TaskGroup).

Trio: Structured Concurrency by Default

Trio makes structured concurrency the default:

import trio

async def fetch_data():
    await trio.sleep(1)
    return "data"

async def main():
    async with trio.open_nursery() as nursery:
        # Tasks spawned in a nursery are guaranteed to complete
        # before the nursery exits
        nursery.start_soon(fetch_data)
        nursery.start_soon(fetch_data)
        # When we exit the async with block, Trio waits
        # for all tasks to finish

trio.run(main)

The nursery is Trio’s way of grouping tasks. You spawn tasks into it, and Trio guarantees they complete before you exit. No more orphaned tasks.

Why Nurseries Work Better

In asyncio, you might write:

async def main():
    tasks = [asyncio.create_task(do_work(i)) for i in range(3)]
    await asyncio.gather(*tasks)

This works—until one task raises an exception:

async def main():
    tasks = [
        asyncio.create_task(do_work(1)),
        asyncio.create_task(do_work(2)),  # This raises
        asyncio.create_task(do_work(3)),
    ]
    await asyncio.gather(*tasks)  # Exception propagates, other tasks may be orphaned

Trio’s nursery handles this automatically:

async def main():
    async with trio.open_nursery() as nursery:
        nursery.start_soon(do_work, 1)
        nursery.start_soon(do_work, 2)  # Raises
        nursery.start_soon(do_work, 3)
        # Trio cancels remaining tasks when one fails
        # No orphaned tasks, no silent failures

Comparing Core APIs

FeatureasyncioTrio
Entry pointasyncio.run()trio.run()
Task groupsTaskGroup (3.11+)Nursery
Spawn taskscreate_task()nursery.start_soon()
Sleepasyncio.sleep()trio.sleep()
Timeoutswait_for(), timeout()trio.move_on_after(), trio.fail_after()
Cancellationtask.cancel()Structured via nursery

Spawning Tasks

asyncio:

async def main():
    task = asyncio.create_task(my_coroutine())
    # Must await or cancel manually
    await task

Trio:

async def main():
    async with trio.open_nursery() as nursery:
        nursery.start_soon(my_coroutine)
        # Auto-awaited when nursery exits

Timeouts

asyncio:

try:
    await asyncio.wait_for(do_something(), timeout=5)
except asyncio.TimeoutError:
    print("Timed out")

Trio:

with trio.move_on_after(5):
    await do_something()
# Code continues after timeout without exception

Or fail outright:

with trio.fail_after(5):
    await do_something()  # Raises trio.Cancelled after 5s

When to Choose Trio

Use Trio when:

  1. You want safety by default — Structured concurrency prevents silent task leaks
  2. Complex cancellation handling — Trio’s cancellation is cooperative and predictable
  3. You value clarity over compatibility — Trio’s API is smaller and more consistent
  4. Nested concurrency — Nurseries can spawn other nurseries naturally
  5. You’re building new projects — No asyncio legacy to maintain

Use asyncio when:

  1. Existing codebase — You have asyncio code already
  2. Library compatibility — Many libraries only support asyncio
  3. Need broad Python version support — asyncio works on Python 3.7+; Trio requires 3.8+
  4. Learning curve matters — asyncio has more tutorials and examples
  5. Production ecosystem — asyncio is battle-tested in production at scale

Real-World Example: HTTP Requests

Let’s compare both libraries fetching multiple URLs:

asyncio Version

import asyncio
import httpx

async def fetch_all(urls):
    async with httpx.AsyncClient() as client:
        tasks = [client.get(url) for url in urls]
        responses = await asyncio.gather(*tasks)
        return [r.status_code for r in responses]

asyncio.run(fetch_all(["http://example.com", "http://example.org"]))

Trio Version

import trio
import httpx

async def fetch_all(urls):
    async with trio.open_nursery() as nursery:
        results = []
        client = httpx.AsyncClient()  # Reuse connection
        
        async def fetch(url):
            response = await client.get(url)
            results.append(response.status_code)
        
        for url in urls:
            nursery.start_soon(fetch, url)
        
        return results

trio.run(fetch_all, ["http://example.com", "http://example.org"])

The Trio version is more verbose but gives you explicit control over task lifecycle.

Migrating from asyncio to Trio

If you have asyncio code, here’s a quick migration guide:

# asyncio
async with asyncio.TaskGroup() as tg:
    tg.create_task(task1())
    tg.create_task(task2())

# Trio equivalent
async with trio.open_nursery() as nursery:
    nursery.start_soon(task1)
    nursery.start_soon(task2)

For timeouts:

# asyncio
await asyncio.wait_for(coro(), timeout=5)

# Trio
with trio.move_on_after(5):
    await coro()

The Future: Trio and asyncio Growing Together

The boundary between Trio and asyncio is blurring. Python 3.11’s TaskGroup brought structured concurrency to asyncio. The exceptiongroups addition (Python 3.11) lets you handle multiple exceptions.

Trio’s influence is visible in:

  • TaskGroup (asyncio) — Similar to nurseries
  • asyncio.timeout() (Python 3.12) — Like Trio’s move_on_after
  • Better cancellation semantics in modern Python

You don’t always have to choose one. Some projects use both—Trio for application code, asyncio for library compatibility.

Summary

  • asyncio: The standard library, battle-tested, ecosystem-rich. Use for existing projects or when you need broad library support.
  • Trio: Built from the ground up with structured concurrency. Use for new projects where you want safety and simplicity by default.
  • Structured concurrency: A paradigm where tasks can’t outlive their parent, preventing silent failures and leaked work.
  • The choice isn’t always binary—understand both, use what’s appropriate.

If you’re starting a new async project and don’t need asyncio compatibility, Trio’s structured approach will save you from subtle bugs. If you’re maintaining asyncio code or need third-party library support, stick with asyncio and adopt structured patterns (TaskGroup) where possible.

See Also