Options, Configuration & Logging
|
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 on Python 3.13, checked on 2026-10-07. Tornado 6.6 is only available as an alpha (6.6a1) at the time of writing, so everything described as "6.6" below is upcoming and may still change. Every example was executed with those versions. The running example is the Bookshelf Live scenario: a service that must run the same way on a laptop, in a container and behind a process manager, and whose operators need to know who asked for what.
Tornado ships two small modules for this job. tornado.options is a command-line and config-file option parser with
a global registry, and tornado.log wires the standard logging module to the framework’s three loggers and to a
readable console format. The two are linked: importing tornado.options defines a family of --logging flags, and
parsing the command line applies them. The rest of Tornado does not depend on tornado.options, so everything here is
optional. Applications that already have a settings library (argparse, a pydantic settings class as in
FastAPI: Settings, Lifespan & Background Tasks, or
plain environment variables as in Flask: Configuration) can keep it and use only
the logging half. The settings dictionary of Application, which holds framework switches such as debug or
cookie_secret, is described in Applications & Request Handlers; this page is about where the values in that dictionary come from.
Defining Options
An option is declared once, normally at module level, with tornado.options.define, and is then readable as an
attribute of the global tornado.options.options object. define is a thin wrapper over OptionParser.define, so
everything below also works on a private OptionParser instance, which is what the examples use so that they can run
side by side in one process.
Parameter of define |
Meaning |
|---|---|
|
The option name. Names must be unique across the whole program. Dashes and underscores are interchangeable when
defining, setting and reading (since Tornado 4.3): |
|
The value when nothing sets the option. If no |
|
One of |
|
The text and value placeholder printed by |
|
The value is a list of |
|
The heading under which |
|
Called with the new value every time the option is set, from the command line, from a file or by assignment. |
The parsing rules per type are worth knowing because they are not those of argparse:
| Type | Accepted text |
|---|---|
|
|
|
Parsed with the type’s constructor. A value that cannot be parsed raises the constructor’s |
|
One of a fixed list of formats such as |
|
A number followed by an optional unit ( |
import datetime
from tornado.options import Error, OptionParser
options = OptionParser()
options.define("port", default=8888, type=int, group="server", help="port to listen on")
options.define("debug", default=False, help="development mode (never in production)")
options.define("request_timeout", default=datetime.timedelta(seconds=30), help="per-request budget")
options.define("opens_on", type=datetime.datetime, help="when the lending desk opens")
options.define("shards", type=int, multiple=True, help="database shards, ranges allowed")
options.define("origin", multiple=True, metavar="HOST", help="allowed browser origins")
rest = options.parse_command_line([
"bookshelf.py",
"--port=9000",
"--debug",
"--request-timeout=1h 30m",
"--opens-on=2026-10-07 14:30",
"--shards=1:3,7",
"--origin=books.example,admin.example",
"serve",
])
assert options.port == 9000 and options.debug is True
assert options.request_timeout == datetime.timedelta(minutes=90)
assert options.opens_on == datetime.datetime(2026, 10, 7, 14, 30)
assert options.shards == [1, 2, 3, 7]
assert options.origin == ["books.example", "admin.example"]
assert rest == ["serve"] # everything after the last option is returned
options.parse_command_line(["bookshelf.py", "--debug=no"])
assert options.debug is True # "no" is not one of false, 0 or f
options.parse_command_line(["bookshelf.py", "--debug=false"])
assert options.debug is False
options.parse_command_line(["bookshelf.py", "--origin=a", "--origin=b"])
assert options.origin == ["b"] # a repeated flag replaces, it does not append
try:
options.port = "9000" # assignment is type-checked too
except Error as exc:
print(exc)
Source: Tornado: tornado.options, define
The output of the last statement is:
Option 'port' is required to be a int (<class 'str'> given)
Three details from the example matter in practice. Parsing stops at the first argument that does not start with a
dash, and everything from there on is returned unparsed (a bare -- also ends option parsing), which is how
sub-commands such as serve can coexist with options. A flag given twice keeps the last value, because every
occurrence resets the list: build a list with one comma-separated flag. And --debug=no enables debug mode, a
classic mistake when an orchestrator passes no or off.
Reading and Listing Options
The parser behaves like a read-mostly mapping: attribute access, options["name"] and in all work, and a name that
was never defined raises AttributeError. items(), as_dict(), groups() and group_dict(group) enumerate what is
defined, which is handy for logging the effective configuration at start-up (without secrets, see below).
assert "port" in options and "no-such-option" not in options
assert options.group_dict("server") == {"port": options.port}
assert "server" in options.groups()
effective = {name: value for name, value in options.items() if name != "help"}
assert effective["request_timeout"] == datetime.timedelta(minutes=90)
assert options["request-timeout"] == options.request_timeout # dashes and underscores are the same
Unknown Flags, --help and Errors
An unknown option prints the full help text to stderr and then raises tornado.options.Error, a missing value raises
Error too, and --help (every parser defines it) prints the help and exits with status 0. A
value that cannot be converted raises the ValueError of the converter. A program that wants friendly messages
catches both exception classes around parse_command_line and exits with a non-zero status.
import contextlib
import io
for argv in (["bookshelf.py", "--no-such-flag=1"], ["bookshelf.py", "--port"], ["bookshelf.py", "--port=abc"]):
with contextlib.redirect_stderr(io.StringIO()): # silence the help text
try:
options.parse_command_line(argv)
except (Error, ValueError) as exc:
print(type(exc).__name__, "-", exc)
Error - Unrecognized command line option: 'no-such-flag'
Error - Option 'port' requires a value
ValueError - invalid literal for int() with base 10: 'abc'
Defining the same name twice raises Error as well, which is the usual symptom of two modules that both define port,
or of a module that is imported twice under different names. Define each option in the module that owns the concept and
prefix names that could collide (db_url, not url). All modules that define options must be imported before the
command line is parsed, otherwise their flags are "unrecognized".
Config Files with parse_config_file
parse_config_file(path, final=True) reads a file that is executed as Python code. Every global name in the file
that matches a defined option sets it, other names are ignored, and the variable __file__ holds the absolute path of
the file (since 4.4), so a config file can locate siblings. Values may have the option’s type or be strings, which are
parsed like command-line text (since 5.1); multiple options accept a list or a comma-separated string. Files are read
as UTF-8.
Because the file is Python, it is code that runs with the privileges of the server. It must be written only by trusted
deployment tooling and be unwritable by the service account. The Tornado documentation itself says it is not safe to
use untrusted config files, and that tornado.options is primarily a command-line library: applications that prefer
files may be better served by another library (a TOML or YAML loader, for instance).
import pathlib
import tempfile
with tempfile.TemporaryDirectory() as tmp:
conf = pathlib.Path(tmp) / "bookshelf.conf"
conf.write_text(
"port = 9100\n"
"origin = ['books.example', 'admin.example']\n"
"shards = '1:2'\n" # strings are parsed like command-line values
"request_timeout = '45s'\n"
"here = __file__\n" # not an option, so ignored
)
options.parse_config_file(str(conf))
assert options.port == 9100
assert options.origin == ["books.example", "admin.example"]
assert options.shards == [1, 2]
assert options.request_timeout == datetime.timedelta(seconds=45)
Parse Callbacks and final=False
Both parse_* methods end by running the parse callbacks (add_parse_callback), which is how the logging flags take
effect: tornado.log registers enable_pretty_logging as one. Calling several parse functions in a row would run those
callbacks several times, and the visible symptom is every log line printed twice because the logging handler was
added twice. The rule from the documentation is to pass final=False to all but the last call.
calls = []
layers = OptionParser()
layers.define("port", default=8888, type=int)
layers.add_parse_callback(lambda: calls.append("callbacks"))
with tempfile.TemporaryDirectory() as tmp:
conf = pathlib.Path(tmp) / "layers.conf"
conf.write_text("port = 9200\n")
layers.parse_config_file(str(conf), final=False) # no callbacks yet
layers.parse_command_line(["bookshelf.py", "--port=9300"]) # the last call runs them
assert layers.port == 9300 and calls == ["callbacks"]
Source: Tornado: tornado.options
A --config Flag as a Callback
The callback parameter of define can turn one option into a loader of the others. The Tornado documentation shows a
config option whose callback parses the named file. With it the file overrides options set earlier on the command
line and can be overridden by flags that come later, so an operator controls the precedence by the position of
--config.
from tornado.options import OptionParser
cli = OptionParser()
cli.define("port", default=8888, type=int)
cli.define("debug", default=False)
cli.define("config", type=str, help="path to a config file",
callback=lambda path: cli.parse_config_file(path, final=False))
with tempfile.TemporaryDirectory() as tmp:
conf = pathlib.Path(tmp) / "prod.conf"
conf.write_text("port = 9400\ndebug = False\n")
cli.parse_command_line(["bookshelf.py", "--debug", "--config=" + str(conf), "--port=9500"])
assert cli.debug is False # the file came after --debug and overrode it
assert cli.port == 9500 # --port came after the file and overrode it
Callbacks are also the place to validate: a callback that raises stops parsing with a clear message, so an out-of-range port or an unknown storage backend is rejected before the server starts.
Environment Variables and Twelve-Factor Configuration
tornado.options has no built-in environment-variable support. The twelve-factor approach (one build, configuration
in the environment, secrets injected by the platform) is therefore a few lines of glue, and the cleanest glue reuses
the parser so that types, multiple and callbacks behave identically for every source: translate the environment into
flags and feed them to parse_command_line(final=False). The order of the calls is the precedence, so the next example
yields defaults, then the optional file, then the environment, then the real command line.
import os
import secrets
app_options = OptionParser()
app_options.define("port", default=8888, type=int)
app_options.define("debug", default=False)
app_options.define("origin", multiple=True)
app_options.define("config", type=str, help="optional config file")
app_options.define("cookie_secret", type=str, help="signing secret, from the environment only")
def load(argv, environ, prefix="BOOKSHELF_"):
"""Defaults < config file < environment < command line."""
config_path = environ.get(prefix + "CONFIG")
if config_path:
app_options.parse_config_file(config_path, final=False)
env_flags = [
f"--{name}={environ[prefix + name.upper()]}"
for name in app_options.as_dict()
if name not in ("help", "config") and prefix + name.upper() in environ
]
app_options.parse_command_line(["env", *env_flags], final=False)
return app_options.parse_command_line(argv) # the last call runs the callbacks
environ = {
"BOOKSHELF_PORT": "9600",
"BOOKSHELF_ORIGIN": "books.example,admin.example",
"BOOKSHELF_COOKIE_SECRET": secrets.token_hex(32), # injected by the platform, never committed
}
load(["bookshelf.py", "--port=9700"], environ)
assert app_options.port == 9700 # the command line wins
assert app_options.origin == ["books.example", "admin.example"]
assert len(app_options.cookie_secret) == 64
Source: Tornado: tornado.options
The same layering with other tools is straightforward, and Tornado does not care which one fills the Application
settings:
| Approach | When it fits |
|---|---|
|
A handful of values, containers and Kubernetes |
|
Programs with sub-commands ( |
A typed settings class ( |
Many values, nested structures, validation and |
|
Small services that want flags and a file with one dependency. Remember the file is executable Python. |
Secrets deserve their own rule: a signing secret, database password or API key is never an option default, never
written in a committed config file and never printed by an "effective configuration" log line. The cookie_secret and
xsrf_cookie_kwargs settings used by the authentication and XSRF pages
(Authentication & Signed Cookies,
XSRF & Cross-Origin Protection) are read from the
environment or a secret store, and a development fallback is generated with secrets.token_hex() at start-up, which
invalidates the sessions on every restart and so cannot be mistaken for a production configuration. Because every
process of a multi-process deployment must share the same cookie_secret, a generated fallback is only for one
process. A cheap safeguard is to print the effective configuration with the secret options removed:
SECRET_OPTIONS = {"cookie_secret"}
def effective_configuration(parser: OptionParser) -> dict:
return {
name: ("<hidden>" if name in SECRET_OPTIONS and value else value)
for name, value in parser.as_dict().items()
if name != "help"
}
shown = effective_configuration(app_options)
assert shown["cookie_secret"] == "<hidden>" and shown["port"] == 9700
The Global options Object and Tests
The module-level options, define and parse_command_line operate on one shared OptionParser. That is convenient
in a script and awkward in a library or test suite: a test cannot easily set a value, and unittest.mock.patch does not
work on the object because it overrides attribute access. OptionParser.mockable() returns a wrapper made for
patch.object, and values are restored when the block exits. Private OptionParser instances, as used on this page,
are the better design for code that must be importable many times in one process. Option definitions made in a test
module stay registered for the life of the process, so define them once at import time, never inside a test method,
which would raise "already defined" on the second run.
from unittest import mock
import tornado.options
tornado.options.define("shelf_size", default=100, type=int, help="books per shelf")
assert tornado.options.options.shelf_size == 100
with mock.patch.object(tornado.options.options.mockable(), "shelf_size", 3):
assert tornado.options.options.shelf_size == 3
assert tornado.options.options.shelf_size == 100
The Three Loggers
Tornado logs through the standard logging module with three named loggers, available in tornado.log as
access_log, app_log and gen_log:
| Logger name | Python object | What it records |
|---|---|---|
|
|
One line per finished request, written by |
|
|
Uncaught exceptions in handlers, failures inside |
|
|
Framework-level problems: malformed requests, a bad signature on a signed cookie, a failing flush, the "Cannot send error response after headers written" message. |
All three are children of tornado, so logging.getLogger("tornado") configures them together. app_log is the
default home for application messages because the first thing an operator filters on is the name. The exception log
line is produced by RequestHandler.log_exception, whose behaviour and the HTTPError rules are covered in
Error Handling.
import asyncio
import logging
from tornado import httpclient, httpserver, netutil, web
from tornado.log import access_log, app_log, gen_log
class Capture(logging.Handler):
"""Collect log records so the examples can print them."""
def __init__(self):
super().__init__()
self.lines = []
def emit(self, record):
self.lines.append(f"{record.name} {record.levelname}: {record.getMessage()}")
capture = Capture()
tornado_logger = logging.getLogger("tornado")
tornado_logger.addHandler(capture)
tornado_logger.setLevel(logging.INFO)
tornado_logger.propagate = False
assert access_log.name == "tornado.access" and app_log.name == "tornado.application"
assert gen_log.name == "tornado.general" and access_log.parent is tornado_logger
class BooksHandler(web.RequestHandler):
def get(self):
app_log.info("listing books for %s", self.request.remote_ip)
self.write({"books": []})
class BrokenHandler(web.RequestHandler):
def get(self):
raise RuntimeError("shelf database unavailable")
async def probe(app, *paths):
server = httpserver.HTTPServer(app)
sockets = netutil.bind_sockets(0, "127.0.0.1")
server.add_sockets(sockets)
base = f"http://127.0.0.1:{sockets[0].getsockname()[1]}"
client = httpclient.AsyncHTTPClient()
try:
return [await client.fetch(base + path, raise_error=False) for path in paths]
finally:
server.stop()
await server.close_all_connections()
app = web.Application([(r"/books", BooksHandler), (r"/broken", BrokenHandler)])
asyncio.run(probe(app, "/books", "/missing", "/broken"))
for line in capture.lines:
print(line.splitlines()[0])
The captured lines, with the port and timings varying from run to run, look like this:
tornado.application INFO: listing books for 127.0.0.1
tornado.access INFO: 200 GET /books (127.0.0.1) 0.60ms
tornado.access WARNING: 404 GET /missing (127.0.0.1) 0.30ms
tornado.application ERROR: Uncaught exception GET /broken (127.0.0.1)
tornado.access ERROR: 500 GET /broken (127.0.0.1) 0.55ms
Silencing and Filtering Loggers
Because they are ordinary loggers, the usual tools apply. Raising the level of tornado.access to WARNING keeps
only failing requests, which is a common choice when a proxy already writes the access log and per-request lines would
only add cost. A logging.Filter can drop health-check lines, and a different level for tornado.general or
tornado.application is a one-liner. Each log call is relatively expensive at high request rates, so reducing access
logging is also a throughput lever on a busy service.
class DropHealthChecks(logging.Filter):
def filter(self, record):
return "/healthz" not in record.getMessage()
access_log.addFilter(DropHealthChecks())
capture.lines.clear()
health_app = web.Application([(r"/healthz", BooksHandler), (r"/books", BooksHandler)])
asyncio.run(probe(health_app, "/healthz", "/books"))
access_lines = [line for line in capture.lines if line.startswith("tornado.access")]
assert len(access_lines) == 1 and "/books" in access_lines[0]
access_log.setLevel(logging.WARNING) # keep only 4xx and 5xx request lines
capture.lines.clear()
asyncio.run(probe(health_app, "/books", "/missing"))
assert [line for line in capture.lines if line.startswith("tornado.access")][0].startswith("tornado.access WARNING")
access_log.setLevel(logging.NOTSET)
access_log.removeFilter(access_log.filters[0])
Source: Tornado: tornado.log, loggers
Customizing the Access Log
Application.log_request(handler) is called when each request finishes. There are two supported ways to replace it:
subclass Application and override log_request, or pass a function in the log_function application setting. The
function receives the finished RequestHandler, and with it handler.request (method, URI, headers, remote_ip,
request_time()) and handler.get_status(). If you provide log_function, Tornado’s default behaviour, including
the level-by-status rule, is bypassed entirely, so reproduce it if you need it.
import json
def log_request(handler: web.RequestHandler) -> None:
status = handler.get_status()
level = logging.INFO if status < 400 else logging.WARNING if status < 500 else logging.ERROR
access_log.log(level, json.dumps({
"event": "request",
"method": handler.request.method,
"path": handler.request.path, # the path only: query strings may carry tokens
"status": status,
"ms": round(1000 * handler.request.request_time(), 1),
"ip": handler.request.remote_ip,
}))
capture.lines.clear()
json_app = web.Application([(r"/books", BooksHandler)], log_function=log_request)
asyncio.run(probe(json_app, "/books?token=do-not-log"))
record = json.loads(next(line for line in capture.lines if line.startswith("tornado.access")).split(": ", 1)[1])
assert record["path"] == "/books" and record["status"] == 200 and "token" not in json.dumps(record)
The same function is a good place for request identifiers, user ids or latency histograms. Two cautions: the default
summary (handler._request_summary(), a private helper) includes the full URI with the query string, and query strings
often hold tokens and personal data, so a custom format that logs request.uri should be reviewed with the same care.
And log_function runs on the event loop after the response has been sent, so it must not block: write to a local
logger or put the record on a queue, never call a remote service synchronously.
Pretty Logging and the Logging Options
When tornado.options is imported it defines the logging options below (by calling tornado.log.define_logging_options)
and registers a parse callback that applies them with tornado.log.enable_pretty_logging. So the first call to
parse_command_line or parse_config_file that is allowed to run the callbacks configures logging. Run a program that
imports tornado.options with --help to see them.
| Option | Default | Meaning |
|---|---|---|
|
|
The level set on the root logger. |
|
unset |
Send output to stderr in colour when the stream is a terminal. Unset means "use stderr unless |
|
unset |
Write to a file (always without colour). With several processes, the prefix must differ per process, for example by including the port. |
|
|
Rotate by size with a |
|
|
Size threshold in bytes (size mode) and the number of old files kept (both modes). |
|
|
Time mode: |
The format is produced by tornado.log.LogFormatter: [I 261007 14:30:00 module:42] message, with the level initial
and, on a colour-capable terminal, the colour per level. Its constructor accepts fmt, datefmt, style, color and
colors (an alias of the logging.config.dictConfig signature since 4.5), so it can be used as a formatter anywhere
in the logging configuration, not only through tornado.options.
from tornado.log import LogFormatter, define_logging_options, enable_pretty_logging
with tempfile.TemporaryDirectory() as tmp:
logging_options = OptionParser()
define_logging_options(logging_options) # the same flags the global parser has
logging_options.parse_command_line([
"bookshelf.py",
"--logging=debug",
"--log-to-stderr=false",
f"--log-file-prefix={tmp}/bookshelf-9000.log",
"--log-file-max-size=1000000",
"--log-file-num-backups=3",
], final=False)
bookshelf_logger = logging.getLogger("bookshelf.demo")
bookshelf_logger.propagate = False
enable_pretty_logging(logging_options, logger=bookshelf_logger) # applies the flags to one logger
bookshelf_logger.info("rotating file handler is active")
for handler in bookshelf_logger.handlers:
handler.flush()
files = sorted(path.name for path in pathlib.Path(tmp).iterdir())
assert files == ["bookshelf-9000.log"]
text = (pathlib.Path(tmp) / files[0]).read_text()
assert text.startswith("[I ") and text.rstrip().endswith("rotating file handler is active")
for handler in list(bookshelf_logger.handlers):
handler.close()
bookshelf_logger.removeHandler(handler)
formatter = LogFormatter(fmt="%(levelname)s %(name)s %(message)s", color=False)
assert formatter.format(logging.LogRecord("x", logging.INFO, "f", 1, "hi", None, None)) == "INFO x hi"
enable_pretty_logging(options=None, logger=None) defaults to the global options and the root logger, and it adds
handlers every time it runs. Calling it by hand after parse_command_line already did it duplicates every line, the
same symptom as forgetting final=False. In containers, the recommendation is to log to stderr (the default) and let
the platform collect it, so --log-file-prefix and rotation are for virtual machines and bare-metal installs.
Taking Over Logging: --logging=none
Tornado’s built-in setup is intentionally simple: a level, one stderr handler and optional rotating files. A service
that needs JSON output, several handlers, per-logger levels or an agent-friendly format configures logging itself and
tells Tornado to stay out of the way. With tornado.options that is --logging=none (or options.logging = None
before parsing). Without tornado.options, nothing configures logging in the first place, and the loggers propagate
to whatever the application set up.
root_before = list(logging.getLogger().handlers)
quiet = OptionParser()
define_logging_options(quiet)
quiet.parse_command_line(["bookshelf.py", "--logging=none"]) # enable_pretty_logging returns immediately
assert quiet.logging == "none"
assert logging.getLogger().handlers == root_before # nothing was added to the root logger
Structured JSON Logs with a Request Id
Aggregators such as Loki, Elasticsearch or a cloud log service work best with one JSON object per line. The standard
library is enough: a logging.Formatter that serializes the record, logging.config.dictConfig to install it on the
tornado loggers, and a contextvars.ContextVar so that every record written while handling a request carries the
request id. Tornado runs each request in its own task, and asyncio tasks copy the current context when they are
created, so a value set in prepare is visible to all later log calls of that request, including those in awaited
coroutines. Off-loaded work in an executor needs contextvars.copy_context().run(...), or loop.run_in_executor fed
a function that receives the id explicitly.
import contextvars
import logging.config
import uuid
request_id = contextvars.ContextVar("request_id", default="-")
class JsonFormatter(logging.Formatter):
def format(self, record):
payload = {
"ts": self.formatTime(record, "%Y-%m-%dT%H:%M:%S"),
"level": record.levelname,
"logger": record.name,
"request_id": request_id.get(),
"message": record.getMessage(),
}
if record.exc_info:
payload["exception"] = self.formatException(record.exc_info)
return json.dumps(payload)
logging.config.dictConfig({
"version": 1,
"disable_existing_loggers": False,
"formatters": {"json": {"()": JsonFormatter}},
"handlers": {"memory": {"class": "logging.StreamHandler", "formatter": "json", "stream": "ext://sys.stdout"}},
"loggers": {"tornado": {"handlers": ["memory"], "level": "INFO", "propagate": False}},
})
class BaseHandler(web.RequestHandler):
def prepare(self):
incoming = self.request.headers.get("X-Request-Id", "")
# accept a client-supplied id only if it looks like one: never log raw header data
request_id.set(incoming if incoming.isalnum() and len(incoming) <= 64 else uuid.uuid4().hex)
self.set_header("X-Request-Id", request_id.get())
class ShelfHandler(BaseHandler):
async def get(self):
app_log.info("loading shelf")
await asyncio.sleep(0)
app_log.info("shelf loaded")
self.write({"shelf": []})
structured_app = web.Application([(r"/shelf", ShelfHandler)], log_function=log_request)
responses = asyncio.run(probe(structured_app, "/shelf"))
assert len(responses[0].headers["X-Request-Id"]) == 32
The program prints lines such as:
{"ts": "2026-10-07T14:30:00", "level": "INFO", "logger": "tornado.application", "request_id": "6f1c0a...", "message": "loading shelf"}
{"ts": "2026-10-07T14:30:00", "level": "INFO", "logger": "tornado.application", "request_id": "6f1c0a...", "message": "shelf loaded"}
The access line written by log_function is a JSON string inside the message field of the envelope. Teams often keep
that payload and add the request_id to it directly, or use the request log line as the only per-request record and
emit application messages at DEBUG. Whichever shape is chosen, keep it consistent and never log credentials,
cookies, Authorization headers or request bodies. A client-supplied request id is useful for tracing across services
but is untrusted input: accept it only after validation, as the example does.
Debug Mode and Logging
The debug=True application setting turns on autoreload, compiled_template_cache=False, static_hash_cache=False
and serve_traceback=True (see Getting Started). It does not change log
levels, which come from --logging. A habit that works well is to expose a single --debug option for development
and keep production images running without it, and to set --logging=debug only while investigating, because the
DEBUG level of tornado.general and of asyncio is verbose and asyncio's own debug mode (the PYTHONASYNCIODEBUG
environment variable or loop.set_debug(True)) also logs slow callbacks. Never run a reachable server in debug mode:
the tracebacks it returns expose source paths and internals, and autoreload is a development tool only.
Pitfalls
-
--debug=no,--debug=offand--debug=disabledare all true. Onlyfalse,0andfmean false. -
A repeated flag does not accumulate: the last occurrence wins. Use a comma-separated list for
multipleoptions. -
Calling
parse_command_lineandparse_config_filewithoutfinal=Falseon all but the last call runs the parse callbacks repeatedly and doubles log lines. -
enable_pretty_loggingis called for you by theparse_*functions. Calling it again adds a second handler. -
A config file is executable Python: never let an untrusted party write it, and never put secrets in a committed one.
-
An option name may be defined only once. Importing a module under two names defines its options twice.
-
Options defined in a module are unknown until that module is imported, which must happen before parsing.
-
log_functionreplaces the default access log completely, including the level-by-status rule. It runs on the event loop, so it must not block. -
The default access line includes the full URI with the query string, so tokens in URLs end up in the logs.
-
A multi-process server needs a distinct
--log-file-prefixper process, and the samecookie_secretin all of them. -
With
--logging=noneand no logging configuration of your own, nothing reaches a handler except messages atWARNINGand above through Python’s last-resort handler, which looks like missing logs.
Recent Changes
| Version | Change relevant to this page |
|---|---|
4.1, 4.4, 5.1 |
Config files are always read as UTF-8, |
4.3 |
Dashes and underscores are interchangeable in option names. |
4.5 |
|
6.5.10 (current) |
The API described here is unchanged. This page was verified against the installed 6.5.10 source and documentation. |
6.6 (upcoming, 6.6a1 only) |
Python 3.11 becomes the minimum version. No change to |
Source: Tornado: Release notes
Summary
-
Declare options with
define, parse them withparse_command_lineandparse_config_file, and passfinal=Falseto every call except the last. -
Precedence is the order of the calls: defaults, file, environment, command line. The environment needs a few lines of glue, and a settings library is a fine alternative.
-
Secrets come from the environment or a secret store, never from defaults or committed files.
-
Three loggers,
tornado.access,tornado.applicationandtornado.general, are ordinary loggers: filter them, change their levels and attach your own handlers. -
Use
log_functionto change the access line,--logging=nonewithdictConfigto take over logging completely, and a context variable to stamp a request id on every record of a request.