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.

flowchart LR D[Defaults in define] --> F[parse_config_file] F --> E[Environment variables] E --> C[parse_command_line] C --> O[options object] O --> S[Application settings and server arguments] C --> L[enable_pretty_logging via parse callbacks]

Defining Options

Layers of configuration in Bookshelf Live: later layers override earlier ones, and the logging flags are applied once parsing is done.

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

name

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): log_file_prefix and log-file-prefix are the same option. Use dashes on the command line and underscores in config files, which are Python.

default

The value when nothing sets the option. If no type is given, the type is the class of the default.

type

One of str, int, float, bool, datetime.datetime or datetime.timedelta. Without a type and without a default, an option is a str.

help, metavar

The text and value placeholder printed by --help, in the shape --name=METAVAR help (default X).

multiple

The value is a list of type. A comma-separated string is accepted. For integer options x:y expands to the inclusive range from x to y.

group

The heading under which --help lists the option. By default options are grouped by the file that defined them.

callback

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

bool

--debug alone is true. Any value other than false, 0 or f (case-insensitive) is true, so --debug=no and --debug=off are true.

int, float, str

Parsed with the type’s constructor. A value that cannot be parsed raises the constructor’s ValueError.

datetime.datetime

One of a fixed list of formats such as 2026-10-07, 2026-10-07 14:30, 2026-10-07T14:30 or 14:30:00. Anything else raises tornado.options.Error.

datetime.timedelta

A number followed by an optional unit (s, sec, m, min, h, d, w, ms, us or a full timedelta keyword such as hours); no unit means seconds. Several terms separated by spaces add up: 1h 30m. Writing them without a space (1h30m) fails with a TypeError.

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)

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

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

The same layering with other tools is straightforward, and Tornado does not care which one fills the Application settings:

Approach When it fits

os.environ plus small helpers

A handful of values, containers and Kubernetes env or secret mounts (see Docker and Kubernetes).

argparse

Programs with sub-commands (serve, migrate, worker), strict validation and a familiar --help. Then tornado.options is not needed, but --logging=none should be applied by hand (see below), because nothing else configures logging.

A typed settings class (pydantic-settings, dataclasses plus a loader)

Many values, nested structures, validation and .env files in development. See FastAPI settings for the pattern, which works unchanged in a Tornado program.

tornado.options with config files

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

tornado.access

tornado.log.access_log

One line per finished request, written by Application.log_request. The level depends on the response: INFO below 400, WARNING for 4xx and ERROR for 5xx. The default message is status summary milliseconds, for example 200 GET /books (127.0.0.1) 1.35ms.

tornado.application

tornado.log.app_log

Uncaught exceptions in handlers, failures inside write_error and exception loggers, and the application’s own messages. Use this logger (or a child) for your code that is not tied to a request.

tornado.general

tornado.log.gen_log

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

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

--logging=debug|info|warning|error|none

info

The level set on the root logger. none (or setting the option to None in code) means Tornado does not touch the logging configuration at all.

--log-to-stderr

unset

Send output to stderr in colour when the stream is a terminal. Unset means "use stderr unless --log-file-prefix is set or some other handler already exists on the root logger".

--log-file-prefix=PATH

unset

Write to a file (always without colour). With several processes, the prefix must differ per process, for example by including the port.

--log-rotate-mode=size|time

size

Rotate by size with a RotatingFileHandler, or by time with a TimedRotatingFileHandler. Any other value raises ValueError.

--log-file-max-size, --log-file-num-backups

100000000, 10

Size threshold in bytes (size mode) and the number of old files kept (both modes).

--log-rotate-when, --log-rotate-interval

midnight, 1

Time mode: S, M, H, D, midnight or W0 to W6, and the multiple of that unit.

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=off and --debug=disabled are all true. Only false, 0 and f mean false.

  • A repeated flag does not accumulate: the last occurrence wins. Use a comma-separated list for multiple options.

  • Calling parse_command_line and parse_config_file without final=False on all but the last call runs the parse callbacks repeatedly and doubles log lines.

  • enable_pretty_logging is called for you by the parse_* 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_function replaces 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-prefix per process, and the same cookie_secret in all of them.

  • With --logging=none and no logging configuration of your own, nothing reaches a handler except messages at WARNING and 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, __file__ is available inside them, and string values are accepted for options of any type.

4.3

Dashes and underscores are interchangeable in option names.

4.5

LogFormatter got a constructor signature compatible with logging.config.dictConfig.

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 tornado.options or tornado.log was announced when this page was checked, but the alpha may still change: re-read the release notes before upgrading.

Summary

  • Declare options with define, parse them with parse_command_line and parse_config_file, and pass final=False to 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.application and tornado.general, are ordinary loggers: filter them, change their levels and attach your own handlers.

  • Use log_function to change the access line, --logging=none with dictConfig to take over logging completely, and a context variable to stamp a request id on every record of a request.