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)"

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)"

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

pycurl

The pure-Python HTTP client behind AsyncHTTPClient, with tornado.curl_httpclient.CurlAsyncHTTPClient, which delegates to libcurl.

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 AsyncHTTPClient.configure(...).

pycares

The default thread-based DNS resolver, with tornado.platform.caresresolver.CaresResolver, a non-blocking resolver for situations where threads are not an option.

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())

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

RequestHandler subclass

One instance is created per request. Tornado calls the method whose name is the lowercase HTTP verb (get, post, put, delete, head, patch, options). A verb without a method answers 405 Method Not Allowed.

self.write(...)

Appends to the output buffer. A str or bytes is sent as HTML, and a dict is serialized as JSON with the content type application/json; charset=UTF-8. A list is rejected with a TypeError, on purpose, because a top-level JSON array has historically been exposed to cross-site attacks; wrap lists in a dictionary.

Application(handlers, **settings)

The routing table (a list of pattern and handler pairs) plus keyword settings such as debug, cookie_secret or template_path. It is the callable the HTTP server hands every request to.

app.listen(8888)

Creates an HTTPServer for the application and binds the port, then returns the server object. It is a convenience for HTTPServer(app).listen(port). The sockets are bound at once, but nothing is served until an event loop runs.

await asyncio.Event().wait()

An event that nobody ever sets, so main waits forever. While it waits, the loop services the listening socket. Without it main would return and asyncio.run would close the loop at once.

asyncio.run(main())

Creates the event loop, runs main to completion and closes the loop. This is the startup form the Tornado documentation has recommended since 6.2.

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.sleep or 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 with app.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

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.get receives "2", not 2. Routing, named groups and reverse_url have their own page.

  • raise tornado.web.HTTPError(404, reason="No such book") is the way to abort with an HTTP status. The reason becomes 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 /books does 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, &lt;script&gt;alert(1)&lt;/script&gt;</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

autoreload

True

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.

compiled_template_cache

False

Templates are recompiled on each use, so edits show up without a restart.

static_hash_cache

False

The version hashes that static_url puts in URLs are recomputed on each use, so edited CSS and JavaScript get new URLs.

serve_traceback

True

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

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.executable and sys.argv, so interpreter flags such as -u are lost.

  • Autoreload and multi-process mode do not mix (HTTPServer.start must be given 1 process, and tornado.process.fork_processes must 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

name, default

The option name (underscores and dashes are interchangeable on the command line) and its default value.

type

str, int, float, bool, datetime or timedelta. If omitted, the type is that of the default. A bool accepts --flag alone as true, and --flag=false.

help, metavar

Text shown by --help, and the placeholder for the value there.

multiple

Allow the option to hold a list. Give the values as one comma-separated string (--origin=a.example,b.example); a repeated flag does not accumulate, and in Tornado 6.5.10 only the last occurrence is kept.

group

The heading under which --help lists the option; by default it is the defining module.

callback

Called with the new value whenever the option is set.

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

IOLoop.current().start() after app.listen()

Deprecated without a running loop (6.2)

asyncio.run(main()) with app.listen() inside main

IOLoop.instance(), IOLoop.install()

Deprecated aliases since 5.0

IOLoop.current() inside a coroutine, or asyncio.get_running_loop()

io_loop= arguments to servers, clients and timers

Removed in 5.0

Nothing; there is one loop per thread

@tornado.web.asynchronous, self.finish() callbacks, callback= arguments

Removed in 6.0

async def handler methods and await

@gen.coroutine and yield

Still works, legacy

async def and await

tornado.wsgi.WSGIApplication

Removed in 6.0

A Tornado app is not a WSGI app; serve a WSGI app from Tornado with WSGIContainer, never the reverse

make_current() and clear_current()

Deprecated in 6.2

asyncio.set_event_loop if you really need it

Version Notes

Version Relevant to getting started

5.0 (2018)

IOLoop wraps the asyncio loop. IOLoop.instance() and install() became deprecated aliases.

6.0 (2019)

Python 2 support dropped. The callback style (@asynchronous, callback=) and WSGIApplication removed.

6.2 (2022)

asyncio.run(main()) became the recommended startup. Calling IOLoop.current() without a running loop was deprecated.

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.

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:

flowchart LR edit["Edit handlers, templates or static files"] --> watch["debug=True watcher or tornado.autoreload sees the change"] watch --> restart["Process re-executes and rebinds the port"] restart --> request["Browser, curl or HTTPie sends a request"] request --> ok{"Uncaught exception?"} ok -- no --> page["Response shown"] ok -- yes --> tb["Error page with traceback (serve_traceback)"] page --> edit tb --> edit edit --> test["python -m unittest"] test --> edit

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:

sequenceDiagram participant U as python bookshelf.py participant A as asyncio.run participant M as main coroutine participant H as HTTPServer participant L as Event loop U->>A: asyncio.run(main()) A->>L: create the loop A->>M: schedule main M->>M: parse_command_line() M->>H: app.listen(port) H->>H: bind and listen on the socket M->>M: await Event().wait() L-->>H: accept connection, read request H-->>L: handler runs, response written Note over L,H: until Ctrl+C cancels main

Pitfalls

  • Port already in use. A second copy of the server fails at app.listen() with OSError: [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. main returns, the loop is closed and the process exits without an error.

  • Blocking calls in async def handlers. An async def method that calls time.sleep or a synchronous driver still blocks the whole loop. Making a function async does not make its body non-blocking.

  • debug=True shipped. 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) raises TypeError; return a dictionary such as {"books": a_list}.

  • Escaping. self.write() of a str is 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.