What’s Changed & Migration

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 (released 2026-09-15) on Python 3.13, checked on 2026-10-07. Tornado 6.6 is only available as the alpha 6.6a1 (2026-09-22) and its final notes are still dated "Oct XX, 2026", so everything listed here under 6.6, 6.7 and 7.0 is upcoming or announced and can still change; the 6.6 entries come from the development release notes, not from running code. Every example that does not say otherwise was executed against 6.5.10, through real servers bound to a loopback port or through AsyncHTTPTestCase. The running scenario is Bookshelf Live: a small service that was written for Tornado 4 or 5 and has to be brought to 6.5.

Tornado has been through two generations of API in its life. Up to 4.x it carried its own Future, its own coroutine runner (@gen.coroutine, @gen.engine, @web.asynchronous), its own context mechanism (stack_context) and callback arguments on nearly every function. Version 5.0 (2018) turned the IOLoop into a wrapper around the asyncio event loop, 5.1 deprecated the callback era, and 6.0 (2019) removed it. Since then the releases have been smaller: startup moved to asyncio.run, cookies and XSRF options were renamed or extended, request parsing got hard limits after a series of denial-of-service reports, and the 6.6 development branch introduces origin-based cross-site protection. Most tutorials, Stack Overflow answers and older books still show the pre-6.0 style, so this page is the translation table: what a snippet from those sources means today. Each topic page of this section already states the changes that touch it; this page collects them in one place and adds the upgrade procedure. The same page exists for the sibling frameworks in Flask: What’s Changed & Migration and Pyramid: What’s Changed & Migration; FastAPI’s counterpart is spread over FastAPI: Introduction & Architecture.

The Releases at a Glance

Timeline of Tornado releases: 5.0 asyncio integration, 6.0 removal of callbacks, 6.1, 6.2 asyncio.run, 6.3 signed cookie names, 6.4, 6.5 and its security patch releases, then the upcoming 6.6 and the announced 7.0
Table 1. Tornado releases that changed how application code is written
Release Date What matters to application code

5.0

2018-03-05

IOLoop always wraps the asyncio loop; tornado.concurrent.Future is asyncio.Future; io_loop= arguments removed

5.1

2018-07-12

Deprecation release: callbacks, stack_context, @asynchronous, gen.engine and friends warn

6.0

2019-03-01

Python 2 dropped; everything deprecated in 5.1 removed; type annotations added

6.1

2020-10-30

Last release for Python 3.5; Windows proactor loop supported; SHA-512 ETags

6.2

2022-07-03

Python 3.7+; asyncio.run(main()) becomes the startup pattern; client TLS verifies certificates by default

6.3

2023-04-17

Python 3.8+; get_signed_cookie and set_signed_cookie; xsrf_cookie_name; WSGIContainer(executor=)

6.4

2023-11-28

Python 3.12; add_callback_from_signal, TwitterMixin and OAuth2Mixin.authorize_redirect(client_secret=) deprecated

6.5

2025-05-15

Python 3.9+, 3.14 and experimental free-threading; CVE-2025-47287; strict header and Host validation

6.5.1 to 6.5.10

2025-05-22 to 2026-09-15

Mostly security fixes: parsing limits, cookie validation, symlinks, redirects, client body limits

6.6

upcoming (alpha 6.6a1)

Python 3.11+; cross_origin_protection and trusted_origins; zero timeouts mean "now"; header folding removed

6.7 and 7.0

announced

Removal of APIs deprecated in 6.x; cross-origin protection expected to become the default in 6.7

How Tornado Deprecates Things

Tornado announces a removal in the release notes of the version that deprecates it, and since 5.1 it also emits a DeprecationWarning when the deprecated API is used, wherever the call site can detect it. Python hides DeprecationWarning outside __main__ and test runners by default, which is why the 5.1 notes tell you to run the application with the -Wd flag or PYTHONWARNINGS=d. Some deprecations are documentation-only: the old name keeps working and nothing is printed (for example the *_secure_cookie aliases, shown below). That makes the release notes, not the warning output, the authoritative list, and it is why the checklist at the end of this page includes a text audit as well as a warnings run. Tornado also follows a rhythm of its own: removals have waited for a major version in the past (callbacks went in 6.0, TwistedResolver was announced for 7.0), but the 6.6 notes show that security concerns can bring removals forward, so the line folding that 6.5 deprecated for 7.0 is already gone in 6.6.

Tornado 5.0 and 5.1: asyncio Becomes the Loop

Tornado 5.0 (2018) is the release after which "Tornado code" and "asyncio code" can share one event loop. Everything in older tutorials that mentions Tornado’s own futures or its own loop implementations predates it.

Table 2. Changes in 5.0 and 5.1
Change What it means today

IOLoop is a wrapper around the asyncio event loop (Python 3)

IOLoop.configure and alternative loop implementations no longer matter; libraries based on asyncio mix freely with Tornado code

tornado.concurrent.Future is asyncio.Future

Futures need a running loop to be created; callbacks run on the next loop iteration; exc_info and set_exc_info are gone (use tornado.concurrent.future_set_exc_info)

@gen.coroutine runs on asyncio.Task

Cancellation works; the coroutine runner of 4.x is gone

io_loop= arguments removed from every constructor

Use IOLoop.current(); do not pass loops around

IOLoop.instance() is a deprecated alias for IOLoop.current()

It no longer returns a process-wide singleton, so using it from another thread to reach the main loop breaks; keep your own reference and call add_callback on it

IOLoop.install and IOLoop.clear_instance deprecated; IOLoop.initialized removed

Not needed: asyncio owns the loop

IOLoop.set_blocking_signal_threshold and set_blocking_log_threshold deprecated

Run Python with PYTHONASYNCIODEBUG=1 to find blocking calls

IOLoop.run_in_executor and set_default_executor added

Awaitable way to call blocking code, since concurrent.futures.Future itself cannot be awaited

HTTPClientError added in 5.1; tornado.httpclient.HTTPError kept as an alias

The client error class is not tornado.web.HTTPError

Tornado 5.1 (2018-07-12) was the warning release for everything 6.0 removed: the callback arguments, @asynchronous, tornado.stack_context, gen.engine, gen.Task and the YieldPoint classes, return_future, the Twisted reactors and tornado.wsgi.WSGIApplication. It also gave PeriodicCallback a jitter argument and made AsyncHTTPTestCase.fetch take raise_error.

Tornado 6.0: What Was Removed

Tornado 6.0 (2019-03-01) dropped Python 2.7 and 3.4 (minimum 3.5.2) and deleted everything 5.1 had deprecated. If code from before 2019 fails to import or raises TypeError, it is one of the entries below.

Table 3. Removed in 6.0
Removed Use instead

@tornado.web.asynchronous

async def handler methods; the response ends when the method returns. Use RequestHandler.detach if you must take over the connection

callback= arguments (fetch, flush, IOStream reads and writes, auth mixins, run_on_executor)

await the returned future

tornado.stack_context (StackContext, ExceptionStackContext)

Exceptions propagate through await; contextvars for ambient state

gen.engine, gen.Task, gen.Callback, gen.Wait, gen.WaitAll, YieldPoint classes

async def and await

concurrent.return_future

async def, or asyncio.Future plus future_set_result_unless_cancelled

TornadoReactor and TwistedIOLoop

Run Twisted on the asyncio reactor, next to Tornado on the same loop

tornado.wsgi.WSGIApplication and WSGIAdapter

tornado.wsgi.WSGIContainer hosts a WSGI app inside Tornado; Tornado handlers can no longer be served by a WSGI server (see Moving Between WSGI and Tornado)

OAuthMixin._oauth_get_user

Override _oauth_get_user_future

HTTPServerRequest.write

request.connection methods; in a handler use self.write

Behaviour changed as well. WebSocketHandler.get became a coroutine (overriding it was never recommended; use prepare or open). raise_error=False on AsyncHTTPClient.fetch now only suppresses errors for completed responses with non-200 status codes, no longer connection failures. AsyncTestCase cancels pending coroutines in tearDown, so a test that expects background work to survive teardown may see asyncio.CancelledError. The default HTTP client no longer turns non-POST redirects into GET and treats 301 like 302 and 303 in that respect.

The next example shows what each removal looks like in practice. The first block is a Tornado 4 style handler, kept as text because it can no longer be imported; the second runs on 6.5.10 and proves the removals.

# norun  (Tornado 4 style, does not work on 6.x)
import tornado.web
from tornado import gen, httpclient

class CoverHandler(tornado.web.RequestHandler):
    @tornado.web.asynchronous
    @gen.engine
    def get(self, isbn):
        client = httpclient.AsyncHTTPClient()
        response = yield gen.Task(client.fetch, "http://covers.internal/%s" % isbn)
        self.write(response.body)
        self.finish()
import asyncio
import contextlib
import importlib

from tornado import gen, httpclient, httpserver, netutil, web, wsgi


@contextlib.asynccontextmanager
async def serve(app):
    """Run `app` on a free loopback port for the duration of the block."""
    sockets = netutil.bind_sockets(0, "127.0.0.1")
    server = httpserver.HTTPServer(app)
    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()


class CoverHandler(web.RequestHandler):
    async def get(self, isbn):
        client = httpclient.AsyncHTTPClient()
        response = await client.fetch(self.application.settings["covers_url"] + "/" + isbn)
        self.write(response.body)


class FakeCovers(web.RequestHandler):
    def get(self, isbn):
        self.write(f"cover-of-{isbn}")


async def main():
    covers = web.Application([(r"/(\d+)", FakeCovers)])
    async with serve(covers) as covers_url:
        app = web.Application([(r"/covers/(\d+)", CoverHandler)], covers_url=covers_url)
        async with serve(app) as base:
            response = await httpclient.AsyncHTTPClient().fetch(base + "/covers/9781")
            print(response.body.decode())
            # the 4.x idioms are gone
            try:
                await httpclient.AsyncHTTPClient().fetch(base + "/covers/9781", callback=print)
            except TypeError as exc:
                print("callback=:", exc)


asyncio.run(main())
print("web.asynchronous:", hasattr(web, "asynchronous"))
print("gen.engine:", hasattr(gen, "engine"), "gen.Task:", hasattr(gen, "Task"))
print("wsgi.WSGIApplication:", hasattr(wsgi, "WSGIApplication"))
try:
    importlib.import_module("tornado.stack_context")
except ModuleNotFoundError as exc:
    print("stack_context:", exc)

The callback= call fails with a TypeError raised by the request constructor, because the unknown keyword is passed on to HTTPRequest. A migration that leaves one forgotten callback= argument therefore fails loudly on the first request that reaches it, which is better than the silent failure mode of 5.1 and earlier.

Tornado 6.1 and 6.2: Starting the Loop the Modern Way

Tornado 6.1 (2020-10-30) was the last release for Python 3.5 and the first with proper Windows support through asyncio’s proactor loop: Tornado now runs a selector in a second thread when it is given a proactor loop, so you no longer have to select the selector loop by hand (Unix remains the supported production platform). Its other visible changes: a User-Agent of Tornado/$VERSION is sent by default, request_timeout and connect_timeout may be zero to disable the timeout, default ETags use SHA-512 instead of MD5, and a 303 redirect always becomes a GET in simple_httpclient.

Tornado 6.2 (2022-07-03) is the release that changed how an application starts. Python 3.10 began deprecating asyncio.get_event_loop() when no loop is running, which invalidated the pattern that Tornado’s documentation had used for a decade: build an Application, call listen, then IOLoop.current().start() at module level. The recommended pattern is now to start the loop with asyncio.run and to create servers inside the coroutine.

Table 4. Changes in 6.2
Change Consequence

Recommended startup is asyncio.run(main())

Create the Application and call listen inside main, where a loop is running; IOLoop.current().start() is the legacy form

IOLoop constructor deprecated unless make_current=False

Use IOLoop.current() inside a running loop (6.3 reverted the deprecation of calling the constructor without make_current)

IOLoop.make_current and IOLoop.clear_current deprecated

Start the loop first; the concept of "current but not running" goes away

AnyThreadEventLoopPolicy deprecated

Create loops in threads explicitly with asyncio.run or asyncio.new_event_loop

Multi-process TCPServer.bind and start deprecated

bind_sockets and fork_processes before the loop, then add_sockets in each child (see TCP Servers, IOStream & Low-Level Networking)

AsyncTestCase and AsyncHTTPTestCase deprecated

Reverted in 6.3: they are supported again; unittest.IsolatedAsyncioTestCase is an alternative

TwistedResolver and CaresResolver deprecated, removal announced for 7.0

The default resolver uses a thread pool; TwistedResolver was already deleted in 6.5, CaresResolver stays deprecated

Python 3.7+; client-side SSLIOStream verifies certificates and host names by default

Code that relied on unverified TLS must now say so explicitly

PeriodicCallback understands coroutines and accepts timedelta

A slow callback no longer starts overlapping copies

max_body_size=0 allowed

Reject any non-empty body for an endpoint that takes none

Startup is the most common change that tutorials get wrong, so here are both forms. The legacy form is kept as text.

# norun  (legacy startup: works on old versions, warns or breaks on current Python)
import tornado.ioloop
import tornado.web

app = tornado.web.Application([(r"/", MainHandler)])
app.listen(8888)
tornado.ioloop.IOLoop.current().start()
import asyncio

from tornado import httpclient, httpserver, netutil, web


class Health(web.RequestHandler):
    def get(self):
        self.write({"status": "ok"})


def make_app() -> web.Application:
    return web.Application([(r"/health", Health)])


async def main() -> None:
    shutdown = asyncio.Event()
    app = make_app()
    # port 0: the operating system picks a free one; a real service reads its port from configuration
    sockets = netutil.bind_sockets(0, "127.0.0.1")
    server = httpserver.HTTPServer(app)
    server.add_sockets(sockets)
    port = sockets[0].getsockname()[1]
    response = await httpclient.AsyncHTTPClient().fetch(f"http://127.0.0.1:{port}/health")
    print(response.code, response.body.decode().strip())
    shutdown.set()          # in production a signal handler sets this event
    await shutdown.wait()   # main() then returns and asyncio.run closes the loop
    server.stop()
    await server.close_all_connections()


if __name__ == "__main__":
    asyncio.run(main())

Binding to port 0 and reading the port back is only for the example; the shutdown event shows the shape a real service has: the coroutine waits on an asyncio.Event that a signal handler (registered with asyncio.get_running_loop().add_signal_handler) sets, and returns, so asyncio.run can close the loop cleanly. add_callback_from_signal is not the replacement for this; it is deprecated (see 6.4).

The single call that tutorials used to start every process was IOLoop.current().start() at module level. On Python 3.12 and later, with no running loop, IOLoop.current() and IOLoop.make_current() are exactly the calls that produce deprecation warnings, as the next example shows.

import warnings

from tornado import ioloop


async def probe():
    loop = ioloop.IOLoop.current()
    with warnings.catch_warnings(record=True) as caught:
        warnings.simplefilter("always")
        loop.make_current()                       # deprecated since 6.2
        alias = ioloop.IOLoop.instance()          # deprecated alias, documentation only
    print("instance() is current():", alias is loop)
    for item in caught:
        print(item.category.__name__ + ":", item.message)


import asyncio
asyncio.run(probe())

IOLoop.instance() prints no warning at all in 6.5.10 even though its documentation calls it a deprecated alias for IOLoop.current(): this is one of the silent deprecations mentioned above. The make_current call does warn.

Scripts That Only Need a Coroutine

For a one-shot script (a migration, a cache warm-up) that is not a server, asyncio.run(coroutine()) is the right call too. IOLoop.current().run_sync(coroutine) still exists and still takes an optional timeout; it is convenient when the timeout matters, and it no longer needs IOLoop.current().start() before it.

import asyncio

from tornado import ioloop


async def warm_cache() -> str:
    await asyncio.sleep(0.01)
    return "warmed"


# plain script, no loop running yet: both forms work
print(asyncio.run(warm_cache()))
print(ioloop.IOLoop.current().run_sync(warm_cache, timeout=5))

Tornado 6.3: Names and Cookies

Tornado 6.3 (2023-04-17) raised the minimum Python to 3.8, added Python 3.12, and made import tornado enough to use tornado.web.RequestHandler (submodules are imported on demand). Its deprecations and additions that matter for application code:

Table 5. Changes in 6.3
Change Replacement or effect

get_secure_cookie and set_secure_cookie renamed

get_signed_cookie and set_signed_cookie (same for the related attributes and the cookie_secret mechanics); the old names remain as deprecated aliases

clear_all_cookies deprecated

Clear each known cookie with clear_cookie, which now accepts every keyword argument of set_cookie (a cookie set with a domain or path must be cleared with the same ones)

xsrf_cookie_name setting

Allows the __Host- cookie prefix: xsrf_cookie_name="__Host-xsrf" with xsrf_cookie_kwargs={"secure": True} (HTTPS only)

WSGIContainer(wsgi_app, executor=...)

Runs the WSGI application on a thread pool, so a synchronous app no longer blocks the loop

AsyncTestCase and AsyncHTTPTestCase no longer deprecated

Both stay supported; AsyncTestCase.get_new_ioloop is deprecated

GoogleOAuth2Mixin.get_google_oauth_settings

Override it to read client credentials from a secret store instead of the Application settings

tornado.testing.ExpectLog

Announced: in 7.0 it matches WARNING and above regardless of logging configuration unless level is given

Receiving large fragmented WebSocket messages

No longer quadratic in the number of fragments

Python 3.7 dropped

Minimum 3.8

The signed cookie rename is the change you will meet in code copied from older answers. The next example sets a signed cookie, reads it back with both names and checks that a tampered cookie is rejected.

import secrets
import unittest

from tornado import httputil, testing, web


class SetSession(web.RequestHandler):
    def get(self):
        # legacy: self.set_secure_cookie("shelf", "42")
        self.set_signed_cookie("shelf", "42", httponly=True, samesite="Lax")
        self.write("ok")


class ReadSession(web.RequestHandler):
    def get(self):
        current = self.get_signed_cookie("shelf")
        legacy = self.get_secure_cookie("shelf")     # alias of get_signed_cookie
        self.write({"current": current and current.decode(), "legacy": legacy and legacy.decode()})


class CookieNames(testing.AsyncHTTPTestCase):
    def get_app(self):
        return web.Application(
            [(r"/set", SetSession), (r"/read", ReadSession)],
            cookie_secret=secrets.token_hex(32),
        )

    def test_rename(self):
        issued = self.fetch("/set").headers["Set-Cookie"]
        cookie = issued.split(";")[0]
        good = self.fetch("/read", headers={"Cookie": cookie})
        self.assertEqual(good.body, b'{"current": "42", "legacy": "42"}')
        tampered = self.fetch("/read", headers={"Cookie": cookie[:-2] + "xx"})
        self.assertEqual(tampered.body, b'{"current": null, "legacy": null}')


result = unittest.TextTestRunner(verbosity=0).run(unittest.defaultTestLoader.loadTestsFromTestCase(CookieNames))
assert result.wasSuccessful()
print("cookie rename test passed")

The secret is generated with secrets.token_hex for the test only; a real deployment reads a stable cookie_secret from the environment or a secret store, because a secret that changes at every start logs every user out. Rotating it with cookie_secret and key_version is covered in Authentication & Signed Cookies. The rename is cosmetic, but it removes a long-standing confusion: "secure" cookies are signed, not encrypted, and have nothing to do with the Secure attribute.

Tornado 6.4: Deprecations Ahead of 7.0

Tornado 6.4 (2023-11-28) added Python 3.12 support and kept its feature list small.

Table 6. Changes in 6.4 (and the 6.4.1 and 6.4.2 patch releases)
Change Replacement or effect

IOLoop.add_callback_from_signal deprecated (removal in 7.0), suspected broken since 5.0

asyncio.get_running_loop().add_signal_handler

TwitterMixin deprecated

No replacement in Tornado: Twitter’s old OAuth 1.0a endpoints are what it implemented; use a maintained client or OAuth2Mixin for the provider you need (see Third-Party Authentication)

client_secret argument of OAuth2Mixin.authorize_redirect deprecated (removal in 7.0)

Never used; remove it

Autoreload works when the program runs as a directory; --until-success flag

Development convenience only; autoreload stays off in production

tornado.gen keeps strong references to the tasks it creates

Fixes "Task was destroyed but it is pending" warnings

WSGIContainer, format_timestamp and StaticFileHandler.get_modified_time accept aware datetimes

Prefer timezone-aware datetimes everywhere

Stricter Transfer-Encoding and whitespace parsing (6.4.1)

Fixes request-smuggling behind some proxies

Cookie header parsing no longer quadratic (6.4.2, CVE-2024-7592)

Upgrade: one malicious header could keep the loop busy

TwitterMixin is a good example of an API that is still importable and still looks fine in review but is end-of-life. The deprecation of the stack of third-party mixins did not stop there: OpenIdMixin (OpenID 2.0, rarely offered by identity providers any more) was deprecated in 6.5.8, and the 6.6 notes move its removal to 6.7.

Tornado 6.5: A Stricter HTTP Parser

Tornado 6.5 (2025-05-15) raised the minimum to Python 3.9, added 3.14 and an experimental build for the free-threaded 3.13 (there are no prebuilt wheels for it in this release), and fixed CVE-2025-47287: a malformed multipart/form-data request could log several warnings and amount to a denial of service; the parser now raises at the first error.

The largest group of changes is stricter HTTP parsing. They rarely matter to application code but they matter to the proxies, test clients and old devices in front of it:

  • request start lines may not contain control characters, and a method name with invalid characters is answered with 400, not 405;

  • header names are limited to the characters the RFCs permit, control characters are rejected in incoming header values, and outgoing headers follow the RFC character set (tab allowed, DEL not);

  • the Host header is restricted to permitted characters; more than one Host header, or none on a request that is not HTTP/1.0, is an error (6.5.2 turned the logging of an invalid Host into a plain 400 response, logged as a warning);

  • invalid If-Modified-Since headers are ignored instead of causing a 500;

  • % characters in the log message of HTTPError are no longer doubled when there are no arguments. Direct access to HTTPError.log_message and args is deprecated; use the new get_message();

  • WebSocket ping_timeout greater than ping_interval is no longer supported, and the default ping_timeout equals ping_interval;

  • TwistedResolver was deleted outright because RFC 8482 made it fail for most names;

  • bind_unix_socket supports the Linux abstract namespace;

  • obs-fold continuation lines in HTTP headers and carriage returns without line feeds as separators are deprecated (6.6 removes folding, see below), and the callback argument of websocket_connect is deprecated (on_message_callback is not).

6.5.2 also restored the host argument of HTTPServerRequest, whose removal without a warning in 6.5.0 had been a mistake; it is now deprecated again, with removal scheduled for 6.7.

The HTTPError change is easy to demonstrate. Error messages used to contain the placeholders and arguments of a logging call; the supported way to read the final text is get_message().

from tornado import httpclient, web

error = web.HTTPError(404, "Book %s not found", 9781)
print(error.get_message())     # fully substituted text, for logs and error bodies
print(error.status_code)

# the client-side error is a different class with a different name since 5.1
print(httpclient.HTTPError is httpclient.HTTPClientError)
print(issubclass(httpclient.HTTPClientError, web.HTTPError))

The 6.5.x Security Releases

Most of the 6.5.x releases are security releases. Together they changed defaults that applications can feel: form and upload limits, cookie arguments, redirect behaviour of the HTTP client and the static file handler. Upgrading the patch version is not optional for an internet-facing service, and the following table is the list to read when a request that used to work is suddenly refused.

Table 7. Security-relevant changes in 6.5.x
Release Date Change and what to check

6.5.3

2025-12-10

Quadratic work parsing multipart/form-data bodies (CVE-2025-67726) and repeated headers (CVE-2025-67725) fixed; header injection and XSS through the reason argument of set_status and HTTPError (CVE-2025-67724) fixed. Never put user input in a status reason. The bundled demos had an open redirect: review your own redirect(self.get_argument("next")) code

6.5.4

2025-12-15

Regression: the in operator of HTTPHeaders was case-sensitive in 6.5.3

6.5.5

2026-03-10

multipart/form-data limited to 100 parts by default (ParseMultipartConfig); domain, path and samesite of set_cookie validated for illegal characters; carriage returns refused in multipart headers

6.5.6

2026-05-27

SimpleAsyncHTTPClient strips Authorization and Cookie when following a redirect to another origin (CVE-2026-49853); it enforces max_body_size on the decompressed size (CVE-2026-49855); a C-extension over-read fixed (CVE-2026-49854); safer OpenIdMixin response parsing

6.5.7

2026-06-08

CurlAsyncHTTPClient fully resets the curl object before reuse, so client TLS settings and proxy credentials can no longer leak into the next request

6.5.8

2026-08-06

Form-encoded bodies limited to 1000 arguments (ParseUrlEncodedConfig); multipart requests with too many parts are rejected earlier; deprecated mixed-case set_cookie arguments get the same validation; OpenIdMixin deprecated

6.5.9

2026-09-14

StaticFileHandler no longer follows symlinks out of the static root (allowed_symlink_directory); curl_httpclient gains max_body_size (default 100 MB); simple_httpclient applies the limit to HTTP/1.0 style responses and rejects more than 10 100 Continue responses; the URL argument limit now also applies to query strings

6.5.10

2026-09-15

allowed_symlink_directory may be a list of directories (Jupyter compatibility)

Two of those changes are knobs you may have to turn. The body limits are global defaults, configured with tornado.httputil.set_parse_body_config, which takes a ParseBodyConfig whose multipart and urlencoded members hold the limits (the documentation calls it a stopgap until a per-application setting exists). The example parses bodies directly so that no server is needed.

from tornado import httputil

many = "&".join(f"field{i}=x" for i in range(1500)).encode()


def parse(body: bytes) -> str:
    arguments, files = {}, {}
    try:
        httputil.parse_body_arguments("application/x-www-form-urlencoded", body, arguments, files)
    except httputil.HTTPInputError as exc:
        return f"rejected: {exc}"
    return f"accepted {len(arguments)} arguments"


print(httputil.ParseUrlEncodedConfig().max_arguments, httputil.ParseMultipartConfig().max_parts)
print(parse(many))                               # 1500 > 1000 on 6.5.8 and later

httputil.set_parse_body_config(
    httputil.ParseBodyConfig(urlencoded=httputil.ParseUrlEncodedConfig(max_arguments=2000))
)
print(parse(many))
httputil.set_parse_body_config(httputil.ParseBodyConfig())       # back to the defaults

# an API that never takes file uploads can switch multipart parsing off entirely
httputil.set_parse_body_config(
    httputil.ParseBodyConfig(multipart=httputil.ParseMultipartConfig(enabled=False))
)
arguments, files = {}, {}
try:
    httputil.parse_body_arguments("multipart/form-data; boundary=foo", b"--foo--\r\n", arguments, files)
except httputil.HTTPInputError as exc:
    print("multipart:", exc)
httputil.set_parse_body_config(httputil.ParseBodyConfig())

Raise a limit only for a reason you can state (an import endpoint that really posts 3000 form fields) and keep the max_body_size of the HTTPServer low for everything else; the limits exist to keep a request from burning CPU and memory before the handler even runs.

The symlink change affects every application that serves static_path from a directory containing links, such as a deployment layout where static/current points at a release directory. Since 6.5.9 such a link is refused unless you opt in with the ancestor directory that should be trusted.

import asyncio
import pathlib
import tempfile

from tornado import httpclient, web


async def check():
    with tempfile.TemporaryDirectory() as tmp:
        root = pathlib.Path(tmp)
        static = root / "app" / "static"
        outside = root / "shared"
        static.mkdir(parents=True)
        outside.mkdir()
        (static / "plain.txt").write_text("inside the root")
        (outside / "cover.txt").write_text("outside the root")
        (static / "linked.txt").symlink_to(outside / "cover.txt")

        async def status(handler_args):
            app = web.Application([(r"/static/(.*)", web.StaticFileHandler, handler_args)])
            async with serve(app) as base:
                client = httpclient.AsyncHTTPClient()
                results = {}
                for name in ("plain.txt", "linked.txt"):
                    response = await client.fetch(f"{base}/static/{name}", raise_error=False)
                    results[name] = response.code
                return results

        print("default:", await status({"path": str(static)}))
        print("opted in:", await status({"path": str(static), "allowed_symlink_directory": [str(static), str(outside)]}))


asyncio.run(check())

The value replaces the static root as the area that files must stay inside, so it has to cover the root as well: the example passes a list with the static root and the one directory holding the shared files (a single ancestor directory works too, but trusts more). Passing only the outside directory would refuse the ordinary files. Pass the narrowest directories that work, and prefer fixing the layout (copy or mount the shared files into the static root) over widening trust. The static files page, Static Files, describes the handler’s other settings.

Tornado 6.6 (Upcoming)

Tornado 6.6.0 is not released on 2026-10-07. The notes below come from the development documentation and are subject to change. The 6.6a1 alpha exists on PyPI, but it is an alpha and the XSRF feature described next was checked in the development source only.

Table 8. Announced for 6.6
Change What to do

Python 3.11 is the minimum; 3.9 and 3.10 dropped; Python 3.15 supported

Raise the interpreter in your images and CI before upgrading Tornado; pip on 3.10 will keep resolving 6.5.x

Free-threaded interpreters supported, with prebuilt wheels for 3.14t and 3.15t; Windows on ARM; wheels for riscv64, ppc64le and s390x (experimental); PyPy 8.0

Informational

cross_origin_protection application setting; trusted_origins setting; RequestHandler.check_trusted_origin

New, simpler defence against cross-site request forgery that inspects the Sec-Fetch-Site and Origin headers; defaults to off in 6.6 and expected to become the default in 6.7. See XSRF & Cross-Origin Protection

WebSocketHandler.check_origin is not called for requests identified as same-origin; the default implementation calls check_trusted_origin

If you overrode check_origin to be stricter, same-origin connections will no longer reach it

Zero timeouts mean "now" in Queue.get, Queue.put, Queue.join, Condition.wait, Event.wait, Semaphore.acquire and Lock.acquire

Replace a timeout=0 that meant "wait forever" with None

Line folding in HTTP headers is no longer supported

Was deprecated in 6.5 for 7.0; removed sooner to avoid a denial-of-service risk. Check old proxies and devices

asyncio.CancelledError handled correctly in gen, handlers, WebSocket loops and chain_future

Cancelled requests no longer leave connections hanging

raise HTTPError(204) no longer attempts to send a body; protocol-relative redirects through RedirectHandler refused; max_age of set_cookie can no longer inject attributes; malformed signed values with valid signatures are ignored

Security hardening; no code change needed

SimpleAsyncHTTPClient: multi-member gzip decoded correctly, trailing garbage is an error; streaming_callback may be a coroutine

Informational

CurlAsyncHTTPClient needs libcurl 7.81 or newer

Matches Ubuntu 22.04 LTS

Default SSLContext objects created lazily

Faster imports

HTTPServerRequest host argument deprecated (since 6.5.2), start_line becomes required in 6.7, OpenIdMixin removal moved from 7.0 to 6.7

Use headers["Host"]; avoid constructing HTTPServerRequest by hand

The zero-timeout change is the one most likely to cause a quiet bug when upgrading, so it deserves a demonstration: on 6.5.10 a timeout of zero means "wait without limit", exactly as None does. Code that wants a non-blocking attempt should use the explicit non-blocking methods, which behave the same on every version.

import asyncio
import time

from tornado import queues


async def probe():
    q = queues.Queue()
    started = time.monotonic()
    try:
        await asyncio.wait_for(q.get(timeout=0), 0.3)
    except asyncio.TimeoutError:
        print(f"6.5.10: get(timeout=0) was still waiting after {time.monotonic() - started:.1f}s")
    try:
        q.get_nowait()                      # portable: raises at once on every version
    except queues.QueueEmpty:
        print("get_nowait(): QueueEmpty, immediately")
    q.put_nowait("isbn-9781")
    print("get_nowait():", q.get_nowait())


asyncio.run(probe())

For the same reason, do not write timeout=0 anywhere in 6.5-era code. Use None for "forever" and datetime.timedelta or a deadline (loop.time() + 5) for a bound; both mean the same on 6.5 and 6.6. The 6.6 protection itself is a configuration of the application, shown here without running it because the setting does not exist in 6.5.10:

# norun  (Tornado 6.6, upcoming: the setting is unknown to 6.5.10)
import secrets

from tornado import web

app = web.Application(
    handlers,
    cross_origin_protection=True,                       # inspect Sec-Fetch-Site and Origin
    trusted_origins=["https://admin.bookshelf.example"],  # extra origins allowed to post
    cookie_secret=secrets.token_hex(32),
)

The practical plan is the one XSRF & Cross-Origin Protection recommends: stay on xsrf_cookies for ordinary requests on 6.5, add Fetch Metadata checks yourself if you need them earlier, and switch to cross_origin_protection when 6.6 is final and tested.

Announced for 6.7 and 7.0

Table 9. Removals on the horizon
API Scheduled Replacement

OpenIdMixin

6.7 (moved up from 7.0)

An OpenID Connect or OAuth2 library for the provider

HTTPServerRequest(host=), positional method, uri and version; start_line becomes required

6.7

headers["Host"] and start_line

Cross-origin protection becoming the default

expected 6.7

Set cross_origin_protection=False explicitly if you cannot adopt it yet

IOLoop.add_callback_from_signal

7.0

loop.add_signal_handler

websocket_connect(callback=)

7.0

await websocket_connect(...); on_message_callback stays

OAuth2Mixin.authorize_redirect(client_secret=)

7.0

Omit the argument

CaresResolver

7.0

The default thread-pool resolver (TwistedResolver is already deleted)

Obs-fold line folding and bare carriage returns as separators

folding removed in 6.6; the rest 7.0

Fix the proxy or device that emits them

tornado.testing.ExpectLog matching WARNING and above regardless of logging configuration

7.0

Pass level explicitly in tests

Items scheduled for 7.0 come from the 6.x deprecation notices and are plans, not guarantees. The 6.6 notes already show two of them moving.

Legacy Code You Will Meet and What to Write Instead

This is the translation table for code from older books, blog posts and Stack Overflow answers. The "since" column is the version that made the old form obsolete (not necessarily removed).

Table 10. Legacy to current
Legacy Current Since Notes

def get(self, callback=None) plus self.finish() in a callback

async def get(self) with await

6.0

callback= raises TypeError on current versions

@tornado.web.asynchronous

Plain async def handler method

6.0

Removed; the response is finished when the method returns. Streaming responses use flush and await self.flush()

@gen.engine, gen.Task, gen.Callback, gen.Wait

async def, await

6.0

Removed

@gen.coroutine with yield

async def with await

5.0

Still works in 6.5.10 and is not scheduled for removal, but it is legacy: no static typing benefits, no async for, async with

yield [future_a, future_b] or yield dict

await asyncio.gather(a, b) (or gen.multi when you need Tornado semantics)

5.0

gen.multi now accepts Sequence and Mapping (6.5)

tornado.stack_context

contextvars and exceptions through await

6.0

Removed

IOLoop.instance(), IOLoop.install()

IOLoop.current() inside a running loop, or your own reference

5.0

Documentation-only deprecation (no warning)

IOLoop.current().start() at module level

asyncio.run(main())

6.2

make_current and the constructor patterns warn

IOLoop(make_current=...), clear_current, AnyThreadEventLoopPolicy

Start the loop with asyncio.run in the thread that needs it

6.2

Removal of the "current but not running" concept is planned

PeriodicCallback(callback, ms) with blocking work

The same class with a coroutine, plus jitter; or an asyncio.create_task loop

5.1 and 6.2

6.2: coroutines supported, no overlapping copies

TCPServer.bind(...) then start(n)

bind_sockets, fork_processes, add_sockets

6.2

See TCP Servers, IOStream & Low-Level Networking

get_secure_cookie and set_secure_cookie

get_signed_cookie and set_signed_cookie

6.3

Aliases remain; "secure" never meant encrypted

clear_all_cookies()

clear_cookie(name, **same_kwargs) per cookie

6.3

Cannot be made reliable

IOLoop.add_callback_from_signal

asyncio.get_running_loop().add_signal_handler

6.4

Removal in 7.0

TwitterMixin

A maintained client library or OAuth2Mixin

6.4

Deprecated

OpenIdMixin

OpenID Connect through OAuth2Mixin or a library

6.5.8

Removal in 6.7

HTTPError.log_message and .args

HTTPError.get_message()

6.5

tornado.httpclient.HTTPError

HTTPClientError

5.1

Alias retained; do not confuse with tornado.web.HTTPError

websocket_connect(url, callback=...)

await websocket_connect(url, on_message_callback=...)

6.5

Removal in 7.0

tornado.wsgi.WSGIApplication

WSGIContainer to host a WSGI app in Tornado

6.0

Tornado handlers cannot run under Gunicorn or uWSGI any more

run_on_executor(callback=), return_future

run_in_executor or run_on_executor plus await

6.0

AsyncTestCase.stop() and .wait()

async def test_... coroutines

5.1

Deprecated; AsyncTestCase itself is supported

Motor (motor.motor_tornado)

PyMongo’s AsyncMongoClient

2025-05-14

Motor is deprecated from 2026-05-14 with critical fixes until 2027-05-14

self.render loops with blocking drivers (psycopg2, pymongo classic, requests)

Async drivers (asyncpg, SQLAlchemy asyncio, AsyncMongoClient) or run_in_executor

always

Blocking calls freeze every client of the process

@gen.coroutine and yield

@gen.coroutine is not removed and the 6.5 documentation still describes it, but it only survives for compatibility; any new code should be native coroutines. The conversion is mechanical: add async, replace yield by await, and replace raise gen.Return(value) by return value. The two forms below produce the same result, which the example checks.

import asyncio

from tornado import gen


@gen.coroutine
def total_legacy(a, b):
    yield gen.sleep(0.01)
    second = yield asyncio.sleep(0.01, result=b)
    raise gen.Return(a + second)


async def total_current(a, b):
    await gen.sleep(0.01)
    second = await asyncio.sleep(0.01, result=b)
    return a + second


async def compare():
    print(await total_legacy(1, 2), await total_current(1, 2))


asyncio.run(compare())

The page Coroutines & asyncio, covers the semantics (a missing await, gen.multi vs asyncio.gather, cancellation) in depth.

Motor and the Async MongoDB Driver

Motor was for years the only way to reach MongoDB from Tornado without blocking, and motor.motor_tornado was its Tornado flavour. MongoDB released the asynchronous API in PyMongo itself (pymongo.AsyncMongoClient, production release 2025-05-14, in PyMongo 4.13) and announced the deprecation of Motor one year later: the official Motor documentation says it is deprecated on 2026-05-14 with critical bug fixes until 2027-05-14. Some write-ups give 2025-05-14 as the deprecation date; that is the date of the replacement release, the Motor deprecation date is a year later. AsyncMongoClient is built on asyncio, so it runs on the same loop as Tornado. The connection string comes from the environment, not from the code.

import asyncio
import os

from pymongo import AsyncMongoClient


async def open_bookshelf():
    # legacy: client = motor.motor_tornado.MotorClient(uri); db = client.bookshelf
    client = AsyncMongoClient(
        os.environ.get("BOOKSHELF_MONGODB_URI", "mongodb://localhost:27017"),
        serverSelectionTimeoutMS=500,
    )
    books = client["bookshelf"]["books"]
    # a real handler would now do:  doc = await books.find_one({"isbn": isbn})
    print(type(client).__name__, books.full_name)
    await client.close()          # AsyncMongoClient.close is a coroutine


asyncio.run(open_bookshelf())

The NoSQL side of the site is in MongoDB; the migration guide from Motor is linked in the references. Other async drivers follow the same pattern for the other databases the scenario could use: asyncpg or SQLAlchemy’s asyncio extension for SQL and an asyncio Redis client for Redis. Whichever driver you choose, create the client inside the running loop (in main, not at import time) and share it through the Application settings.

Python Versions and Platforms

Table 11. Minimum Python version per release
Tornado Minimum Python Also noted

5.0

2.7.9 or 3.4

Python 2.7 and 3.4 deprecated

5.1

2.7.9 or 3.4

Last release with Python 2 support

6.0

3.5.2

6.1

3.5

Last with 3.5

6.2

3.7

6.3

3.8

3.12 supported

6.4

3.8

3.12 supported

6.5

3.9

3.14 supported; free-threaded 3.13 experimental

6.6 (upcoming)

3.11

3.15 and free-threaded 3.14t and 3.15t supported

The table is about support, not about what happens to install. pip refuses to install a release on an interpreter that is outside the version’s declared Requires-Python, so an old Docker image with Python 3.10 simply keeps resolving to 6.5.x when 6.6 comes out. Pin the upper bound explicitly while you migrate (tornado>=6.5.10,<6.6) and move the pin when your interpreter is ready. The installed package tells you what it is at run time:

import sys

import tornado

print("tornado", tornado.version, tornado.version_info)
print("python", sys.version.split()[0])

# a service that will move to 6.6 needs to fail early on an unsupported interpreter
if tornado.version_info >= (6, 6) and sys.version_info < (3, 11):
    raise RuntimeError("Tornado 6.6 needs Python 3.11 or newer")

Platform support did not change: Unix-like systems are the target. Windows has worked through asyncio’s proactor loop since 6.1, but the documentation still states that Windows is not officially supported or recommended for production use, and its scalability is significantly lower. Tornado code is not thread-safe either: the only method you may call from another thread is IOLoop.add_callback.

Moving Between WSGI and Tornado

Since 6.0 a Tornado Application cannot be run by a WSGI server: WSGIApplication and WSGIAdapter are gone, and the framework’s design (long-lived connections, one loop) never fitted WSGI’s synchronous model. The inverse direction still works. WSGIContainer hosts a WSGI application, a Flask or Django app for example, inside a Tornado HTTPServer, so a company can serve a legacy synchronous app and the new real-time endpoints from one port, with the WSGI part wrapped in a FallbackHandler. Since 6.3 WSGIContainer(app, executor=...) runs the WSGI app on a thread pool, so a slow request no longer blocks every WebSocket on the loop; the container is explicitly the right tool for incremental migration rather than for production traffic of a heavy app.

import asyncio
from concurrent.futures import ThreadPoolExecutor

from tornado import httpclient, web, wsgi


def legacy_wsgi_app(environ, start_response):
    """Stands in for a Flask app: a plain WSGI callable."""
    body = b"legacy catalogue page"
    start_response("200 OK", [("Content-Type", "text/plain"), ("Content-Length", str(len(body)))])
    return [body]


class Live(web.RequestHandler):
    async def get(self):
        self.write("live endpoint")


async def run():
    executor = ThreadPoolExecutor(max_workers=4)
    container = wsgi.WSGIContainer(legacy_wsgi_app, executor=executor)
    app = web.Application([
        (r"/live", Live),
        (r".*", web.FallbackHandler, {"fallback": container}),
    ])
    async with serve(app) as base:
        client = httpclient.AsyncHTTPClient()
        for path in ("/live", "/catalogue"):
            print(path, (await client.fetch(base + path)).body.decode())
    executor.shutdown()


asyncio.run(run())

If you are comparing frameworks rather than upgrading Tornado, the migration paths run the other way too: Flask: Async, gevent and Quart covers the choices for a Flask application that needs concurrency, and the FastAPI section shows how to mount a WSGI application in an ASGI one in FastAPI: Sub-Applications, WSGI & GraphQL. Django’s own async story is in Django: Introduction & Architecture. A Tornado service that moves its HTTP API to one of those frameworks and keeps only the WebSocket endpoints in Tornado is a common outcome, because the WebSocket and long-poll code ports least gracefully.

Testing: AsyncTestCase and IsolatedAsyncioTestCase

Tests are the part of the codebase that suffers most from the 5.x to 6.x change, because the old tests drove the loop by hand with self.stop() and self.wait() (deprecated in 5.1). In 6.5.10 you have two supported choices, and the 6.2 deprecation of AsyncTestCase was reverted in 6.3. AsyncHTTPTestCase remains the convenient choice for handler tests because it starts the application on a free port; unittest.IsolatedAsyncioTestCase is the standard-library way to test coroutines that do not need a server. Both run async def test methods.

import asyncio
import unittest

from tornado import testing, web


class Ping(web.RequestHandler):
    async def get(self):
        await asyncio.sleep(0)
        self.write("pong")


class PingTest(testing.AsyncHTTPTestCase):
    def get_app(self):
        return web.Application([(r"/ping", Ping)])

    @testing.gen_test
    async def test_ping_async(self):
        response = await self.http_client.fetch(self.get_url("/ping"))
        self.assertEqual(response.body, b"pong")

    def test_ping_sync(self):
        # fetch() is the synchronous helper; non-200 responses do not raise (raise_error defaults to False)
        self.assertEqual(self.fetch("/missing").code, 404)


class WorkerTest(unittest.IsolatedAsyncioTestCase):
    async def test_runs_on_asyncio(self):
        self.assertEqual(await asyncio.sleep(0, result=3), 3)


suite = unittest.TestSuite()
loader = unittest.defaultTestLoader
suite.addTests(loader.loadTestsFromTestCase(PingTest))
suite.addTests(loader.loadTestsFromTestCase(WorkerTest))
result = unittest.TextTestRunner(verbosity=0).run(suite)
assert result.wasSuccessful()
print("tests passed:", result.testsRun)

Upgrading from 4.x or 5.x: the Procedure

The order below keeps the application runnable at each step. The diagram shows the stages; the list after it adds the detail.

flowchart TD A[Pin current version and add tests] --> B[Upgrade to 5.1 and run with -Wd] B --> C[Replace callbacks, asynchronous, gen.engine, stack_context] C --> D[Upgrade to 6.0 then to 6.5.10] D --> E[Fix 6.x warnings: startup, signed cookies, HTTPError] E --> F[Apply security settings: limits, symlinks, cookies] F --> G[Prepare for 6.6: Python 3.11, zero timeouts, cross-origin protection]
Upgrade path from Tornado 4.x or 5.x to 6.5
  1. Write characterisation tests first: a smoke test per route through AsyncHTTPTestCase, one WebSocket round trip and one signed-cookie login. They are the only way to know that a mechanical migration did not change behaviour.

  2. Move to 5.1 (or stay on whatever you have) and run with python -Wd or PYTHONWARNINGS=d, with PYTHONASYNCIODEBUG=1 in a staging run. Everything 6.0 removes warns in 5.1.

  3. Convert, in this order: callback= arguments to await, @asynchronous handlers to async def, gen.engine and gen.Task to coroutines, stack_context to exceptions and contextvars, io_loop= arguments to nothing, Twisted reactors to the asyncio reactor, WSGIApplication to a different deployment.

  4. Upgrade to 6.5.10. Search the code for HTTPError( with log_message access, *_secure_cookie, IOLoop.instance, make_current and IOLoop.current().start; change them using the legacy table.

  5. Check the proxies and test clients in front of the service against the stricter parser: duplicate Host headers, folded headers, header names with spaces, obs-fold. Test with the exact load balancer you run in production.

  6. Review the new limits and defaults: form arguments (1000), multipart parts (100), request body size, client max_body_size, the static symlinks, and the cookie arguments you pass to set_cookie.

  7. Re-run the TLS configuration of any SSLIOStream or TCPClient you use directly (client verification is on by default since 6.2).

  8. Replace blocking drivers and Motor, one dependency at a time, in a separate change.

  9. Prepare for 6.6 and 7.0 by running the tests with warnings as errors (below), moving the interpreter to 3.11, replacing timeout=0, and planning the switch from xsrf_cookies to cross_origin_protection.

Because many deprecations print nothing at run time, the warnings run is not enough. The next example is a small audit script that scans a source tree for the legacy names in the table above. It finds the names, not the semantics, so treat its output as a to-do list.

import pathlib
import re
import tempfile

LEGACY = {
    r"web\.asynchronous|@asynchronous": "removed in 6.0: use async def",
    r"gen\.engine|gen\.Task\b|YieldPoint": "removed in 6.0: use async def",
    r"stack_context": "removed in 6.0: use contextvars",
    r"callback\s*=": "removed in 6.0: await the result",
    r"io_loop\s*=": "removed in 5.0: use IOLoop.current()",
    r"IOLoop\.instance\(|IOLoop\.install\(": "deprecated in 5.0",
    r"IOLoop\.current\(\)\.start\(": "legacy startup: use asyncio.run(main())",
    r"make_current\(|clear_current\(": "deprecated in 6.2",
    r"_secure_cookie": "renamed in 6.3: *_signed_cookie",
    r"clear_all_cookies": "deprecated in 6.3",
    r"TwitterMixin|OpenIdMixin": "deprecated: no longer maintained",
    r"add_callback_from_signal": "deprecated in 6.4: loop.add_signal_handler",
    r"\.log_message\b": "deprecated in 6.5: HTTPError.get_message()",
    r"WSGIApplication|WSGIAdapter": "removed in 6.0: use WSGIContainer",
    r"import motor|motor_tornado": "Motor is deprecated: pymongo AsyncMongoClient",
    r"timeout\s*=\s*0\b": "6.6: zero now means immediately, was forever",
}


def audit(root: pathlib.Path):
    for path in sorted(root.rglob("*.py")):
        for number, line in enumerate(path.read_text().splitlines(), 1):
            for pattern, advice in LEGACY.items():
                if re.search(pattern, line):
                    yield f"{path.name}:{number}: {line.strip()}  ->  {advice}"


with tempfile.TemporaryDirectory() as tmp:
    sample = pathlib.Path(tmp, "handlers.py")
    sample.write_text(
        "import motor.motor_tornado\n"
        "class Old(RequestHandler):\n"
        "    @tornado.web.asynchronous\n"
        "    def get(self):\n"
        "        user = self.get_secure_cookie('user')\n"
        "        item = yield queue.get(timeout=0)\n"
        "tornado.ioloop.IOLoop.current().start()\n"
    )
    for finding in audit(pathlib.Path(tmp)):
        print(finding)

Warnings as Errors in the Test Suite

Run the tests with deprecations turned into failures once, so every use of a warning API shows up as a test error. With pytest the setting lives in the project configuration; with unittest use python -W error::DeprecationWarning -m unittest. Keep it as a permanent CI job for the next version, because each Tornado release adds a handful.

# pyproject.toml
[tool.pytest.ini_options]
filterwarnings = [
    "error::DeprecationWarning",
    # third-party packages you cannot fix yet go here, with a comment and a removal date
    "ignore::DeprecationWarning:some_vendor_package.*",
]
import warnings

from tornado import ioloop


async def strict():
    loop = ioloop.IOLoop.current()
    with warnings.catch_warnings():
        warnings.simplefilter("error", DeprecationWarning)
        try:
            loop.make_current()
        except DeprecationWarning as exc:
            print("caught as an error:", exc)


import asyncio
asyncio.run(strict())

Pitfalls Summary

  • Do not trust a green deprecation-free run: several Tornado deprecations (IOLoop.instance, *_secure_cookie, the HTTPError.log_message attribute) are documentation-only. Use the release notes and a text audit.

  • Pip decides by Requires-Python: an old interpreter silently stays on an older Tornado. Pin deliberately.

  • Do not put user input in a status reason, and review any redirect that takes its target from a request argument (6.5.3).

  • Raising the form-argument or multipart limits globally enlarges every endpoint’s attack surface; keep them low and raise them for the one route only if you can state why.

  • A static directory containing symlinks stops working at 6.5.9 until allowed_symlink_directory is set; set it to the narrowest directory.

  • timeout=0 meant "forever" up to 6.5 and means "now" in 6.6.

  • A proxy that sends folded headers, duplicate Host headers or bare carriage returns will meet 400 responses on 6.5 and later.

  • Tornado handlers can no longer be mounted in WSGI servers; hosting a WSGI app inside Tornado is possible but a migration aid, not a goal.

  • Blocking drivers carried over from a Flask or Django service freeze the loop; migrate to async drivers or offload.

  • Motor is end-of-life; the async MongoDB API now lives in PyMongo.

  • Do not enable debug=True or autoreload as part of a migration experiment on a reachable server.