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)
Source: tornado.web.Application
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.
| Setting | Meaning | Default and remarks |
|---|---|---|
|
Shorthand for |
|
|
Restart the process when a source file changes. |
|
|
Include the traceback in the default error page. |
|
|
Compress textual responses automatically with gzip, if the client sends a matching |
|
|
Handler used when no rule matches: the hook for a custom 404 page. |
An |
|
A callable that receives the finished |
Writes to the |
|
Mappings (or modules, or lists of them) of UI modules and UI methods made available to templates. |
Empty. See Templates & UI Modules. |
|
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. |
Source: tornado.web.Application.settings
| Setting | Meaning | Default and remarks |
|---|---|---|
|
The key used by |
None: signing raises an error without it. Long, random, from the environment, never committed. |
|
Which key of a |
The highest. |
|
Where the |
None. See Authentication & Signed Cookies. |
|
Enable the XSRF check for every request that is not GET, HEAD or OPTIONS. |
|
|
Name of the XSRF cookie (to use a |
|
|
Origin-header based protection for state-changing requests, and the list of origins (for example
|
Not in 6.5.10. Announced as the likely default in a later release; see XSRF & Cross-Origin Protection. |
|
Credentials read by the mixins in |
Documented with the mixins; the Twitter ones are deprecated. See Third-party Authentication. |
Source: tornado.web.Application.settings
| Setting | Meaning | Default and remarks |
|---|---|---|
|
Directory with the template files. |
None. |
|
A |
None. |
|
Name of the escaping function applied to template output, or |
|
|
Cache compiled templates; |
|
|
How whitespace in templates is handled ( |
Keeps it unchanged. |
|
Directory served as static files, and the URL prefix for them. |
None; |
|
Replace |
|
|
Cache the content hashes used by |
|
Source: tornado.web.Application.settings
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:
-
The constructor runs. It resets the output (
clear(), which callsset_default_headers()), registerson_connection_close, and callsinitialize(**kwargs)with the dictionary from the routing rule. Do not override__init__; overrideinitialize. -
If the HTTP method is not in
SUPPORTED_METHODS, the request ends with 405. -
The path arguments are decoded (
decode_argument) and, for POST, PUT, DELETE and PATCH,check_xsrf_cookieruns whenxsrf_cookiesis set. -
prepare()runs; it may beasync def, and nothing proceeds until it finishes. If it callsfinish()orredirect(), or raises, the verb method is skipped. -
The verb method (
get,post,put,delete,patch,head,options) runs and may beasync def. -
Unless auto-finish was turned off (
@web.asynchronousexisted for that and was removed in 6.0; today you simply await inside the method),finish()is called, which sends the response. -
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.
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 |
|---|---|
|
Store the dependencies passed in the routing rule. Keep it synchronous and cheap; it cannot be a coroutine. |
|
Headers that every response of the handler should have (security headers, a custom |
|
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. |
|
Cleanup that pairs with |
|
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 |
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.
| Source | Method | Remarks |
|---|---|---|
URL path groups |
Verb method arguments, or |
Available already in |
Query string |
|
Added in 3.2. |
Form body ( |
|
Added in 3.2. Only filled for these two content types. |
Query or body |
|
Searches both; use the specific variants when the source matters. |
Uploaded files |
|
The whole upload is in memory; use |
Raw body, JSON |
|
Tornado does not parse JSON for you; do it once in |
Cookies |
|
|
Source: tornado.web: Input
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)
Source: tornado.web: Input
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:
| Method | What it does |
|---|---|
|
Appends a |
|
Sets the status line. The reason phrase comes from |
|
Replace, append (for repeated headers) or remove an outgoing header. |
|
Sends what is buffered now and returns a future. Used for streaming; after it the status and headers can no longer
change, and |
|
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 a template into the response, or into bytes. |
|
Sets the |
|
Cookie output; the effect is seen by the browser only on the next request. |
|
Discards unflushed output and writes the error page through |
|
Resets headers and body (it is what |
Source: tornado.web: Output
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"))
Source: tornado.web.RequestHandler.write
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))
Source: tornado.web.RequestHandler.flush
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 |
|---|---|
|
|
|
Answers every request with the status code given as |
|
Wraps another HTTP server callable (typically a |
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)
Source: tornado.web: RedirectHandler
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))
Source: tornado.web: Thread-safety notes
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
selfvanish 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’sStoreis the example of intended sharing). -
Writing after
finish(), whichredirect()andrender()call, raises an error. -
write()of a list is refused; wrap it in a dictionary. -
get_argumentsearches both the query and the body, so a query parameter can shadow or add to a form field. Useget_query_argument/get_body_argumentwhen the origin matters. -
Argument values are strings, with whitespace stripped by default; convert and validate them, and treat uploaded
filenameandcontent_typeas attacker-controlled. -
initializeis not for I/O. It is synchronous; do awaited work inprepare. -
prepareis not guaranteed to run when the framework answers an error by itself, andon_finishcan run without it in rare error paths; write cleanup code defensively. -
debug=Trueandserve_tracebackleak 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_responseruns 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, thecallbackarguments of methods such asflushandfinish, andgen.enginewere removed in Tornado 6.0 (2019). A handler is simply anasync defmethod, and Tornado awaits it. -
get_secure_cookieandset_secure_cookiewere renamedget_signed_cookieandset_signed_cookiein 6.3; the old names remain as deprecated aliases. -
IOLoop.instance()andIOLoop.current().start()as the way to start a server are legacy since the 5.0/6.2 releases: build the app inasync def main()and callasyncio.run(main()). -
tornado.wsgi.WSGIApplicationwas removed in 6.0: a TornadoApplicationcannot run under a WSGI server.WSGIContainerdoes the opposite (a WSGI app inside Tornado). -
The
gzipsetting is the deprecated name ofcompress_response;debugis no longer the only switch for autoreload, template caching and tracebacks, which have their own settings since 3.2. -
HTTPError.log_messageis deprecated in 6.5 in favour ofget_message(), and header line folding is deprecated (and removed in the upcoming 6.6). -
Upcoming in 6.6: the
cross_origin_protectionandtrusted_originssettings, and a Python 3.11 minimum. The collected list is in What’s Changed & Migration.