Applications & Request Handlers

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, and every example was executed on Python 3.13 with that version. Tornado 6.6 is not released yet (only the alpha 6.6a1 is on PyPI), so what is described as "6.6" below is upcoming and may still change. Everything in a Tornado web application hangs off two classes of tornado.web: Application, a collection of routing rules plus a dictionary of settings, and RequestHandler, the object that Tornado creates for every request and whose methods you write. The page builds the heart of Bookshelf Live, the real-time scenario of this section, as a small JSON API for books and reviews. It assumes you ran the first app of Getting Started, and it is the Tornado counterpart of Application & Request Lifecycle, Request Data and Responses & JSON in the Flask section. The layer underneath, asyncio and the single-threaded model, is explained in Asynchronous I/O & the Event Loop; coroutines in Coroutines & asyncio.

Running the Examples

Every snippet below is executed. They share one small helper that starts the application on a free loopback port, makes one request with AsyncHTTPClient and stops the server again, so there are no fixed ports and nothing is left running. bind_unused_port and AsyncHTTPClient are public Tornado APIs; the helper is only scaffolding, and the section Testing a Handler shows the AsyncHTTPTestCase form that real projects use.

import asyncio
import json
import os
import secrets

from tornado import web
from tornado.httpclient import AsyncHTTPClient
from tornado.httpserver import HTTPServer
from tornado.testing import bind_unused_port


async def _call(app, method, path, **kwargs):
    sock, port = bind_unused_port()
    server = HTTPServer(app)
    server.add_sockets([sock])
    client = AsyncHTTPClient(force_instance=True)
    try:
        return await client.fetch(
            f"http://127.0.0.1:{port}{path}", method=method,
            raise_error=False, follow_redirects=False, **kwargs
        )
    finally:
        client.close()
        server.stop()
        await server.close_all_connections()


def call(app, method, path, **kwargs):
    """Start the app, make one request, return the HTTPResponse."""
    return asyncio.run(_call(app, method, path, **kwargs))

The Application

web.Application(handlers=None, default_host=None, transforms=None, **settings) has three jobs. It owns the routing table, a list of rules that map a URL pattern to a handler class; it stores the settings, every keyword argument that is not one of the three named ones; and it is itself a callable that HTTPServer calls for each request, so the same object that you route with is the one you hand to the server. The class is documented on the official tornado.web page. How the patterns are written and matched is the subject of Routing; here only the shapes matter.

A rule is a tuple (pattern, handler_class), optionally followed by a dictionary of keyword arguments for the handler and a name: (pattern, handler_class, kwargs, name). The default matcher is a regular expression that must match the whole path. Capturing groups become arguments of the verb methods: keyword arguments for named groups, positional arguments for unnamed ones (and the two kinds cannot be mixed in one rule). The tuple form is shorthand for the web.url(...) / URLSpec form, which has the same four fields and is used when a rule needs a name for reverse_url.

BOOKS: dict[str, dict] = {
    "1": {"id": "1", "title": "Fluent Python", "author": "Luciano Ramalho"},
    "2": {"id": "2", "title": "Architecture Patterns with Python", "author": "Harry Percival"},
}


class BookHandler(web.RequestHandler):
    def initialize(self, books):
        self.books = books              # a dependency, injected per request

    def get(self, book_id):             # the (\w+) group becomes this argument
        book = self.books.get(book_id)
        if book is None:
            raise web.HTTPError(404, reason="No such book")
        self.write(book)                # a dict is written as JSON


class BookListHandler(web.RequestHandler):
    def initialize(self, books):
        self.books = books

    def get(self):
        self.write({"books": list(self.books.values())})


def make_app(**extra_settings):
    settings = {
        # In production the secret comes from the environment, never from source control.
        "cookie_secret": os.environ.get("BOOKSHELF_COOKIE_SECRET") or secrets.token_hex(32),
        "xsrf_cookies": False,          # turned on in the security pages
        "compress_response": True,
    }
    settings.update(extra_settings)
    return web.Application(
        [
            (r"/api/books", BookListHandler, {"books": BOOKS}),
            web.url(r"/api/books/(\w+)", BookHandler, {"books": BOOKS}, name="book"),
        ],
        **settings,
    )


app = make_app()
r = call(app, "GET", "/api/books/1")
print(r.code, r.headers["Content-Type"], json.loads(r.body))
r = call(app, "GET", "/api/books/99")
print(r.code, r.reason)

The dictionary in the third position of the tuple is handed to the handler’s constructor and from there to initialize; this is how you pass a database pool, a client or a configuration object into a handler without globals. The same dictionary object is reused for every request, so it must hold things that are meant to be shared. A handler class can also be given as a dotted string, which Tornado imports on first use.

To serve the app, the usual shape is the one from the getting-started page: build the Application inside an async def main(), call app.listen(port) (a convenience wrapper that creates an HTTPServer and binds it, returning the server) and wait on an asyncio.Event. The examples on this page use the helper above instead so that they finish.

# norun
import asyncio

async def main():
    app = make_app()
    app.listen(8888)                    # HTTPServer(app).listen(8888)
    await asyncio.Event().wait()        # run until the process is stopped

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

Settings

Settings are plain keyword arguments, stored in application.settings and visible from every handler as self.settings (and self.application.settings). Tornado reads only the documented keys, but you may add your own, which makes the dictionary a convenient place for application-wide configuration. The tables list the settings documented for 6.5.10; those marked upcoming are in the 6.6 development documentation only.

Table 1. General settings
Setting Meaning Default and remarks

debug

Shorthand for autoreload=True, compiled_template_cache=False, static_hash_cache=False and serve_traceback=True. Each of the four can still be set explicitly.

False. Never enable it in production: it exposes tracebacks and restarts the process when files change.

autoreload

Restart the process when a source file changes.

False (True with debug). Incompatible with multi-process mode.

serve_traceback

Include the traceback in the default error page.

False (True with debug).

compress_response

Compress textual responses automatically with gzip, if the client sends a matching Accept-Encoding.

False. gzip is the deprecated alias, since 4.0.

default_handler_class and default_handler_args

Handler used when no rule matches: the hook for a custom 404 page.

An ErrorHandler that answers 404.

log_function

A callable that receives the finished RequestHandler and logs the request. Overriding Application.log_request does the same.

Writes to the tornado.access logger.

ui_modules and ui_methods

Mappings (or modules, or lists of them) of UI modules and UI methods made available to templates.

Empty. See Templates & UI Modules.

websocket_ping_interval and websocket_ping_timeout

Seconds between WebSocket pings, and how long to wait for the pong before closing the connection.

Off. The timeout defaults to the interval. See WebSockets.

Table 2. Authentication and security settings
Setting Meaning Default and remarks

cookie_secret

The key used by set_signed_cookie / get_signed_cookie to sign cookies. May be a dictionary of versioned keys for rotation.

None: signing raises an error without it. Long, random, from the environment, never committed.

key_version

Which key of a cookie_secret dictionary signs new cookies.

The highest.

login_url

Where the @authenticated decorator redirects an anonymous user.

None. See Authentication & Signed Cookies.

xsrf_cookies

Enable the XSRF check for every request that is not GET, HEAD or OPTIONS.

False.

xsrf_cookie_name, xsrf_cookie_kwargs, xsrf_cookie_version

Name of the XSRF cookie (to use a __Host- prefix), extra set_cookie arguments for it, and its format version.

_xsrf; the highest version.

cross_origin_protection and trusted_origins (upcoming, 6.6)

Origin-header based protection for state-changing requests, and the list of origins (for example https://bookshelf.example, no trailing slash) allowed to make them. trusted_origins is also used by the default check_origin for WebSockets.

Not in 6.5.10. Announced as the likely default in a later release; see XSRF & Cross-Origin Protection.

google_oauth, facebook_api_key, facebook_secret, twitter_consumer_key, twitter_consumer_secret

Credentials read by the mixins in tornado.auth.

Documented with the mixins; the Twitter ones are deprecated. See Third-party Authentication.

Table 3. Template and static file settings
Setting Meaning Default and remarks

template_path

Directory with the template files.

None.

template_loader

A tornado.template.BaseLoader instance; when set, template_path and autoescape are ignored.

None.

autoescape

Name of the escaping function applied to template output, or None to disable it.

"xhtml_escape".

compiled_template_cache

Cache compiled templates; False recompiles on every request.

True.

template_whitespace

How whitespace in templates is handled (all, single or oneline).

Keeps it unchanged.

static_path, static_url_prefix

Directory served as static files, and the URL prefix for them. static_path also installs routes for /favicon.ico and /robots.txt.

None; /static/.

static_handler_class and static_handler_args

Replace StaticFileHandler, and give it keyword arguments for initialize.

StaticFileHandler.

static_hash_cache

Cache the content hashes used by static_url.

True.

The two other constructor arguments are rarely needed. default_host is the host pattern used when the request’s Host does not match any pattern from add_handlers, and the official documentation warns that it must not be used by applications exposed to DNS rebinding (Routing covers host patterns and that advice). transforms replaces the list of output transforms; leave it alone and use compress_response.

Reading Settings from a Handler

Because self.settings is just the dictionary, per-application values do not need module globals. The following handler reads a custom page_size setting, and the example also shows that debug=True expands into the four settings of the table.

class SettingsHandler(web.RequestHandler):
    def get(self):
        size = int(self.settings.get("page_size", 20))
        self.write({"page_size": size, "debug": self.settings.get("debug", False)})


demo = web.Application([(r"/s", SettingsHandler)], page_size=5, debug=True, autoreload=False)
print({k: demo.settings[k] for k in ("debug", "autoreload", "compiled_template_cache",
                                      "static_hash_cache", "serve_traceback")})
print(json.loads(call(demo, "GET", "/s").body))

(autoreload=False is passed here only because the example is not a server that should restart itself; with plain debug=True Tornado also starts the autoreload machinery, as described in Getting Started.)

The RequestHandler Lifecycle

For every request Tornado instantiates the matching handler class afresh, so instance attributes live exactly as long as one request and need no locking or cleanup between requests. The documented hooks are called in a fixed order:

  1. The constructor runs. It resets the output (clear(), which calls set_default_headers()), registers on_connection_close, and calls initialize(**kwargs) with the dictionary from the routing rule. Do not override __init__; override initialize.

  2. If the HTTP method is not in SUPPORTED_METHODS, the request ends with 405.

  3. The path arguments are decoded (decode_argument) and, for POST, PUT, DELETE and PATCH, check_xsrf_cookie runs when xsrf_cookies is set.

  4. prepare() runs; it may be async def, and nothing proceeds until it finishes. If it calls finish() or redirect(), or raises, the verb method is skipped.

  5. The verb method (get, post, put, delete, patch, head, options) runs and may be async def.

  6. Unless auto-finish was turned off (@web.asynchronous existed for that and was removed in 6.0; today you simply await inside the method), finish() is called, which sends the response.

  7. on_finish() runs after the response has been sent, so it can no longer write to the client.

on_connection_close() is the exception to the order: Tornado calls it if the client goes away while the handler is still processing asynchronously, which can happen at any await of the previous steps.

sequenceDiagram participant C as Client participant A as Application participant H as RequestHandler C->>A: GET /api/books/1 A->>H: new instance, initialize(**kwargs) Note over H: clear() calls set_default_headers() A->>H: check_xsrf_cookie() for POST, PUT, DELETE, PATCH A->>H: prepare() - await if async A->>H: get(book_id) - await if async H-->>C: finish() sends the response A->>H: on_finish() - cleanup, no output Note over H,C: on_connection_close() fires instead if the client disconnects mid-request
The life of one request through a RequestHandler

The sequence is observable. The handler below records each hook, and the response carries the list, including a header set by set_default_headers.

class LifecycleHandler(web.RequestHandler):
    EVENTS: list[str] = []

    def initialize(self, tag):
        self.EVENTS.append(f"initialize({tag})")

    def set_default_headers(self):
        self.set_header("X-Frame-Options", "DENY")
        self.EVENTS.append("set_default_headers")

    async def prepare(self):
        self.EVENTS.append("prepare")
        await asyncio.sleep(0)          # prepare may be a coroutine

    async def get(self):
        self.EVENTS.append("get")
        self.write({"events": list(self.EVENTS)})

    def on_finish(self):
        self.EVENTS.append("on_finish")


life = web.Application([(r"/life", LifecycleHandler, {"tag": "demo"})])
r = call(life, "GET", "/life")
print(r.headers["X-Frame-Options"], json.loads(r.body)["events"])
print(LifecycleHandler.EVENTS)
r = call(life, "PUT", "/life", body=b"")        # no put() defined
print(r.code, r.reason)

Note the order in the output: set_default_headers precedes initialize (because clear() runs in the constructor first), and on_finish appears only in the second print, because it runs after the body was sent. The last request shows that a verb with no method answers 405; the default implementation of each verb raises it.

What Each Hook Is For

Hook Typical use

initialize(**kwargs)

Store the dependencies passed in the routing rule. Keep it synchronous and cheap; it cannot be a coroutine.

set_default_headers()

Headers that every response of the handler should have (security headers, a custom Server). It runs again when the output is reset for an error page, which is why it is the right place and a verb method is not.

prepare()

Work common to every method: authentication, loading the current user, parsing a JSON body, opening a per-request resource. The official documentation notes there is no guarantee it is called when the framework handles an error itself (for example a 405).

the verb method

The actual handler. Arguments come from the routing pattern.

on_finish()

Cleanup that pairs with prepare, and logging. It cannot produce output. In a few error paths it can run when prepare did not, so guard against unset attributes.

on_connection_close()

Release what a long-lived connection holds (a subscription, a queue). It is called only if the client closed the connection during asynchronous processing, and a proxy may delay it, so anything that must run after every request belongs in on_finish.

Supported Methods

SUPPORTED_METHODS is the class attribute that lists the methods a handler may serve; the default is GET, HEAD, POST, DELETE, PATCH, PUT and OPTIONS. To accept another method, extend the tuple and define a method with the lower-cased name. A handler also normally has a base class of its own, which is where the shared prepare, set_default_headers and error handling belong. The BaseHandler below is the one the following examples use.

class BaseHandler(web.RequestHandler):
    """Shared behaviour for the Bookshelf Live handlers."""

    def set_default_headers(self):
        self.set_header("X-Content-Type-Options", "nosniff")

    def prepare(self):
        # Parse a JSON body once, for every method that carries one.
        self.json_body = None
        ctype = self.request.headers.get("Content-Type", "")
        if self.request.body and ctype.split(";")[0].strip() == "application/json":
            try:
                self.json_body = json.loads(self.request.body)
            except ValueError:
                raise web.HTTPError(400, reason="Body is not valid JSON")


class PropfindHandler(BaseHandler):
    SUPPORTED_METHODS = BaseHandler.SUPPORTED_METHODS + ("PROPFIND",)

    def propfind(self):
        self.write({"method": self.request.method})


r = call(web.Application([(r"/dav", PropfindHandler)]), "PROPFIND", "/dav", allow_nonstandard_methods=True)
print(r.code, json.loads(r.body), r.headers["X-Content-Type-Options"])

Reading the Request

The request is available as self.request, an HTTPServerRequest with method, uri, path, query, headers, body (bytes), cookies, files, remote_ip and protocol. On top of it the handler has helpers for the data a client sends. All of them return text decoded as UTF-8, with surrounding whitespace stripped unless you pass strip=False.

Table 4. Where the data lives
Source Method Remarks

URL path groups

Verb method arguments, or self.path_args and self.path_kwargs

Available already in prepare. decode_argument can be overridden to change the decoding.

Query string

get_query_argument(name, default) and get_query_arguments(name)

Added in 3.2.

Form body (application/x-www-form-urlencoded, multipart/form-data)

get_body_argument(name, default) and get_body_arguments(name)

Added in 3.2. Only filled for these two content types.

Query or body

get_argument(name, default) and get_arguments(name)

Searches both; use the specific variants when the source matters.

Uploaded files

self.request.files[name], a list of dictionaries with filename, body and content_type

The whole upload is in memory; use stream_request_body for large uploads.

Raw body, JSON

self.request.body, then json_decode or json.loads

Tornado does not parse JSON for you; do it once in prepare.

Cookies

get_cookie(name), get_signed_cookie(name)

self.cookies is self.request.cookies.

When default is omitted the argument is required, and a missing one raises MissingArgumentError, a subclass of HTTPError that produces a 400 response without logging a traceback. With default=None (or any other default) it returns the default. If an argument appears more than once, the singular methods return the last value and the plural methods return all of them as a list; an absent argument gives an empty list. Values are always strings, so validate and convert them yourself: int(...) of user input raises ValueError, which would otherwise end as a 500 error.

class SearchHandler(BaseHandler):
    def get(self, shelf="all"):
        term = self.get_query_argument("q")                 # required: 400 when missing
        limit = self.get_query_argument("limit", "10")
        try:
            limit = max(1, min(int(limit), 50))
        except ValueError:
            raise web.HTTPError(400, reason="limit must be an integer")
        tags = self.get_query_arguments("tag")              # ?tag=a&tag=b -> ["a", "b"]
        hits = [b for b in BOOKS.values() if term.lower() in b["title"].lower()]
        self.write({"shelf": shelf, "q": term, "tags": tags, "limit": limit, "hits": hits[:limit]})


search = web.Application([(r"/search/(?P<shelf>\w+)", SearchHandler), (r"/search", SearchHandler)])
r = call(search, "GET", "/search/favorites?q=python&tag=a&tag=b&limit=1")
print(r.code, json.loads(r.body))
print(call(search, "GET", "/search").code)                  # missing q
print(call(search, "GET", "/search?q=x&limit=lots").code)   # bad limit

JSON Bodies

Tornado parses forms but not JSON. The official documentation shows the idiom: decode self.request.body in prepare and read the result from an attribute instead of calling get_argument. The BaseHandler above does that and answers 400 for malformed JSON. A write handler can then validate the document itself:

class BookCreateHandler(BaseHandler):
    def post(self):
        data = self.json_body
        if not isinstance(data, dict) or not data.get("title"):
            raise web.HTTPError(422, reason="title is required")
        book_id = str(len(BOOKS) + 1)
        BOOKS[book_id] = {"id": book_id, "title": str(data["title"]), "author": str(data.get("author", ""))}
        self.set_status(201)
        self.set_header("Location", f"/api/books/{book_id}")
        self.write(BOOKS[book_id])


create = web.Application([(r"/api/books", BookCreateHandler)])
hdrs = {"Content-Type": "application/json"}
r = call(create, "POST", "/api/books", headers=hdrs, body=json.dumps({"title": "Learning Tornado"}))
print(r.code, r.headers["Location"], json.loads(r.body))
print(call(create, "POST", "/api/books", headers=hdrs, body="{oops").code)
print(call(create, "POST", "/api/books", headers=hdrs, body="{}").code)

Tornado enforces limits before the handler runs: max_body_size and max_buffer_size on the HTTPServer (both 100 MB by default), and in 6.5.9 and later a limit on the number of arguments applies to query strings too. Lower the body limit for an API that never takes large documents. The server options are covered in Running & Deploying, and large uploads with streaming in Streaming, Long Polling & SSE.

Uploaded Files

For a multipart/form-data request, text fields are in the body arguments and every file part is in self.request.files, keyed by the form field name. The client controls filename and content_type, so treat both as untrusted: never join the filename into a path without sanitising it, and check the real content type from the bytes if it matters. The example builds a multipart body by hand to stay dependency-free; a browser form or HTTPie --form produces the same.

import uuid


class CoverUploadHandler(BaseHandler):
    def post(self, book_id):
        uploads = self.request.files.get("cover", [])
        if len(uploads) != 1:
            raise web.HTTPError(400, reason="send exactly one file named cover")
        upload = uploads[0]
        if upload["content_type"] not in ("image/png", "image/jpeg"):
            raise web.HTTPError(415, reason="only PNG or JPEG covers")
        # never use upload["filename"] as a path: pick the name yourself
        stored = f"{book_id}-{secrets.token_hex(4)}.bin"
        self.write({
            "stored_as": stored, "client_name": upload["filename"],
            "bytes": len(upload["body"]), "alt": self.get_body_argument("alt", ""),
        })


boundary = uuid.uuid4().hex
body = (
    f'--{boundary}\r\nContent-Disposition: form-data; name="alt"\r\n\r\nA cover\r\n'
    f'--{boundary}\r\nContent-Disposition: form-data; name="cover"; filename="c.png"\r\n'
    f"Content-Type: image/png\r\n\r\n"
).encode() + b"\x89PNG-fake" + f"\r\n--{boundary}--\r\n".encode()
upload_app = web.Application([(r"/api/books/(\d+)/cover", CoverUploadHandler)])
r = call(upload_app, "POST", "/api/books/1/cover", body=body,
         headers={"Content-Type": f"multipart/form-data; boundary={boundary}"})
print(r.code, json.loads(r.body))

Writing the Response

Output is buffered. Everything passed to write() accumulates in memory until the handler returns (or calls finish()), at which point Tornado sets Content-Length when it can, computes an Etag for 200 responses to GET and HEAD requests, answers 304 when the client’s If-None-Match matches and sends the response. The methods:

Table 5. Output methods
Method What it does

write(chunk)

Appends a str, bytes or dict to the buffer. A dictionary is serialised as JSON and sets Content-Type: application/json. Lists are deliberately not converted: the documentation explains they are a JSON hijacking risk, so wrap a list in an object ({"books": [...]}).

set_status(code, reason=None)

Sets the status line. The reason phrase comes from http.client.responses; pass reason only for a non-standard code.

set_header(name, value), add_header(...) and clear_header(name)

Replace, append (for repeated headers) or remove an outgoing header. datetime values are formatted as HTTP dates.

flush()

Sends what is buffered now and returns a future. Used for streaming; after it the status and headers can no longer change, and send_error can only terminate the response.

finish(chunk=None)

Ends the response, optionally writing a last chunk. Called for you when the verb method returns; call it yourself only when you want to finish early. After it, writing raises an error.

render(template_name, **kwargs) and render_string

Render a template into the response, or into bytes. render calls finish().

redirect(url, permanent=False, status=None)

Sets the Location header and a 302 (301 when permanent) or the explicit status, and finishes.

set_cookie, set_signed_cookie, clear_cookie

Cookie output; the effect is seen by the browser only on the next request.

send_error(status_code, **kwargs)

Discards unflushed output and writes the error page through write_error. Prefer raising HTTPError.

clear()

Resets headers and body (it is what send_error uses).

The example combines the common cases: JSON with a custom status, a Cache-Control header, a cookie with the recommended attributes, a redirect and the list rule.

class OutputHandler(BaseHandler):
    def get(self, what):
        if what == "json":
            self.set_header("Cache-Control", "no-store")
            self.write({"ok": True})
        elif what == "list":
            try:
                self.write([1, 2, 3])                      # not allowed
            except TypeError as exc:
                self.set_status(500)
                self.write({"error": "lists cannot be written", "detail": str(exc)[:40]})
        elif what == "cookie":
            self.set_cookie("theme", "dark", max_age=3600, httponly=True, secure=True, samesite="Lax")
            self.write("cookie set")
        elif what == "redirect":
            self.redirect("/out/json", permanent=False)
        elif what == "text":
            self.set_header("Content-Type", "text/plain; charset=UTF-8")
            self.write("plain text")
        elif what == "gone":
            self.set_status(410, reason="Gone for good")
        else:
            raise web.HTTPError(404)


out = web.Application([(r"/out/(\w+)", OutputHandler)])
for what in ("json", "list", "cookie", "redirect", "text", "gone"):
    r = call(out, "GET", f"/out/{what}")
    print(what, r.code, r.reason, r.headers.get("Content-Type"), r.headers.get("Location"),
          r.headers.get("Set-Cookie"))

Three things in that output are worth remembering. The Content-Type of a string response defaults to text/html; charset=UTF-8, so set it explicitly for anything else. A cookie set with set_cookie is not visible to get_cookie in the same request. And if a handler calls redirect() it has already finished, so it must not write afterwards.

flush and finish

flush() and finish() return futures that can be awaited to wait until the data has been written to the socket, which provides back-pressure for large or endless responses. A handler that streams a response (server-sent events, a large export) writes a chunk, await self.flush(), and repeats; Streaming, Long Polling & SSE develops it. The example streams three chunks and shows that the client receives a response of unknown length (chunked encoding).

class ChunkedHandler(BaseHandler):
    async def get(self):
        self.set_header("Content-Type", "text/plain; charset=UTF-8")
        for i in range(3):
            self.write(f"chunk {i}\n")
            await self.flush()          # headers go out with the first flush
            await asyncio.sleep(0.01)
        # finish() is called automatically when the coroutine returns


r = call(web.Application([(r"/chunks", ChunkedHandler)]), "GET", "/chunks")
print(r.code, r.headers.get("Transfer-Encoding"), r.headers.get("Content-Length"), repr(r.body))

Rendering Templates

render("books.html", books=...) finds the template through the template_path setting (or template_loader), renders it and calls finish(). Template syntax, autoescaping and UI modules are the subject of Templates & UI Modules, but the wiring belongs here because it is a setting. The example uses an in-memory DictLoader so that it needs no files.

from tornado import template

loader = template.DictLoader({
    "books.html": "<ul>{% for b in books %}<li>{{ b['title'] }}</li>{% end %}</ul>",
})
html_app = web.Application([(r"/books.html", type("H", (web.RequestHandler,), {
    "get": lambda self: self.render("books.html", books=list(BOOKS.values()))}))],
    template_loader=loader)
r = call(html_app, "GET", "/books.html")
print(r.code, r.headers["Content-Type"], r.body.decode()[:60])

Built-in Handlers

Three small handler classes ship in tornado.web for the cases where writing a class would be noise.

Handler Use

RedirectHandler

(r"/oldpath", web.RedirectHandler, {"url": "/newpath"}) redirects every GET. The destination may contain str.format fields filled from the capturing groups of the pattern (4.5), and since 5.0 any query string is copied to the destination. The permanent argument defaults to True, so the answer is 301; pass "permanent": False for 302.

ErrorHandler

Answers every request with the status code given as status_code. The default 404 for unknown URLs uses it, and it is a handy target for routes that must be explicitly gone. Unlike other handlers it skips the XSRF check, so that a wrong POST yields 404 and not 403.

FallbackHandler

Wraps another HTTP server callable (typically a WSGIContainer) and forwards the request to it, so Tornado handlers and a WSGI application can share one server. WSGI Interoperability covers it, and the reasons not to rely on it for a production Flask or Django site.

extras = web.Application([
    # swap the first two path segments, keep the rest: /library/fiction/9 -> /fiction/library/9
    (r"/(.*?)/(.*?)/(.*)", web.RedirectHandler, {"url": "/{1}/{0}/{2}"}),
])
r = call(extras, "GET", "/library/fiction/9?x=1")
print(r.code, r.headers["Location"])

legacy = web.Application([
    (r"/books/list", web.RedirectHandler, {"url": "/api/books", "permanent": True}),
    (r"/admin/.*", web.ErrorHandler, {"status_code": 403}),
], default_handler_class=web.ErrorHandler, default_handler_args={"status_code": 404})
print(call(legacy, "GET", "/books/list").code, call(legacy, "GET", "/books/list").headers["Location"])
print(call(legacy, "POST", "/admin/x", body=b"").code, call(legacy, "GET", "/nowhere").code)

compress_response

With compress_response=True Tornado installs the gzip output transform. It compresses only responses whose Content-Type starts with text/ or is on a short list (JSON, JavaScript, XML, Atom, XHTML and SVG), only when the client advertises gzip in Accept-Encoding, and adds Vary: Accept-Encoding. A response written in one piece is compressed only above a minimum length of about a kilobyte. Other types (images, archives) are not touched. It costs CPU on the event loop for every response, so in production prefer compressing in the reverse proxy (nginx) and keep the setting for development or for deployments without a proxy. The old setting name is gzip.

import gzip

big = web.Application([(r"/big", type("H", (web.RequestHandler,), {
    "get": lambda self: self.write({"books": [{"title": "x" * 40}] * 100})}))], compress_response=True)
r = call(big, "GET", "/big", headers={"Accept-Encoding": "gzip"}, decompress_response=False)
print(r.code, r.headers["Content-Encoding"], r.headers["Vary"], len(r.body), len(gzip.decompress(r.body)))
plain = call(big, "GET", "/big", headers={"Accept-Encoding": "identity"}, decompress_response=False)
print(plain.headers.get("Content-Encoding"), len(plain.body))

When Something Goes Wrong

Uncaught exceptions and HTTPError end up in send_error and write_error. A raised HTTPError is logged as a warning by tornado.general without a stack trace; any other exception is logged as an error with the traceback by tornado.application and answered with 500. The default error page is a small HTML document; with serve_traceback it includes the traceback, which is why that setting stays off in production. web.Finish ends a request early without an error page, for example after setting a 401 and a WWW-Authenticate header. Customising the error pages, JSON error bodies and logging belong to Error Handling; the minimal form is shown here because every API base class needs it.

class JsonErrorHandler(web.RequestHandler):
    def write_error(self, status_code, **kwargs):
        self.set_header("Content-Type", "application/json")
        self.finish({"status": status_code, "message": self._reason})

    def get(self):
        raise web.HTTPError(418, reason="I am a teapot")


r = call(web.Application([(r"/tea", JsonErrorHandler)]), "GET", "/tea")
print(r.code, r.headers["Content-Type"], json.loads(r.body))

Putting It Together: the Bookshelf Live API

The following application combines what the page covered into the REST core of Bookshelf Live: a shared base handler, injected storage, JSON in and out, argument validation and one custom setting. The data is an in-memory dictionary standing in for the database that Databases & Async Drivers wires in with an async driver. The rest of the section keeps adding routes to this skeleton: WebSocket chat rooms, long polling, authentication and the cover fetcher.

class Store:
    """In-memory stand-in for the async database of the databases page."""

    def __init__(self):
        self.books: dict[str, dict] = {}
        self.reviews: dict[str, list[dict]] = {}


class ApiHandler(BaseHandler):
    def initialize(self, store):
        self.store = store

    def write_error(self, status_code, **kwargs):
        self.set_header("Content-Type", "application/json")
        self.finish({"status": status_code, "message": self._reason})


class BooksHandler(ApiHandler):
    def get(self):
        page = int(self.get_query_argument("page", "1"))
        size = self.settings["page_size"]
        books = list(self.store.books.values())
        self.write({"page": page, "books": books[(page - 1) * size : page * size], "total": len(books)})

    def post(self):
        if not isinstance(self.json_body, dict) or not self.json_body.get("title"):
            raise web.HTTPError(422, reason="title is required")
        book_id = str(len(self.store.books) + 1)
        self.store.books[book_id] = {"id": book_id, "title": self.json_body["title"]}
        self.store.reviews[book_id] = []
        self.set_status(201)
        self.set_header("Location", self.reverse_url("book", book_id))
        self.write(self.store.books[book_id])


class BookDetailHandler(ApiHandler):
    def prepare(self):
        super().prepare()
        book_id = self.path_args[0]             # path arguments are already available here
        if book_id not in self.store.books:
            raise web.HTTPError(404, reason="No such book")

    def get(self, book_id):
        self.write({**self.store.books[book_id], "reviews": self.store.reviews[book_id]})

    def delete(self, book_id):
        del self.store.books[book_id]
        self.set_status(204)                    # no body with 204


def make_bookshelf_live(store):
    return web.Application(
        [
            (r"/api/books", BooksHandler, {"store": store}),
            web.url(r"/api/books/(\d+)", BookDetailHandler, {"store": store}, name="book"),
        ],
        page_size=2,
        compress_response=True,
        default_handler_class=web.ErrorHandler,
        default_handler_args={"status_code": 404},
    )


store = Store()
live = make_bookshelf_live(store)
for title in ("Fluent Python", "Learning Tornado", "Python Concurrency"):
    r = call(live, "POST", "/api/books", headers=hdrs, body=json.dumps({"title": title}))
    print(r.code, r.headers["Location"])
print(json.loads(call(live, "GET", "/api/books?page=2").body))
print(call(live, "GET", "/api/books/42").code, call(live, "DELETE", "/api/books/1").code)

Testing a Handler

The helper at the top of the page is scaffolding. In a project, test handlers with tornado.testing.AsyncHTTPTestCase: override get_app() to return the application, and use self.fetch(path), which runs the request on the test’s own event loop and returns the response. Testing covers it in depth; this is the shape.

import unittest

from tornado.testing import AsyncHTTPTestCase


class BookshelfApiTest(AsyncHTTPTestCase):
    def get_app(self):
        self.store = Store()
        return make_bookshelf_live(self.store)

    def test_create_and_read(self):
        r = self.fetch("/api/books", method="POST", body=json.dumps({"title": "Tornado"}),
                       headers={"Content-Type": "application/json"})
        self.assertEqual(r.code, 201)
        r = self.fetch(r.headers["Location"])
        self.assertEqual(json.loads(r.body)["title"], "Tornado")

    def test_missing_book_is_json_404(self):
        r = self.fetch("/api/books/7")
        self.assertEqual(r.code, 404)
        self.assertEqual(json.loads(r.body)["message"], "No such book")

    def test_unknown_url_uses_default_handler(self):
        self.assertEqual(self.fetch("/nope").code, 404)


suite = unittest.defaultTestLoader.loadTestsFromTestCase(BookshelfApiTest)
result = unittest.TextTestRunner(verbosity=0).run(suite)
print("tests run:", result.testsRun, "failures:", len(result.failures) + len(result.errors))

Threads and Handlers

Handlers are not thread-safe. The official documentation says write(), finish() and flush() must only be called from the main thread; if work runs in another thread, hand control back with IOLoop.add_callback, or keep the thread to IOLoop.run_in_executor and make sure the function running in the executor does not touch Tornado objects (the request, the handler). The usual pattern is to pass plain values into the executor and to write the result in the handler after the await. A handler that blocks the loop (a synchronous driver, time.sleep, password hashing) stalls every other connection of the process; see Asynchronous I/O & the Event Loop.

import hashlib


def hash_password(password: str, salt: bytes) -> str:
    # CPU-bound: runs in a worker thread, touching no Tornado objects
    return hashlib.scrypt(password.encode(), salt=salt, n=2**14, r=8, p=1).hex()


class HashHandler(BaseHandler):
    async def post(self):
        loop = asyncio.get_running_loop()
        salt = secrets.token_bytes(16)
        digest = await loop.run_in_executor(None, hash_password, self.get_body_argument("pw"), salt)
        self.write({"digest_length": len(digest)})


r = call(web.Application([(r"/hash", HashHandler)]), "POST", "/hash", body="pw=hunter2",
         headers={"Content-Type": "application/x-www-form-urlencoded"})
print(r.code, json.loads(r.body))

Pitfalls

  • Blocking calls in a handler stop the whole process. Anything slow goes to an async library or an executor.

  • Instances are per request, state is not. Attributes on self vanish after the request; class attributes and the dictionary passed through the routing rule are shared by all requests and all connections, so mutate them deliberately (this page’s Store is the example of intended sharing).

  • Writing after finish(), which redirect() and render() call, raises an error.

  • write() of a list is refused; wrap it in a dictionary.

  • get_argument searches both the query and the body, so a query parameter can shadow or add to a form field. Use get_query_argument / get_body_argument when the origin matters.

  • Argument values are strings, with whitespace stripped by default; convert and validate them, and treat uploaded filename and content_type as attacker-controlled.

  • initialize is not for I/O. It is synchronous; do awaited work in prepare.

  • prepare is not guaranteed to run when the framework answers an error by itself, and on_finish can run without it in rare error paths; write cleanup code defensively.

  • debug=True and serve_traceback leak internals and must never reach production. Take the cookie secret from the environment or a secrets manager and keep it out of the repository.

  • compress_response runs in the event loop; let the proxy compress in production.

  • Cookies set in a handler are invisible to the same request, and the XSRF check runs before prepare.

Where Older Material Is Outdated

Older Tornado tutorials, and the Flask Cookbook-style recipes that wrap Tornado, show code that no longer works or is legacy:

  • @tornado.web.asynchronous, the callback arguments of methods such as flush and finish, and gen.engine were removed in Tornado 6.0 (2019). A handler is simply an async def method, and Tornado awaits it.

  • get_secure_cookie and set_secure_cookie were renamed get_signed_cookie and set_signed_cookie in 6.3; the old names remain as deprecated aliases.

  • IOLoop.instance() and IOLoop.current().start() as the way to start a server are legacy since the 5.0/6.2 releases: build the app in async def main() and call asyncio.run(main()).

  • tornado.wsgi.WSGIApplication was removed in 6.0: a Tornado Application cannot run under a WSGI server. WSGIContainer does the opposite (a WSGI app inside Tornado).

  • The gzip setting is the deprecated name of compress_response; debug is no longer the only switch for autoreload, template caching and tracebacks, which have their own settings since 3.2.

  • HTTPError.log_message is deprecated in 6.5 in favour of get_message(), and header line folding is deprecated (and removed in the upcoming 6.6).

  • Upcoming in 6.6: the cross_origin_protection and trusted_origins settings, and a Python 3.11 minimum. The collected list is in What’s Changed & Migration.