Coroutines & asyncio

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

The reference material consulted while preparing these pages is listed in the section bibliography.

This page is written against Tornado 6.5.10 on Python 3.13, checked on 2026-10-07. Tornado 6.6 is only available as an alpha (6.6a1) at the time of writing, so everything described as "6.6" below is upcoming and may still change. Every example on this page was executed with those versions: some through asyncio.run() against a throw-away server bound to a loopback port, the handler example through AsyncHTTPTestCase, and every external service is replaced by a small local fake. The running example is the Bookshelf Live scenario: a handler that needs cover metadata for several books from a slow external metadata service, which is exactly the kind of waiting that coroutines exist to overlap.

A coroutine is a function that can pause at an await, let the event loop run something else, and resume when the awaited thing is ready. Since Tornado 5.0 the Tornado IOLoop is a thin wrapper around the standard library’s asyncio event loop, so a Tornado coroutine is an asyncio coroutine and a Tornado Future is an asyncio.Future. That one fact explains most of this page: the Tornado helpers (tornado.gen.multi, tornado.gen.with_timeout, tornado.gen.sleep) and the asyncio helpers (asyncio.gather, asyncio.timeout, asyncio.sleep) are two vocabularies for one event loop, and you can mix them freely. The page covers the vocabulary, the pitfalls (the biggest one is a coroutine nobody awaits) and, at the end, how to read and migrate the older @gen.coroutine code that predates async def.

For the Python language side (asyncio, tasks, threads, the GIL) see Concurrency & Async in the Python Reference. For the same topic in other frameworks see FastAPI (async-first on ASGI) and Flask: Async, gevent & Quart (async bolted onto WSGI). The Tornado pages Asynchronous I/O & the Event Loop, Queues & Locks and Applications & Request Handlers cover those topics.

Native Coroutines

A native coroutine is a function declared with async def. Calling it does not run it: it returns a coroutine object, and the body only starts when something awaits that object or schedules it on the event loop. Inside a coroutine, await suspends the function until the awaited object (a coroutine, a Future, a task) has a result, and meanwhile the event loop serves other connections. Tornado’s own methods that do I/O are all awaitable, for example AsyncHTTPClient.fetch.

The examples on this page share one small fixture: a fake cover-metadata service, bound to an unused loopback port, that answers after a configurable delay (one second by default for identifiers starting with slow, 200 ms otherwise) and fails for identifiers starting with bad. In the real Bookshelf Live it would be an external HTTP API.

import asyncio
import contextlib
import datetime
import logging
import time

from tornado import gen, httpclient, httpserver, ioloop, netutil, web
from tornado.escape import json_decode


class CoverMetadataHandler(web.RequestHandler):
    """Local fake of the external cover-metadata service."""

    async def get(self, isbn):
        default_delay = "1.0" if isbn.startswith("slow") else "0.2"
        await gen.sleep(float(self.get_query_argument("delay", default_delay)))
        if isbn.startswith("bad"):
            raise web.HTTPError(502, reason="upstream failure")
        self.write({"isbn": isbn, "cover_url": f"https://covers.example/{isbn}.jpg"})


@contextlib.asynccontextmanager
async def fake_metadata_service():
    sockets = netutil.bind_sockets(0, address="127.0.0.1")
    server = httpserver.HTTPServer(
        web.Application([(r"/covers/([^/]+)", CoverMetadataHandler)])
    )
    server.add_sockets(sockets)
    port = sockets[0].getsockname()[1]
    try:
        yield f"http://127.0.0.1:{port}"
    finally:
        server.stop()
        await server.close_all_connections()

With the fixture in place, the first coroutine is a plain async def that awaits an HTTP fetch and decodes the JSON body. The example also shows the "calling is not running" rule, and that a coroutine can be started with asyncio.run() at the program’s entry point.

async def fetch_cover(base_url, isbn, delay=0.2):
    client = httpclient.AsyncHTTPClient()
    response = await client.fetch(f"{base_url}/covers/{isbn}?delay={delay}")
    return json_decode(response.body)


async def main():
    async with fake_metadata_service() as base:
        pending = fetch_cover(base, "978-0-13-468599-1")
        assert asyncio.iscoroutine(pending)  # nothing has run yet
        cover = await pending                # now it runs, and we wait
        print(cover)
        assert cover["isbn"] == "978-0-13-468599-1"


asyncio.run(main())

Native coroutines are the recommended style. They are faster than the decorated generator style described at the end of the page and they support async for and async with. The RequestHandler methods (get, post, prepare, on_finish…​) may be declared async def, and Tornado awaits them for you; that is the normal way to write handlers in Applications & Request Handlers. The usual application entry point is asyncio.run(main()), as shown above; the older IOLoop.current().run_sync(main) still works (and takes an optional timeout) and appears in plenty of existing code.

What a Coroutine Must Not Do

await only helps when what you wait on is itself non-blocking. A coroutine that calls time.sleep(), a synchronous database driver, requests.get() or a CPU-heavy function holds the whole event loop for the entire call, and every other connection in the process stalls with it. Two things fix that:

  • use the asynchronous equivalent (gen.sleep or asyncio.sleep instead of time.sleep, AsyncHTTPClient or HTTPX instead of requests, an async database driver);

  • if there is no async equivalent, move the call to a thread or process pool with IOLoop.run_in_executor or asyncio.to_thread, shown in Mixing asyncio Libraries below, and await the result.

Asynchronous I/O & the Event Loop explains the event loop and what blocks it in detail.

Running Coroutines in Parallel

Awaiting coroutines one after the other adds their waiting times. Awaiting them together overlaps the waiting, so the total is the slowest one, as the figure shows. Tornado offers tornado.gen.multi, asyncio offers asyncio.gather and, on Python 3.11 and later, asyncio.TaskGroup.

Three timelines: lookups awaited one by one take the sum of their times, lookups awaited with gather or gen.multi take only the slowest, and with_timeout raises TimeoutError in the caller while the slow lookup keeps running
Figure 1. Awaiting three 200 ms lookups sequentially, in parallel, and in parallel with a timeout that the slowest one exceeds.
ISBNS = ["978-0-13-468599-1", "978-1-49-205067-6", "978-1-09-811955-4", "978-1-61-729409-4"]


async def main():
    async with fake_metadata_service() as base:
        started = time.perf_counter()
        sequential = [await fetch_cover(base, isbn) for isbn in ISBNS]
        t_sequential = time.perf_counter() - started

        started = time.perf_counter()
        gathered = await asyncio.gather(*(fetch_cover(base, isbn) for isbn in ISBNS))
        t_gather = time.perf_counter() - started

        listed = await gen.multi([fetch_cover(base, isbn) for isbn in ISBNS])
        keyed = await gen.multi({isbn: fetch_cover(base, isbn) for isbn in ISBNS})

        print(f"sequential {t_sequential:.2f}s, gather {t_gather:.2f}s")
        assert t_gather < t_sequential / 2
        assert sequential == gathered == listed          # same order as the input
        assert list(keyed) == ISBNS                      # same keys as the input


asyncio.run(main())

gen.multi accepts a list (result: a list in the same order) or a dict (result: a dict with the same keys), which makes it convenient for "fetch these named things together". asyncio.gather takes the awaitables as separate arguments and returns a list.

The two differ in how they handle failure and cancellation:

Situation tornado.gen.multi asyncio.gather

One child raises

Raises the first exception; the others are logged unless their type is in quiet_exceptions

Raises the first exception by default; with return_exceptions=True the exceptions are returned in the result list instead

The returned future is cancelled

The children are not cancelled

The children are cancelled

Input shapes

A list or a dict of awaitables

Separate awaitable arguments

async def main():
    async with fake_metadata_service() as base:
        # gather(return_exceptions=True): inspect each outcome, keep the good ones
        outcomes = await asyncio.gather(
            fetch_cover(base, "978-0-13-468599-1"),
            fetch_cover(base, "bad-1"),
            return_exceptions=True,
        )
        assert outcomes[0]["isbn"] == "978-0-13-468599-1"
        assert isinstance(outcomes[1], httpclient.HTTPClientError)
        assert outcomes[1].code == 502

        # gen.multi: the first failure is raised; quiet_exceptions silences the
        # log line for the others
        try:
            await gen.multi(
                [fetch_cover(base, "bad-1"), fetch_cover(base, "bad-2")],
                quiet_exceptions=(httpclient.HTTPClientError,),
            )
        except httpclient.HTTPClientError as error:
            print("first failure:", error.code, error.message)


asyncio.run(main())

On Python 3.11 and later, asyncio.TaskGroup is the structured alternative: it starts tasks, waits for all of them at the end of the async with block, and cancels the survivors if one fails, raising an ExceptionGroup. It works with Tornado coroutines unchanged, because they are ordinary asyncio coroutines.

async def main():
    async with fake_metadata_service() as base:
        async with asyncio.TaskGroup() as group:
            tasks = [group.create_task(fetch_cover(base, isbn)) for isbn in ISBNS]
        covers = [task.result() for task in tasks]
        assert [c["isbn"] for c in covers] == ISBNS

        try:
            async with asyncio.TaskGroup() as group:
                group.create_task(fetch_cover(base, "bad-1"))
                group.create_task(fetch_cover(base, "978-0-13-468599-1", delay=1.0))
        except* httpclient.HTTPClientError as errors:
            print("task group failed:", len(errors.exceptions), "error(s)")


asyncio.run(main())

Parallelism here means overlapping waits. All the coroutines still run on one thread, so a CPU-bound coroutine does not become faster by being gathered. And parallel is not unbounded: firing a thousand fetches at once opens a thousand connections to the metadata service. AsyncHTTPClient queues requests beyond its max_clients setting, and for your own work the bounded worker pool in Queues & Locks is the tool.

Timeouts

A call to an external service must have a deadline, otherwise one stuck dependency pins every request that waits on it. tornado.gen.with_timeout(timeout, future) returns a future that raises asyncio.TimeoutError (the standard TimeoutError on Python 3.11 and later) if the wrapped awaitable is not done in time.

Three details matter:

  • timeout is not a plain number of seconds. It accepts what IOLoop.add_timeout accepts: a datetime.timedelta, or an absolute deadline on the IOLoop.time() clock. A bare 0.3 is a deadline 0.3 seconds after the clock’s origin, which is long in the past, so it times out immediately. Always pass datetime.timedelta(seconds=...).

  • The wrapped awaitable is not cancelled when the timeout expires. It keeps running in the background and can be awaited again later; asyncio.wait_for is similar but does cancel what it wraps. If the awaited work is expensive and nobody will use its result, cancel it yourself.

  • If the abandoned work fails afterwards, the exception is logged (so it is not silently lost) unless it is asyncio.CancelledError or one of the types in quiet_exceptions.

async def main():
    async with fake_metadata_service() as base:
        # 1. with_timeout leaves the slow lookup running
        slow = asyncio.ensure_future(fetch_cover(base, "978-slow", delay=0.6))
        try:
            await gen.with_timeout(datetime.timedelta(seconds=0.2), slow)
        except asyncio.TimeoutError:
            print("gave up waiting")
        assert not slow.done() and not slow.cancelled()
        late = await slow                      # still usable
        assert late["isbn"] == "978-slow"

        # 2. asyncio.wait_for cancels what it wraps
        slow = asyncio.ensure_future(fetch_cover(base, "978-slow", delay=0.6))
        try:
            await asyncio.wait_for(slow, timeout=0.2)
        except asyncio.TimeoutError:
            pass
        await asyncio.sleep(0)
        assert slow.cancelled()

        # 3. asyncio.timeout (Python 3.11+) also cancels, and bounds a whole block
        try:
            async with asyncio.timeout(0.2):
                await fetch_cover(base, "978-slow", delay=0.6)
        except TimeoutError:
            print("block timed out")

        # 4. the pitfall: a bare number is an absolute deadline in the past
        try:
            await gen.with_timeout(5, fetch_cover(base, "978-fast", delay=0.0))
        except asyncio.TimeoutError:
            print("a bare 5 timed out immediately")


asyncio.run(main())

The AsyncHTTPClient also has its own connect_timeout and request_timeout request options; they are the first line of defence for HTTP calls and are covered in Async HTTP Client. with_timeout is for bounding anything else, or a whole group of calls. Tornado 6.6 (upcoming) changes how a timeout of zero is read in tornado.locks and tornado.queues: it will mean "return or raise immediately" instead of "wait forever", so never pass 0 to mean "no timeout"; use None.

Results as They Arrive: WaitIterator

gen.multi returns only when all children are done and loses every result if one fails. When the page can render whatever cover lookups finished, or when you want to process results in completion order, use gen.WaitIterator. It takes futures (not bare coroutines, so wrap them with asyncio.ensure_future), either positionally or by keyword, and hands back results in the order they complete. current_index and current_future say which input produced the latest result, and the keyword names become the indexes when you pass keywords.

async def main():
    async with fake_metadata_service() as base:
        futures = {
            "slow": asyncio.ensure_future(fetch_cover(base, "978-slow", delay=0.30)),
            "bad": asyncio.ensure_future(fetch_cover(base, "bad-1", delay=0.10)),
            "mid": asyncio.ensure_future(fetch_cover(base, "978-mid", delay=0.20)),
        }
        iterator = gen.WaitIterator(**futures)
        arrived = []
        while not iterator.done():
            try:
                cover = await iterator.next()
            except httpclient.HTTPClientError as error:
                arrived.append((iterator.current_index, f"error {error.code}"))
            else:
                arrived.append((iterator.current_index, cover["isbn"]))
        print(arrived)
        assert [name for name, _ in arrived] == ["bad", "mid", "slow"]


asyncio.run(main())

WaitIterator also implements async for, which is shorter but aborts the whole iteration at the first exception, so the explicit while not iterator.done() loop above is the one to use when individual failures are expected.

async def main():
    async with fake_metadata_service() as base:
        futures = [
            asyncio.ensure_future(fetch_cover(base, "978-b", delay=0.2)),
            asyncio.ensure_future(fetch_cover(base, "978-a", delay=0.1)),
        ]
        isbns = [cover["isbn"] async for cover in gen.WaitIterator(*futures)]
        assert isbns == ["978-a", "978-b"]


asyncio.run(main())

Background Work

Not every coroutine belongs to a request. A handler may want to kick off "refresh the cover cache" and return without waiting, and the application may want a job that runs every few minutes. Tornado gives two tools.

Fire and Forget with spawn_callback

IOLoop.spawn_callback(callback, *args, **kwargs) schedules a function (or coroutine function) to run on the next loop iteration and does not return anything to wait on. Since Tornado 6.0 it is equivalent to IOLoop.add_callback. What makes it better than a bare asyncio.create_task for background coroutines is the error handling: when the callback returns an awaitable, the IOLoop watches it and logs an uncaught exception to the tornado.application logger. Call it from code that already runs on the loop; from another thread the safe method is add_callback.

class ListHandler(logging.Handler):
    def __init__(self):
        super().__init__()
        self.messages = []

    def emit(self, record):
        self.messages.append(record.getMessage())


async def refresh_covers():
    await gen.sleep(0.01)
    raise RuntimeError("cover refresh failed")


async def main():
    capture = ListHandler()
    app_log = logging.getLogger("tornado.application")
    app_log.addHandler(capture)
    try:
        ioloop.IOLoop.current().spawn_callback(refresh_covers)
        await gen.sleep(0.1)
    finally:
        app_log.removeHandler(capture)
    print(capture.messages)
    assert any("Exception in callback" in message for message in capture.messages)


asyncio.run(main())

Two limits apply. The coroutine is not tied to the request that started it: it outlives the response, so it must not touch self of a finished handler (its request, write() and so on). And nothing waits for it at shutdown. If it matters that the work completes, put it on a queue with a worker instead.

Periodic Work with PeriodicCallback

PeriodicCallback(callback, callback_time, jitter=0) calls a function or coroutine function repeatedly. Note the unit: callback_time is in milliseconds when it is a number (unlike most Tornado timing arguments, which use seconds), or a datetime.timedelta (accepted since 6.2, and clearer). The callback must be started explicitly with start() once an event loop is running, and stopped with stop().

Parameter or behaviour Meaning

callback_time

Interval in milliseconds, or a timedelta (6.2+). Must be positive

jitter

Randomises each interval within a window of jitter * callback_time centred on the nominal interval; 0.1 allows 10 percent variation. Use it so that many processes started together do not hit a shared service at the same instant (the thundering herd)

Slow callbacks

If a run takes longer than the interval, the missed ticks are skipped to get back on schedule. Since 6.2 this also applies to coroutines; before that, coroutine callbacks were fire-and-forget and could pile up

Exceptions

Logged to tornado.application and the schedule continues

async def main():
    ticks = []

    async def refresh_cache():
        ticks.append(time.perf_counter())
        await gen.sleep(0.01)

    refresher = ioloop.PeriodicCallback(
        refresh_cache, datetime.timedelta(milliseconds=50), jitter=0.2
    )
    refresher.start()
    await gen.sleep(0.4)
    refresher.stop()
    count = len(ticks)
    print("ticks:", count)
    assert 4 <= count <= 12
    assert not refresher.is_running()
    await gen.sleep(0.15)
    assert len(ticks) == count   # stopped means stopped


asyncio.run(main())

The user’s guide takes a different route for background loops: write an ordinary while True: coroutine that does its work and then awaits gen.sleep(...), and start it once with spawn_callback. The two have different timing: the PeriodicCallback measures the period from the start of one run to the start of the next, while the sleep loop waits the sleep time after each run. Use PeriodicCallback for a steady rate and the loop when each run must finish before the pause begins (or when you want a place to catch exceptions and decide to stop). Either way, keep the object (or cancel the task) so shutdown code can stop it.

Coroutines That Nobody Awaits

The most common coroutine bug in Tornado code is calling an async def function and forgetting await. The call returns a coroutine object, nothing runs, and Python emits a RuntimeWarning ("coroutine …​ was never awaited") that is easy to miss in logs. The handler returns as if everything worked.

The second form is subtler: the coroutine is started, as a task, but nothing observes the outcome. An exception inside it is stored in the task and, at best, reported by asyncio when the task object is garbage collected ("Task exception was never retrieved"), long after the request that caused it. A task that nothing refers to can also be garbage collected before it finishes, because the event loop only keeps weak references to tasks.

import gc
import warnings


async def save_review(isbn):
    raise RuntimeError("database offline")


async def main():
    # 1. Forgotten await: nothing runs, a RuntimeWarning is the only trace
    with warnings.catch_warnings(record=True) as caught:
        warnings.simplefilter("always")
        save_review("978-0-13-468599-1")   # BUG: no await
        gc.collect()
    print([str(w.message) for w in caught])
    assert any("never awaited" in str(w.message) for w in caught)

    # 2. Task nobody watches: the error surfaces only through the asyncio loop's
    #    exception handler, when the task is garbage collected
    reported = []
    loop = asyncio.get_running_loop()
    loop.set_exception_handler(lambda loop, context: reported.append(context["message"]))
    asyncio.create_task(save_review("978-0-13-468599-1"))   # BUG: reference dropped
    await asyncio.sleep(0.05)
    gc.collect()
    print(reported)
    assert any("never retrieved" in message for message in reported)


asyncio.run(main())

There are three correct ways to run work in the background:

  • await it (or gather it): the request waits and sees the error.

  • IOLoop.spawn_callback(coroutine_function): the error is logged for you, as shown above.

  • Keep your own reference and attach a done callback that handles the error, which is the pattern recommended by the asyncio documentation for create_task.

background_tasks = set()


def run_in_background(coroutine):
    task = asyncio.ensure_future(coroutine)
    background_tasks.add(task)          # strong reference until it is done

    def finished(done):
        background_tasks.discard(done)
        if not done.cancelled() and done.exception() is not None:
            logging.getLogger("bookshelf").error(
                "background task failed", exc_info=done.exception()
            )

    task.add_done_callback(finished)
    return task


async def main():
    task = run_in_background(save_review("978-0-13-468599-1"))
    await asyncio.sleep(0.05)
    assert task.done() and not background_tasks


asyncio.run(main())

Do not place a try/except around the call of a coroutine function and expect it to catch the coroutine’s exceptions: the exception is raised when the coroutine is awaited, not when the coroutine object is created.

Mixing asyncio Libraries

Because the IOLoop runs on the asyncio loop, any asyncio-based library (an async database driver, redis.asyncio, HTTPX, aiohttp’s client, asyncio.Queue, asyncio.Lock) works inside Tornado handlers with no adapter. The reverse also holds: Tornado’s AsyncHTTPClient and gen.sleep can be awaited from asyncio code. The only requirement is that everything uses the same running loop.

async def main():
    io_loop = ioloop.IOLoop.current()
    running = asyncio.get_running_loop()
    assert io_loop.asyncio_loop is running           # one loop, two names
    assert isinstance(gen.sleep(0), asyncio.Future)  # a Tornado Future is an asyncio Future
    await asyncio.gather(gen.sleep(0.01), asyncio.sleep(0.01))   # mixed vocabularies


asyncio.run(main())

Pitfalls when mixing:

  • Never start a second loop inside a coroutine. asyncio.run() raises RuntimeError when a loop is already running in the thread, and loop.run_until_complete() on the same loop is equally wrong. Await the coroutine instead.

  • Libraries bound to another loop. A client object (a connection pool, an HTTP session) created in one loop cannot be used from another. Create such objects inside the loop that uses them: in main() after the loop has started, not at import time.

  • Threads. Tornado code is not thread-safe, and IOLoop.add_callback is the only method you may call from another thread (it is how a worker thread hands a result back). To call blocking code from a coroutine, use IOLoop.run_in_executor (default executor if None is passed, as in tornado.ioloop) or asyncio.to_thread.

  • Windows. Python’s default event loop on Windows is the proactor loop, which Tornado does not use natively; Tornado emulates a selector loop with a helper thread. Windows is not a recommended production platform for Tornado.

import functools
import hashlib


def hash_password(password: str, salt: bytes) -> str:
    # CPU-heavy work belongs off the loop; scrypt is deliberately slow
    return hashlib.scrypt(password.encode(), salt=salt, n=2**14, r=8, p=1).hex()


async def main():
    io_loop = ioloop.IOLoop.current()
    salt = b"demo-salt-0123456"      # demo only: use os.urandom(16) per user
    # run_in_executor: Tornado's API; pass keyword arguments with functools.partial
    via_tornado = await io_loop.run_in_executor(
        None, functools.partial(hash_password, "correct horse", salt=salt)
    )
    # asyncio.to_thread: the standard library's equivalent (Python 3.9+)
    via_asyncio = await asyncio.to_thread(hash_password, "correct horse", salt)
    assert via_tornado == via_asyncio


asyncio.run(main())

The salt in the example is a literal only to keep the output deterministic; real code generates a random salt per password with os.urandom and stores it with the hash. Authentication & Signed Cookies covers password hashing properly.

A Handler That Awaits in Parallel

The pieces combine in a request handler. The Bookshelf Live "book detail" endpoint needs the cover metadata and a reviews summary, and both come from other services. The handler awaits them together and bounds the whole thing with a timeout, returning partial data if the cover service is slow. The test uses AsyncHTTPTestCase, whose fetch() is a coroutine, so a test method is itself async def.

import unittest

from tornado.testing import AsyncHTTPTestCase, gen_test


class ReviewSummaryHandler(web.RequestHandler):
    async def get(self, isbn):
        self.write({"isbn": isbn, "reviews": 42, "average": 4.6})


class BookDetailHandler(web.RequestHandler):
    async def get(self, isbn):
        base = self.settings["metadata_url"]
        client = httpclient.AsyncHTTPClient()
        cover = asyncio.ensure_future(client.fetch(f"{base}/covers/{isbn}"))
        reviews = asyncio.ensure_future(client.fetch(f"{base}/reviews/{isbn}"))
        detail = {"isbn": isbn}
        try:
            reviews_response = await reviews
            detail["reviews"] = json_decode(reviews_response.body)
            cover_response = await gen.with_timeout(datetime.timedelta(seconds=0.5), cover)
            detail["cover"] = json_decode(cover_response.body)
        except asyncio.TimeoutError:
            detail["cover"] = None      # degrade instead of failing the page
        self.write(detail)


class BookDetailTest(AsyncHTTPTestCase):
    def get_app(self):
        return web.Application(
            [
                (r"/meta/covers/([^/]+)", CoverMetadataHandler),
                (r"/meta/reviews/([^/]+)", ReviewSummaryHandler),
                (r"/books/([^/]+)", BookDetailHandler),
            ],
        )

    def setUp(self):
        super().setUp()
        self._app.settings["metadata_url"] = self.get_url("/meta")

    @gen_test
    async def test_detail_combines_both_sources(self):
        response = await self.http_client.fetch(self.get_url("/books/978-0-13-468599-1"))
        body = json_decode(response.body)
        assert body["cover"]["cover_url"].endswith("978-0-13-468599-1.jpg")
        assert body["reviews"]["reviews"] == 42

    @gen_test
    async def test_slow_cover_degrades(self):
        response = await self.http_client.fetch(self.get_url("/books/slow-1"))
        body = json_decode(response.body)
        assert body["cover"] is None          # the page still renders
        assert body["reviews"]["reviews"] == 42


suite = unittest.defaultTestLoader.loadTestsFromTestCase(BookDetailTest)
result = unittest.TextTestRunner(verbosity=0).run(suite)
assert result.wasSuccessful(), result

Legacy Style: @gen.coroutine, yield and gen.Return

Before Python 3.5 introduced async def, Tornado coroutines were generators: a function decorated with @tornado.gen.coroutine that used yield where today you write await. You will meet this style in older tutorials, books and codebases (it was the only style in Tornado 3 and 4), and it still works in Tornado 6.5.

A decorated coroutine returns a Future, can be awaited from a native coroutine, and can itself yield native coroutines, so a codebase can be migrated one function at a time. The style cannot use async for or async with, is slower, and has sharper edges: because it started life before return was allowed with a value inside generators, a value was returned by raising gen.Return(value). Since Python 3.3 a plain return value works too, and gen.Return is only needed for code that must run on Python 2.

@gen.coroutine
def legacy_fetch_cover(base_url, isbn):
    response = yield httpclient.AsyncHTTPClient().fetch(f"{base_url}/covers/{isbn}")
    raise gen.Return(json_decode(response.body))      # or: return json_decode(...)


@gen.coroutine
def legacy_parallel(base_url, isbns):
    # a list yielded from a gen.coroutine means "wait for all of them"
    covers = yield [legacy_fetch_cover(base_url, isbn) for isbn in isbns]
    # a dict works too
    named = yield {isbn: legacy_fetch_cover(base_url, isbn) for isbn in isbns}
    raise gen.Return((covers, named))


async def modern(base_url, isbns):
    covers = await gen.multi([fetch_cover(base_url, isbn) for isbn in isbns])
    named = await gen.multi({isbn: fetch_cover(base_url, isbn) for isbn in isbns})
    return covers, named


async def main():
    async with fake_metadata_service() as base:
        assert gen.is_coroutine_function(legacy_fetch_cover)
        legacy = await legacy_parallel(base, ISBNS)       # native code awaits legacy code
        current = await modern(base, ISBNS)
        assert legacy == current


asyncio.run(main())

A decorated function runs its body when called, up to the first yield, and stores any exception in the returned Future. The tornado.gen.coroutine documentation warns that an exception is easy to miss unless the future is yielded, passed to IOLoop.add_future or run with IOLoop.run_sync, which is the legacy form of the un-awaited coroutine problem above.

Translation Table

Legacy Current Notes

@gen.coroutine on a generator function

async def

Handlers, prepare, helpers: all can be async def

response = yield future

response = await future

Any awaitable

a, b = yield [f1, f2]

a, b = await gen.multi([f1, f2]) or asyncio.gather(f1, f2)

Native coroutines must call multi explicitly: only the generator runner converts a yielded list for you

d = yield {"x": f1, "y": f2}

d = await gen.multi({"x": f1, "y": f2})

asyncio.gather has no dict form

raise gen.Return(value)

return value

gen.Return still works

yield gen.sleep(1)

await gen.sleep(1) or await asyncio.sleep(1)

Both non-blocking

yield gen.moment

await asyncio.sleep(0)

Let the loop run other work once

yield gen.maybe_future(x)

await x, or asyncio.ensure_future(x)

maybe_future is deprecated (since 4.3)

gen.multi_future, gen.Multi

gen.multi

multi_future deprecated since 4.3

yield gen.Task(func), @gen.engine, YieldPoint

Removed in 6.0: wrap the callback in a Future or use an awaitable API

Callback-based APIs are gone

callback= arguments

Await the returned Future

Removed everywhere in 6.0

@web.asynchronous

Not needed: async def handlers

Removed in 6.0

@gen_test on a generator test method that uses yield

@gen_test on an async def test method

See Testing

IOLoop.instance(), IOLoop.current().start()

asyncio.run(main())

See Getting Started

Deprecated asyncio Helpers

The module tornado.platform.asyncio was the bridge between the two worlds when Tornado 3.2 added optional asyncio support. Since Tornado 5.0 the bridge is installed automatically, and most of the module is documentation of how things used to be wired. What to recognise and what to do about each:

Name Status Replace with

to_tornado_future(f)

Deprecated since 5.0; returns its argument

Use the asyncio future directly

to_asyncio_future(f)

Deprecated since 5.0; equivalent to tornado.gen.convert_yielded

asyncio.ensure_future(f)

AsyncIOMainLoop, AsyncIOLoop

Deprecated since 5.0; used automatically

Nothing: asyncio.run() and IOLoop.current() do it

AnyThreadEventLoopPolicy

Deprecated since 6.2; instantiating it emits a DeprecationWarning

asyncio.run() or asyncio.new_event_loop() explicitly in every thread that needs a loop

IOLoop.current().start(), IOLoop.instance(), IOLoop.install()

Legacy startup (the latter two deprecated since 5.0)

asyncio.run(main())

make_current and clear_current

Deprecated since 6.2

Rely on asyncio.run()

add_callback_from_signal

Deprecated since 6.4

Use asyncio signal handling (loop.add_signal_handler) or add_callback

import warnings

from tornado.platform import asyncio as tornado_asyncio


async def main():
    # to_asyncio_future is just convert_yielded; to_tornado_future is the identity
    future = asyncio.ensure_future(gen.sleep(0))
    assert tornado_asyncio.to_tornado_future(future) is future
    assert isinstance(tornado_asyncio.to_asyncio_future(gen.sleep(0)), asyncio.Future)

    # the "any thread" policy warns when created, because it is deprecated
    with warnings.catch_warnings(record=True) as caught:
        warnings.simplefilter("always")
        tornado_asyncio.AnyThreadEventLoopPolicy()
    assert any(issubclass(w.category, DeprecationWarning) for w in caught)


asyncio.run(main())

The new way to give a worker thread an event loop is to create one explicitly where it is needed:

import threading


def thread_main(results):
    async def work():
        await gen.sleep(0.01)
        return threading.current_thread().name

    results.append(asyncio.run(work()))     # a separate loop owned by this thread


collected = []
worker = threading.Thread(target=thread_main, args=(collected,), name="metadata-worker")
worker.start()
worker.join()
assert collected == ["metadata-worker"]

Where Older Material Is Outdated

  • Tutorials with @tornado.web.asynchronous, callback= arguments, gen.Task, gen.engine or stack_context: all removed in Tornado 6.0. The page above shows the await replacement of each.

  • Tutorials that call IOLoop.instance() and IOLoop.instance().start() or install AsyncIOMainLoop by hand: the startup was reworked in 5.0 and 6.2; use asyncio.run(main()).

  • Examples that pass io_loop= to constructors: removed in 5.0.

  • Advice to avoid await because yield is "faster": native coroutines are the faster form.

  • Code that pairs Tornado with the Motor MongoDB driver: Motor was deprecated by MongoDB in 2025 in favour of PyMongo’s native async client (Databases & Async Drivers covers it).

  • Tornado 6.6 (upcoming) requires Python 3.11 or later, which makes asyncio.TaskGroup and asyncio.timeout always available to Tornado code, and its release notes list improved handling of asyncio.CancelledError in tornado.gen, tornado.web and tornado.websocket. The zero-timeout change is described in the timeouts section. None of that changes the coroutine API on this page.

Pitfalls Summary

  • A function that is declared async def but called without await does nothing (and only warns).

  • A task whose reference is dropped may vanish and swallows its exception; keep a reference and add a done callback, or use spawn_callback.

  • gen.with_timeout(0.5, ...) is an immediate timeout: pass a datetime.timedelta. It also does not cancel the work.

  • gen.multi logs the exceptions it does not raise; asyncio.gather can return them with return_exceptions=True.

  • PeriodicCallback takes milliseconds for numbers.

  • Blocking calls (time.sleep, synchronous drivers, CPU-bound code) stop the entire process: offload them.

  • Tornado objects are not thread-safe: from another thread, only IOLoop.add_callback is allowed.

  • Do not create a second event loop inside a running one.