Right, let’s talk about Trio. You’ve probably written enough code with asyncio to appreciate its power but also to feel the low, constant hum of its jankiness. It’s the framework equivalent of a brilliant engineer who leaves their coffee cups all over the lab. Trio is a response to that. It’s a from-the-ground-up rethinking of async I/O in Python built around one core, brilliant idea: structured concurrency.

The name sounds academic, but the concept is brutally simple. In asyncio, when you fire off a task with asyncio.create_task(), it launches off into the ether. It’s your responsibility to await it somewhere, someday, or it might just run forever silently, leaking resources if it crashes. It’s like sending a kid to the store with your credit card and no deadline for their return. Trio eliminates this whole class of nightmares by enforcing a rule: you cannot spawn a background task without being in a context that guarantees it will be finished before that context exits. This context is called a nursery.

Your First Nursery: No Child Left Behind

Think of a nursery as a supervised playground. You can start any number of tasks (children) inside it, but the async with block won’t exit (the playground gates won’t close) until every single one of those tasks has either completed or crashed. This completely eliminates the possibility of orphaned tasks. Let’s see it in action.

import trio

async def child(name: str, sleep_time: int):
    print(f"Child {name} is going to sleep for {sleep_time}s.")
    await trio.sleep(sleep_time)
    print(f"Child {name} is awake!")

async def parent():
    print("Parent is starting a nursery.")
    async with trio.open_nursery() as nursery:
        # Start three children running concurrently
        nursery.start_soon(child, "A", 3)
        nursery.start_soon(child, "B", 1)
        nursery.start_soon(child, "C", 2)
    print("Nursery is closed. All children are accounted for.")

trio.run(parent)

Run this. The output is beautifully predictable:

Parent is starting a nursery.
Child A is going to sleep for 3s.
Child B is going to sleep for 1s.
Child C is going to sleep for 2s.
Child B is awake!
Child C is awake!
Child A is awake!
Nursery is closed. All children are accounted for.

The async with block on open_nursery() only exits after 3 seconds, when the slowest task (Child A) is done. This is structured concurrency. The control flow of your code visually matches the actual concurrency flow.

Error Handling That Actually Helps

Here’s where it gets even better. What if one child crashes? In the unstructured world, this often means a log message and a silently hung program. In Trio, the nursery’s primary job is to keep its children safe, and that includes noticing when one of them has a catastrophic accident.

async def problematic_child():
    await trio.sleep(1)
    raise ValueError("Oops, I tripped!")

async def resilient_parent():
    try:
        async with trio.open_nursery() as nursery:
            nursery.start_soon(child, "Well-behaved", 3)
            nursery.start_soon(problematic_child)
    except ValueError as e:
        print(f"The parent caught the child's error: {e}")

trio.run(resilient_parent)

The moment problematic_child raises its exception, the nursery’s protocol kicks in:

  1. Cancel all other children in the nursery immediately. Trio sends a cancellation request to the “Well-behaved” child.
  2. Wait for all children to exit. The well-behaved child can either catch the trio.Cancelled exception and clean up, or it can be forcibly terminated if it doesn’t respond.
  3. Once all children are gone, the original exception (the ValueError) is re-raised outside the nursery block, right into our try/except.

This is a game-changer. It makes writing robust, error-resistant concurrent code almost trivial. The nursery becomes a single, obvious place to handle failures from an entire group of related tasks.

The Cancellation Gotcha You Will Hit

Trio’s cancellation is brilliant, but it’s cooperative. Your tasks need to be good citizens and check for cancellation points. The main way to do this is by using trio.sleep(). Any await trio.sleep(...) is a cancellation point. If your task gets stuck in a long-running CPU-bound loop or calls a time.sleep(10) (which is a crime in async code, by the way), it becomes immune to cancellation. It will block the entire nursery from closing.

The fix is to either break up the work or use await trio.sleep(0) to yield control and check for cancellation.

async def uncancellable_bad_task():
    # This is BAD. It blocks the event loop.
    import time
    time.sleep(10) # 10 seconds of blocking nonsense!

async def cancellable_good_task():
    # This is GOOD. It yields control periodically.
    for i in range(10):
        await do_some_work_chunk(i)
        await trio.sleep(0) # Yield and check for cancellation

Why You Can’t Just nursery.start_later()

You might be looking for a way to start tasks dynamically from outside the async with block. Tough luck. The designers made a very intentional choice here. The nursery context manager must be the one to oversee all its tasks. This preserves the core guarantee. You can, however, pass the nursery object into other functions to let them start tasks.

async def task_starter(nursery):
    nursery.start_soon(child, "Dynamically-Added", 1)

async def main():
    async with trio.open_nursery() as nursery:
        # Pass the nursery to another function to start more work
        await task_starter(nursery)

It feels a bit weird at first, but you learn to appreciate the rigor. It forces you to architect your concurrency cleanly. Trio isn’t just a library; it’s a philosophy. It argues, quite convincingly, that by accepting a few sensible constraints, we can build async systems that are dramatically easier to reason about and debug. And honestly, after a while, you start to wonder how you ever managed without it.