Getting Started
|
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), checked on 2026-10-07. Tornado 6.5 supports
Python 3.9 and newer; the code on this page uses Python 3.10+ syntax and was executed on Python 3.13. Tornado 6.6 is
not released yet (only an alpha, 6.6a1, is on PyPI); everything described as "6.6" below is upcoming and may still
change. The page takes you from an empty directory to a running server and explains what each step does. Unlike
Flask or Django, Tornado is not a WSGI application: the framework brings its own HTTP server and runs on a single
asyncio event loop in the process that starts it, so "starting the app" means starting that loop. The app is the
first version of Bookshelf Live, the real-time scenario that grows over the following pages of this section, and
it is the Tornado counterpart of Getting Started with Flask and
Getting Started with FastAPI.
Install Tornado
Create a virtual environment first, as for any Python project. Virtual environments, package managers and
pyproject.toml are covered in the Python Reference, in
Virtual Environments & Packaging, and are not
repeated here. With the standard library:
mkdir bookshelf-live
cd bookshelf-live
python3 -m venv .venv
. .venv/bin/activate # Windows: .venv\Scripts\activate
pip install tornado
python -c "import tornado; print(tornado.version)"
Source: Tornado Web Server: Installation
With uv, which creates .venv and records the resolved versions in uv.lock:
uv init bookshelf-live --bare
cd bookshelf-live
uv add tornado
uv run python -c "import tornado; print(tornado.version)"
Source: Tornado Web Server: Installation
Both print 6.5.10 at the time of writing. tornado.version is the string and tornado.version_info the tuple.
Tornado has no mandatory runtime dependencies, which keeps the install small: the web framework, the HTTP server and
client, the template engine and the networking code are all in the one distribution. The source tarball also
contains demo applications (a chat room, a blog, a WebSocket echo and others) that are not part of the wheel; clone
the repository if you want to read them.
Optional Packages
Two optional packages replace default components. Neither is needed for anything on this page:
| Package | What it replaces | Remarks |
|---|---|---|
|
The pure-Python HTTP client behind |
Needs a libcurl of version 7.81 or newer. It is the way to get features such as proxy support in the client; it is
selected explicitly with |
|
The default thread-based DNS resolver, with |
It does not mix IPv4 and IPv6 results for unspecified address families, so it suits IPv4. The release notes announce its removal in Tornado 7.0. |
Supported Platforms
Tornado targets Unix-like systems, with the best performance on those that provide epoll (Linux), kqueue
(BSD and macOS) or /dev/poll. The official documentation says it also runs on Windows, but "is not officially
supported or recommended for production use", and the multi-process mode used in production deployments is not
available there. Develop and deploy on Linux or macOS, or inside a Linux container or WSL.
Tornado code is also not thread-safe: the only method that may be called from another thread is
IOLoop.add_callback. The pages on asynchronous I/O and on running Tornado in production return to this rule.
Source: Tornado Web Server: Platform Support
The Smallest Tornado App
The minimal application has three parts: a handler class that answers requests, an Application that maps URL
patterns to handlers, and a main coroutine that binds a port and then keeps the event loop alive.
# norun
import asyncio
import tornado.web
class MainHandler(tornado.web.RequestHandler):
def get(self):
self.write("Hello from Bookshelf Live")
def make_app():
return tornado.web.Application([
(r"/", MainHandler),
])
async def main():
app = make_app()
app.listen(8888)
await asyncio.Event().wait()
if __name__ == "__main__":
asyncio.run(main())
Source: Tornado: Running and deploying
Save it as hello.py, run python hello.py and the process stays in the foreground, serving requests until you
press Ctrl+C. Each part does one thing:
| Piece | Role |
|---|---|
|
One instance is created per request. Tornado calls the method whose name is the lowercase HTTP verb ( |
|
Appends to the output buffer. A |
|
The routing table (a list of pattern and handler pairs) plus keyword settings such as |
|
Creates an |
|
An event that nobody ever sets, so |
|
Creates the event loop, runs |
Why the Server Is Started Inside main()
Tornado 5.0 turned IOLoop into a thin wrapper over the asyncio event loop, so there is exactly one loop and the
standard library owns it. Sockets, timers and async code from Tornado and from any asyncio library then share
that one loop. Creating the server inside a coroutine guarantees that the loop it attaches to is the one that is
running. Code that calls app.listen() before any loop exists, as older tutorials do, relies on a loop being
created implicitly, which is deprecated (see Legacy Startup Forms You Will Meet).
Two consequences are worth knowing from day one:
-
Everything runs on one thread. A handler that blocks, for example with
time.sleepor a synchronous database call, stalls every other connection of the process. Asynchronous I/O & the Event Loop explains how to find and fix that. -
app.listen()binds all network interfaces by default. During development bind only the loopback interface withapp.listen(8888, address="127.0.0.1"), so that the unfinished app is not reachable from the network.
Run It and Call It
Start the server and call it from a second terminal. curl -i prints the status line and headers; the Server,
Etag and Content-Type headers come from Tornado:
python hello.py # terminal 1
curl -i http://127.0.0.1:8888/ # terminal 2
HTTP/1.1 200 OK
Server: TornadoServer/6.5.10
Content-Type: text/html; charset=UTF-8
Date: Wed, 07 Oct 2026 15:07:03 GMT
Etag: "0234a8dd20bf..."
Content-Length: 25
Hello from Bookshelf Live
Tornado adds the Etag header to every successful GET response by default and answers 304 Not Modified to a
matching If-None-Match; setting compute_etag in a handler to return None turns this off. The server also
logs one line per request to the tornado.access logger (see Command-Line Options with tornado.options).
HTTPie is a friendlier client for JSON APIs. It prints headers and the colorized body, and :8888 is its shorthand
for localhost:8888:
http :8888/
http :8888/books/2
Source: HTTPie documentation
The First Bookshelf Live
The hello app has one route and no data. Here is the first real version, kept in a single file: an in-memory book list, three routes, a regular-expression group that becomes a handler argument, a 404 for an unknown book and two command-line options. The rest of the section grows this file into a package with async database access, WebSockets and tests.
# norun
import asyncio
import tornado.web
from tornado.options import define, options, parse_command_line
define("port", default=8888, type=int, help="port to listen on")
define("debug", default=False, type=bool, help="run in debug mode (development only)")
BOOKS = {
"1": {"id": "1", "title": "Dune", "author": "Frank Herbert"},
"2": {"id": "2", "title": "Neuromancer", "author": "William Gibson"},
}
class HomeHandler(tornado.web.RequestHandler):
def get(self):
self.write({"name": "Bookshelf Live", "books": "/books"})
class BookListHandler(tornado.web.RequestHandler):
def get(self):
self.write({"books": list(BOOKS.values())})
class BookHandler(tornado.web.RequestHandler):
def get(self, book_id):
book = BOOKS.get(book_id)
if book is None:
raise tornado.web.HTTPError(404, reason="No such book")
self.write(book)
def make_app(debug=False):
return tornado.web.Application(
[
(r"/", HomeHandler),
(r"/books", BookListHandler),
(r"/books/([0-9]+)", BookHandler),
],
debug=debug,
)
async def main():
parse_command_line()
app = make_app(debug=options.debug)
app.listen(options.port, address="127.0.0.1")
print(f"Listening on http://127.0.0.1:{options.port}")
await asyncio.Event().wait()
if __name__ == "__main__":
asyncio.run(main())
Save it as bookshelf.py. Points worth noting:
-
Every capture group of the route pattern is passed to the handler method as a positional argument, always as a
str.BookHandler.getreceives"2", not2. Routing, named groups andreverse_urlhave their own page. -
raise tornado.web.HTTPError(404, reason="No such book")is the way to abort with an HTTP status. Thereasonbecomes the reason phrase on the status line. The default error page is a small HTML document; error pages for JSON APIs are the subject of Error Handling. -
A route is anchored: Tornado adds
$to a pattern that does not end with one, so/booksdoes not also match/books/2. Patterns are tried in order and the first match wins. -
The
address="127.0.0.1"argument keeps the development server private, as explained above.
python bookshelf.py --port=8899
curl -s http://127.0.0.1:8899/books/2
curl -si http://127.0.0.1:8899/books/9
curl -si -X DELETE http://127.0.0.1:8899/books/1
{"id": "2", "title": "Neuromancer", "author": "William Gibson"}
HTTP/1.1 404 No such book
Content-Type: text/html; charset=UTF-8
...
<html><title>404: No such book</title><body>404: No such book</body></html>
HTTP/1.1 405 Method Not Allowed
The first command shows the JSON body of a book; the second the custom reason phrase; the third that a verb without a method on the handler is answered with 405.
write() Does Not Escape HTML
self.write(str) sends the string unchanged and labels it HTML. Interpolating request data into it is a
cross-site scripting hole. Escape untrusted text with tornado.escape.xhtml_escape, or render a template: Tornado
templates escape every expression by default (Templates & UI Modules covers where that stops being enough).
import json
import unittest
import tornado.web
from tornado.escape import xhtml_escape
from tornado.testing import AsyncHTTPTestCase
class GreetHandler(tornado.web.RequestHandler):
def get(self):
name = self.get_query_argument("name", "reader")
self.write(f"<p>Hello, {xhtml_escape(name)}</p>")
class GreetTest(AsyncHTTPTestCase):
def get_app(self):
return tornado.web.Application([(r"/greet", GreetHandler)])
def test_escaped(self):
response = self.fetch("/greet?name=%3Cscript%3Ealert(1)%3C/script%3E")
self.assertEqual(response.code, 200)
self.assertEqual(
response.body.decode(),
"<p>Hello, <script>alert(1)</script></p>",
)
result = unittest.TextTestRunner().run(unittest.defaultTestLoader.loadTestsFromTestCase(GreetTest))
assert result.wasSuccessful()
A First Test
Before adding features, put the app under test. AsyncHTTPTestCase starts the application on an unused port in the
test process and gives you self.fetch(), which takes a path and returns the response. The test imports
make_app from the file above. Run it with python -m unittest, or with pytest, which runs unittest cases as
they are.
import json
import unittest
import bookshelf
from tornado.testing import AsyncHTTPTestCase
class BookshelfTest(AsyncHTTPTestCase):
def get_app(self):
return bookshelf.make_app()
def test_home(self):
response = self.fetch("/")
self.assertEqual(response.code, 200)
self.assertEqual(json.loads(response.body)["name"], "Bookshelf Live")
self.assertTrue(response.headers["Content-Type"].startswith("application/json"))
def test_book(self):
response = self.fetch("/books/2")
self.assertEqual(json.loads(response.body)["title"], "Neuromancer")
def test_unknown_book(self):
response = self.fetch("/books/9")
self.assertEqual(response.code, 404)
self.assertEqual(response.reason, "No such book")
def test_method_not_allowed(self):
response = self.fetch("/books/1", method="DELETE")
self.assertEqual(response.code, 405)
result = unittest.TextTestRunner().run(unittest.defaultTestLoader.loadTestsFromTestCase(BookshelfTest))
assert result.wasSuccessful()
self.fetch does not raise on a 4xx or 5xx response, which is why the tests assert on response.code. Testing
covers AsyncTestCase, gen_test, WebSocket tests and log assertions.
Calling a Running Server from Python
A real server on a loopback port can also be exercised with Tornado’s own client. The example starts the app inside
asyncio.run, fetches one URL with AsyncHTTPClient and shuts the server down cleanly:
import asyncio
import json
import socket
import bookshelf
from tornado.httpclient import AsyncHTTPClient
def free_port():
with socket.socket() as s:
s.bind(("127.0.0.1", 0))
return s.getsockname()[1]
async def probe():
port = free_port()
server = bookshelf.make_app().listen(port, address="127.0.0.1")
try:
response = await AsyncHTTPClient().fetch(f"http://127.0.0.1:{port}/books")
return json.loads(response.body)
finally:
server.stop()
await server.close_all_connections()
data = asyncio.run(probe())
assert [b["title"] for b in data["books"]] == ["Dune", "Neuromancer"]
fetch raises HTTPClientError for non-2xx responses unless called with raise_error=False. The client page
covers its options, timeouts and the curl implementation.
Debug Mode and Autoreload
Pass debug=True to Application during development. It is a shorthand that sets four settings, each of which can
still be overridden individually:
| Setting | Value | Effect |
|---|---|---|
|
|
The process watches the source files of its modules (and of templates and static files) and restarts itself when one changes. Requests in progress are aborted by the restart. |
|
|
Templates are recompiled on each use, so edits show up without a restart. |
|
|
The version hashes that |
|
|
An uncaught exception in a handler produces an error page that contains the stack trace, instead of a bare 500. |
The defaults are applied with setdefault, so an explicit setting always wins. This is how to keep the
tracebacks but turn the restarts off, which is also what a test should do (a test process must not install a file
watcher):
import tornado.web
quiet = tornado.web.Application([], debug=True, autoreload=False)
settings = quiet.settings
assert settings["debug"] is True
assert settings["autoreload"] is False # explicit value wins
assert settings["serve_traceback"] is True # still set by debug=True
assert settings["compiled_template_cache"] is False
assert settings["static_hash_cache"] is False
Because the constructor installs the watcher, build the Application only where it will be served. Debug mode
differs from Flask’s in one important respect: there is no interactive debugger and no PIN console. The error page is
read-only, so Tornado’s debug mode leaks source paths and code through the traceback but does not offer remote code
execution the way an exposed Werkzeug debugger does.
What Debug Mode Must Never Touch
Debug mode is for the machine on which you are typing. In production the traceback page discloses file paths,
library versions and code, the watcher costs CPU and file descriptors, and, more importantly, autoreload does not work
with the multi-process mode (fork_processes), which is how Tornado is normally deployed on several CPUs. Read the
debug flag from the command line or the environment, default it to False, and never put debug=True in a file
that is also deployed. The --debug option of bookshelf.py follows that rule.
Running python bookshelf.py --debug serves a traceback for the same unknown route that otherwise answers with a
bare 404:
python bookshelf.py --debug
curl -si http://127.0.0.1:8888/books/9
HTTP/1.1 404 No such book
Content-Type: text/plain
...
Traceback (most recent call last):
File ".../tornado/web.py", line 1885, in _execute
result = method(*self.path_args, **self.path_kwargs)
File ".../bookshelf.py", line 29, in get
raise tornado.web.HTTPError(404, reason="No such book")
tornado.web.HTTPError: HTTP 404: No such book
The tornado.autoreload Wrapper
The reloader that debug=True starts can only restart a process that has already imported your modules. A syntax
error saved while the server is running crashes the restarted process, and nothing is left to watch for the fix. The
tornado.autoreload module also works as a command-line wrapper that avoids this: it runs your script, and keeps
watching the files even when the script fails to import.
python -m tornado.autoreload bookshelf.py --debug --port=8899 # run a script
python -m tornado.autoreload -m bookshelf --debug --port=8899 # run a module
Source: Tornado: tornado.autoreload
Arguments after the script name are passed to it. The wrapper and debug=True are meant to be used together, as
above: the wrapper catches failures at import time, and the debug setting catches changes once the server runs. The
--until-success flag stops the reloading once the script exits with status 0, which is handy for wrapping a test
runner. When the script raises SyntaxError, the wrapper logs the traceback, keeps the failing file under
watch and starts the script again as soon as you save a fixed version.
Limitations to remember:
-
Reloading re-executes Python with
sys.executableandsys.argv, so interpreter flags such as-uare lost. -
Autoreload and multi-process mode do not mix (
HTTPServer.startmust be given 1 process, andtornado.process.fork_processesmust not be used). -
On some platforms, among them Windows, the old process exits before the new one starts, which can confuse an IDE that tracks the process.
-
Autoreload watches imported Python modules, templates and static files. For anything else, such as a YAML or INI file read at startup, register it yourself.
import tempfile
from tornado import autoreload
with tempfile.NamedTemporaryFile("w", suffix=".ini") as config:
autoreload.watch(config.name) # restart when this file changes
def flush_caches():
print("code changed; restarting") # runs just before the process re-executes
autoreload.add_reload_hook(flush_caches)
Command-Line Options with tornado.options
tornado.options is Tornado’s small, global configuration helper, and the reason bookshelf.py accepts --port
and --debug. An option is declared once with define, and parse_command_line fills in values from argv:
Parameter of define |
Meaning |
|---|---|
|
The option name (underscores and dashes are interchangeable on the command line) and its default value. |
|
|
|
Text shown by |
|
Allow the option to hold a list. Give the values as one comma-separated string ( |
|
The heading under which |
|
Called with the new value whenever the option is set. |
Source: Tornado: tornado.options
Options are defined as a side effect of importing the module that calls define, at module level, and each name may
be defined only once. Besides your own options, tornado.log defines a family of logging options that
parse_command_line applies for you. Run python bookshelf.py --help:
Usage: bookshelf.py [OPTIONS]
Options:
--help show this help information
bookshelf.py options:
--debug run in debug mode (development only)
(default False)
--port port to listen on (default 8888)
tornado/log.py options:
--log-file-max-size max size of log files before rollover
(default 100000000)
--log-file-num-backups number of log files to keep (default 10)
--log-file-prefix=PATH Path prefix for log files. ...
--log-rotate-interval The interval value of timed rotating
(default 1)
--log-rotate-mode The mode of rotating files(time or size)
(default size)
--log-rotate-when specify the type of TimedRotatingFileHandler
interval other options:(...) (default midnight)
--log-to-stderr Send log output to stderr ...
--logging=debug|info|warning|error|none
Set the Python log level. ...
Unless you pass --logging=none (or set options.logging = None before parsing), parse_command_line also
configures the root logger, which is why requests are logged in color to the terminal as in the earlier
curl runs. The loggers behind that output are tornado.access (one line per request), tornado.application
(uncaught exceptions in handlers) and tornado.general. Options, Configuration & Logging covers them.
The global options object is convenient in a script and awkward in a library or a test, because it is shared state.
OptionParser offers the same interface for a private instance, which is how the next example can run in the same
process as the others. Its parse_command_line takes a full argv whose first element is the program name:
from tornado.options import OptionParser
parser = OptionParser()
parser.define("port", default=8888, type=int, help="port to listen on")
parser.define("debug", default=False, type=bool)
parser.define("origin", default=[], type=str, multiple=True)
remaining = parser.parse_command_line(
["bookshelf.py", "--port=9000", "--debug", "--origin=a.example,b.example", "extra"]
)
assert parser.port == 9000 and parser.debug is True
assert parser.origin == ["a.example", "b.example"]
assert remaining == ["extra"]
Unknown options and values of the wrong type are rejected with an error, and --help prints the table above and
exits. Options can also come from a file with parse_config_file. The file is executed as Python code, and every
global name that matches a defined option sets it. A config file is code, so it must be as trusted as the program
itself and written only by deployment tooling.
import pathlib
import tempfile
from tornado.options import OptionParser
parser = OptionParser()
parser.define("port", default=8888, type=int)
parser.define("debug", default=False, type=bool)
with tempfile.TemporaryDirectory() as tmp:
config = pathlib.Path(tmp) / "bookshelf.conf"
config.write_text("port = 9100\ndebug = False\n")
parser.parse_config_file(str(config))
assert parser.port == 9100
parse_config_file(path, final=False) followed by parse_command_line() is the usual way to let the command line
override a file. Options, Configuration & Logging also compares this module with environment variables
and twelve-factor settings, which are the usual choice in containers. Secrets such as the future cookie_secret of
the login page must never be defined as an option default or written in a file that is committed; generate one with
secrets.token_hex() or read it from the environment.
Legacy Startup Forms You Will Meet
Tutorials, Stack Overflow answers and older code bases start Tornado in other ways. They show how the API changed, and a few of them no longer work.
# norun
import tornado.ioloop
import tornado.web
class MainHandler(tornado.web.RequestHandler):
def get(self):
self.write("legacy")
if __name__ == "__main__":
app = tornado.web.Application([(r"/", MainHandler)])
app.listen(8888)
tornado.ioloop.IOLoop.current().start()
This was the standard main through Tornado 5. On Python 3.13 with Tornado 6.5.10 it still serves requests, but
IOLoop.current() with no running loop is deprecated since 6.2 (Python prints "DeprecationWarning: There is no
current event loop") and relies on the loop being created implicitly. Convert it to the
async def main() form shown at the top of this page. The table summarizes the old idioms and what replaced them;
What’s Changed & Migration collects the complete list.
| You will see | Status | Use instead |
|---|---|---|
|
Deprecated without a running loop (6.2) |
|
|
Deprecated aliases since 5.0 |
|
|
Removed in 5.0 |
Nothing; there is one loop per thread |
|
Removed in 6.0 |
|
|
Still works, legacy |
|
|
Removed in 6.0 |
A Tornado app is not a WSGI app; serve a WSGI app from Tornado with |
|
Deprecated in 6.2 |
|
Source: Tornado: Release notes
Version Notes
| Version | Relevant to getting started |
|---|---|
5.0 (2018) |
|
6.0 (2019) |
Python 2 support dropped. The callback style ( |
6.2 (2022) |
|
6.5 (2025) |
Python 3.9 or newer. Python 3.14 and, experimentally, the free-threaded build are supported. |
6.6 (upcoming) |
Python 3.11 or newer is announced as the minimum. Not yet released; check the release notes when you upgrade. |
Source: Tornado: Release notes
The Development Loop
A day with Tornado is the cycle below. The wrapper catches broken edits, debug mode restarts the working ones, and a test fails fast before a browser is involved:
Source: Tornado: Running and deploying
What happens when the process starts is shown in the following sequence. Notice that app.listen() is a plain call,
not a coroutine: binding the socket needs no waiting, and the loop is what turns the bound socket into a server:
Pitfalls
-
Port already in use. A second copy of the server fails at
app.listen()withOSError: [Errno 48] Address already in use(error 98 on Linux). Stop the first copy or choose another--port. A stale autoreload child that survived a crashed parent is a common culprit. -
Event().wait()forgotten.mainreturns, the loop is closed and the process exits without an error. -
Blocking calls in
async defhandlers. Anasync defmethod that callstime.sleepor a synchronous driver still blocks the whole loop. Making a functionasyncdoes not make its body non-blocking. -
debug=Trueshipped. See What Debug Mode Must Never Touch. -
Binding all interfaces. Pass
address=in development, and put a reverse proxy in front in production. -
Ports below 1024 require elevated privileges; run Tornado on a high port behind nginx or a load balancer instead.
-
Writing a list.
self.write(a_list)raisesTypeError; return a dictionary such as{"books": a_list}. -
Escaping.
self.write()of astris HTML and is not escaped.
Where to Go Next
The following pages of this section build on this one: Introduction & Architecture explains how
asyncio, IOLoop, IOStream and HTTPServer fit together underneath this code; Asynchronous I/O & the Event Loop
and Coroutines & asyncio explain what to avoid inside a handler and how to run work concurrently; Applications & Request Handlers,
Routing and Error Handling turn bookshelf.py into the JSON API; and Testing
expands the first test. If you come from another framework, the comparable first steps are in
Flask: Getting Started, FastAPI:
Getting Started and, for the concurrency background, in
Python concurrency and async.