tornado review
Tornado 6.5.8 combines an asyncio-based web framework with an HTTP server and client, WebSockets, TCP streams, queues, subprocess helpers, and lower-level networking pieces. URL patterns dispatch to RequestHandler subclasses, while long-lived connections share an event loop. It is its own server and framework model rather than ASGI middleware. The current security release caps form-encoded bodies at 1,000 arguments, rejects oversized multipart requests earlier, applies invalid-character checks to deprecated mixed-case cookie options, and deprecates the OpenID 2.0 mixin for removal in 6.7.
Tornado 6.5.8 installed as one 2 MB package in 0.3 seconds in our sandbox, with 0 dependencies and 0 audit findings. Keep it for long-lived connections, Jupyter integrations, or an existing handler stack; start conventional APIs on ASGI unless Tornado's integrated networking solves a specific requirement.
We installed it
| Install | ✓ · 0.3s | 1 package on disk · 2 MB |
| Import | ✓ | import tornado in 0.07s · compiled extensions · py.typed · requires Python >= 3.9 |
| Known vulns | 0 | (pip-audit) |
Answers from our run
Does tornado install cleanly?
Yes. In a fresh container with an empty cache, pip install tornado finished in 0.3s, leaving 1 package and 2 MB on disk. pip-audit reported no known vulnerabilities.
What does tornado need to run?
Python >= 3.9, and a platform wheel with compiled extensions. In our run import tornado succeeded in 0.07s, and the package ships py.typed for type checkers.
tornado or fastapi: which should you use?
fastapi: Choose it for typed JSON APIs, validation, OpenAPI generation, and the ASGI server ecosystem. Tornado 6.5.8 installed as one 2 MB package in 0.3 seconds in our sandbox, with 0 dependencies and 0 audit findings.
When should you not use tornado?
A new typed JSON API needs validation, dependency injection, OpenAPI, and standard ASGI deployment; FastAPI fits that brief directly
Use it if
- A service owns many WebSocket, long-poll, streaming, or other long-lived connections
- The code extends Jupyter or another established application already built around Tornado handlers
- One dependency should provide HTTP server, client, WebSocket, TCP, and IOStream primitives
- An existing Tornado service values a stable handler model over migration to ASGI conventions
- A new typed JSON API needs validation, dependency injection, OpenAPI, and standard ASGI deployment; FastAPI fits that brief directly
- Middleware, lifespan, observability, or server tooling is standardized on ASGI
- The framework must provide ORM, migrations, authentication, forms, or an admin application
- Database and SDK calls are synchronous and cannot be replaced or put behind a bounded executor
- The team expects a fast-moving application framework rather than careful upkeep of an established networking API
Setup reality
Our Python 3.12 sandbox installed Tornado 6.5.8 in 0.3 seconds. One package occupied 2 MB, and pip-audit found 0 known vulnerabilities. Inspection reported 0 direct dependencies, a Python 3.9 minimum, Apache-2.0 licensing, compiled .so extensions, and py.typed metadata. import tornado worked in 0.07 seconds. Supported wheels avoid compilation during the ordinary install; unusual source-only platforms still need to test the extension build.
A basic deployment constructs Application, calls listen inside asyncio.run(), and keeps the coroutine alive. listen binds one process. More capacity requires separately supervised workers or Tornado's pre-fork socket tools behind a proxy. Do not fork after threads or event-loop-owned clients have started. debug enables autoreload and belongs in development. When a reverse proxy terminates TLS, enable trusted forwarding only when requests can reach the server through that trusted proxy.
Every handler on a process shares an event loop. Await database and HTTP clients; time.sleep(), requests, filesystem walks, and synchronous database work pause unrelated clients. run_in_executor can contain a legacy function, but its bounded pool becomes the concurrency ceiling. Streaming handlers should await flush() so backpressure can surface and stop work after disconnect. WebSocket origin checking defaults to same origin; cross-origin applications need a narrow explicit allow list.
Version 6.5.8 sets a default limit of 1,000 form arguments and rejects excessive multipart parts earlier in parsing. Increase the form limit only for a route with a measured resource budget. Cookie secrets belong in deployment configuration. Secure cookies are signed rather than encrypted, and get_secure_cookie returns bytes. The OpenIdMixin uses obsolete OpenID 2.0 and is scheduled for removal in 6.7, so any remaining login flow needs a replacement before that upgrade.
Patterns
Start one Tornado HTTP process start-server
import asyncio
import tornado.web
class Home(tornado.web.RequestHandler):
def get(self):
self.write('Hello')
async def main():
app = tornado.web.Application([(r'/', Home)])
app.listen(8888)
await asyncio.Event().wait()
asyncio.run(main())listen binds the socket and returns. The final await keeps the event loop alive until the application runs its shutdown path.
Make an outbound request without blocking handlers await-http-client
from tornado.httpclient import AsyncHTTPClient
class Status(tornado.web.RequestHandler):
async def get(self):
response = await AsyncHTTPClient().fetch(
'https://api.example.com/status',
request_timeout=5,
)
self.set_header('Content-Type', 'application/json')
self.write(response.body)Use the asynchronous client with a finite timeout. A requests call would pause every connection sharing this event loop.
Reject an invalid JSON body parse-json
import json
class Items(tornado.web.RequestHandler):
def post(self):
try:
data = json.loads(self.request.body)
name = data['name']
except (json.JSONDecodeError, KeyError, TypeError):
raise tornado.web.HTTPError(400, reason='invalid JSON body')
self.set_status(201)
self.write({'name': name})Tornado has no automatic schema validation. Writing a dict produces JSON, while a top-level list is rejected by write().
Read a route capture and repeated query values read-arguments
class User(tornado.web.RequestHandler):
def get(self, user_id):
view = self.get_argument('view', 'summary')
tags = self.get_arguments('tag')
self.write({'id': int(user_id), 'view': view, 'tags': tags})
app = tornado.web.Application([(r'/users/([0-9]+)', User)])Regex captures are strings. get_argument raises MissingArgumentError when no default is supplied and the value is absent.
Allow one browser origin for a WebSocket serve-websocket
import tornado.websocket
class Events(tornado.websocket.WebSocketHandler):
def check_origin(self, origin):
return origin == 'https://app.example.com'
async def on_message(self, message):
await self.write_message({'echo': message})
def on_close(self):
self.application.log_close(self.close_code)Same-origin checks are the default. Return true only for origins that should be able to open the socket.
Return JSON from handler errors write-json-errors
class ApiHandler(tornado.web.RequestHandler):
def write_error(self, status_code, **kwargs):
self.set_header('Content-Type', 'application/json')
self.finish({'error': self._reason, 'status': status_code})
class Missing(ApiHandler):
def get(self):
raise tornado.web.HTTPError(404, reason='item not found')The built-in error page is HTML. Unexpected exceptions still become status 500 and are written to the application log.
Sign an HTTPS login cookie use-secure-cookie
class Base(tornado.web.RequestHandler):
def get_current_user(self):
return self.get_secure_cookie('user')
class Login(Base):
def post(self):
self.set_secure_cookie(
'user', self.get_body_argument('user'),
secure=True, httponly=True, samesite='Lax',
)
self.redirect('/')
app = tornado.web.Application(routes, cookie_secret=os.environ['COOKIE_SECRET'])Signing detects changes but does not hide the value. get_secure_cookie returns bytes, and HTTPS deployments should set secure.
Flush each server-sent event stream-events
class Stream(tornado.web.RequestHandler):
async def get(self):
self.set_header('Content-Type', 'text/event-stream')
self.set_header('Cache-Control', 'no-cache')
for event in event_source():
self.write(f'data: {event}\n\n')
await self.flush()Awaiting flush exposes backpressure. Long streams also need cancellation and disconnect cleanup around the producer loop.
Run legacy blocking work in a shared pool offload-blocking-work
from concurrent.futures import ThreadPoolExecutor
workers = ThreadPoolExecutor(max_workers=8)
class Report(tornado.web.RequestHandler):
async def get(self):
loop = tornado.ioloop.IOLoop.current()
result = await loop.run_in_executor(workers, build_report)
self.write({'result': result})Create one bounded executor for the process. Its 8 workers are the concurrency limit for this function.
Test a handler through a real HTTP client test-handler
from tornado.testing import AsyncHTTPTestCase
class HomeTest(AsyncHTTPTestCase):
def get_app(self):
return tornado.web.Application([(r'/', Home)])
def test_home(self):
response = self.fetch('/')
self.assertEqual(response.code, 200)
self.assertEqual(response.body, b'Hello')AsyncHTTPTestCase binds a test port. The response body is bytes, matching the production client interface.
Set the process-wide form parser limit set-form-limit
from tornado.httputil import (
ParseBodyConfig, ParseUrlEncodedConfig, set_parse_body_config,
)
set_parse_body_config(
ParseBodyConfig(urlencoded=ParseUrlEncodedConfig(max_arguments=1000))
)Version 6.5.8 defaults to 1,000 form arguments. This setting is global, so raise it only after sizing the affected request path.
Stop accepting connections before shutdown prepare-graceful-stop
server = app.listen(8888)
async def shutdown():
server.stop()
await asyncio.sleep(1)
tornado.ioloop.IOLoop.current().stop()server.stop() closes listening sockets and leaves active handlers running. Production shutdown should track those requests instead of relying only on a fixed delay.
Alternatives
| Package | Registry | Pick it when |
|---|---|---|
| fastapi | PyPI | Choose it for typed JSON APIs, validation, OpenAPI generation, and the ASGI server ecosystem. |
| aiohttp | PyPI | Choose it when async client and server APIs in one package matter and function handlers fit. |
| starlette | PyPI | Choose it for a small ASGI toolkit with middleware, WebSockets, routing, and lifespan. |
| django | PyPI | Choose it when ORM, migrations, authentication, forms, and admin outweigh low-level socket control. |
More web backend guides
urllib3 · requests · ws · anyio · httpx · undici · the whole shelf →
How this guide is made: grounded in the library's documentation, release notes, changelog, and issue history, on a fixed rubric — not a hands-on install of every release. The 50 most-downloaded entries are additionally install-verified in clean containers. Corrections: contact the desk.

