Every CLI eventually needs to say something other than its result: a warning that a config key is deprecated, a debug trace of which file it opened, an error explaining why it stopped. Reaching for print() for those messages quietly breaks your tool the moment someone pipes its output into another program. This overview shows how to wire Python's logging module into a command-line app so diagnostics go to the right stream, humans get readable output, and machines get parseable records — with verbosity you can dial up or down at runtime.
TL;DR
- Use the
loggingmodule for diagnostics, notprint(). Reserveprint()(orstdout) for the actual result a caller wants to capture. - Send logs to stderr, results to stdout. That one rule keeps your tool composable in pipes and scripts.
- Learn the four moving parts once: a logger creates records, a handler routes them, a formatter renders them, and a level filters them.
- Give humans a pretty console (a plain formatter or
RichHandler) and give machines structured JSON. - Expose the level with verbose and quiet flags so users choose how much they see.
print() versus logging for CLIs
print() is fine for the one thing your command produces — the converted file path, the JSON payload, the table of results. It is the wrong tool for everything about the run. The moment you print() a status message, you have mixed diagnostics into the data stream, and a user who runs mycli export > data.json finds "Connecting to database..." wedged into their JSON.
logging fixes this by separating three concerns you'd otherwise hand-roll: where a message goes (handler), how it looks (formatter), and whether it shows at all (level). You write one line — log.info("connected") — and configuration elsewhere decides the rest. That indirection is exactly what lets the same call site be silent by default, verbose under -v, and JSON under --log-format=json.
import logging
log = logging.getLogger("mycli")
def export(rows: list[dict]) -> None:
log.info("exporting %d rows", len(rows)) # diagnostic -> stderr
for row in rows:
print(row["id"]) # result -> stdout
The result goes to stdout; the "exporting N rows" note goes to stderr once you attach a handler there. A caller redirecting stdout still sees the log on their terminal, and a caller redirecting both keeps them in separate files.
The logging model: logger, handler, formatter, level
Four objects do all the work, and understanding them is the whole game:
- Logger — what you call (
logging.getLogger("mycli")). Loggers form a dotted namespace (mycli,mycli.db) so you can tune sub-areas independently. - Handler — where records go: a
StreamHandlertostderr, aFileHandlerto a log file, aRichHandlerfor a colorized console. One logger can have several. - Formatter — how each record is rendered to text: a timestamp-and-level line for humans, a JSON object for machines.
- Level — the threshold.
DEBUG < INFO < WARNING < ERROR < CRITICAL. A record below the effective level is dropped before it's ever formatted.
Configure them once at startup, ideally in a single setup_logging() function called from your entry point:
import logging
import sys
def setup_logging(level: int = logging.WARNING) -> None:
handler = logging.StreamHandler(sys.stderr) # logs -> stderr
handler.setFormatter(logging.Formatter(
"%(levelname)s %(name)s: %(message)s"
))
root = logging.getLogger()
root.handlers.clear() # avoid duplicate handlers on re-init
root.addHandler(handler)
root.setLevel(level)
Two details matter. Clearing existing handlers makes the function safe to call more than once (tests love this). And configuring the root logger means every module that does logging.getLogger(__name__) inherits the handler for free — you never wire logging per-module.
Logs go to stderr, results go to stdout
This is the rule that makes a CLI behave in a pipeline. stdout is the data channel; stderr is the diagnostics channel. Keep them separate and your tool composes:
$ mycli export --format json > out.json # only results land in the file
exporting 128 rows # log still shows on the terminal
$ mycli export --format json 2>/dev/null | jq '.[0]' # drop logs, keep data
logging.StreamHandler() defaults to stderr, which is already correct — but be explicit (StreamHandler(sys.stderr)) so no one "fixes" it to stdout later. The payoff is that -v can add as much noise as a user wants without ever corrupting the output another program is parsing. This same discipline underpins choosing exit codes and error handling: errors go to stderr and set a non-zero exit, so scripts can branch on success without scraping text.
Human output versus machine output
The same log record should look different depending on who's reading. A developer at an interactive terminal wants a short, colorized line. A log collector wants a JSON object it can index. Decide by asking one question: is stderr a TTY?
import logging
import sys
def choose_formatter() -> logging.Formatter:
if sys.stderr.isatty():
return logging.Formatter("%(levelname)s: %(message)s") # human
# non-interactive (piped, CI, systemd): switch to structured output
from myapp.jsonlog import JsonFormatter
return JsonFormatter()
When stderr is a terminal, render friendly text. When it's redirected — CI, a pipe, a service manager — emit structured records instead. Let a --log-format=json|console flag override the guess, because autodetection is a default, not a law. The deep recipe for the machine side lives in the child guide below.
The two guides underneath this one
This overview stays at the level of how the pieces fit. Two focused guides carry the implementations:
- Structured JSON logging in Python CLIs — emit machine-readable JSON with a stdlib formatter or structlog, attach context fields like a request ID, and switch renderers based on the terminal.
- Adding verbose and quiet logging flags — map
-v,-vv, and--quietonto log levels, pick sane defaults, and keep verbose and quiet mutually exclusive.
Read the flags guide first if you just want users to be able to say "tell me more"; read the JSON guide when your logs need to land in a pipeline.
A pretty console with RichHandler
For interactive use, Rich gives you colorized levels, aligned columns, and syntax-highlighted tracebacks with almost no configuration. Swap the plain StreamHandler for a RichHandler when stderr is a terminal:
import logging
from rich.logging import RichHandler
def setup_rich_logging(level: int = logging.WARNING) -> None:
logging.basicConfig(
level=level,
format="%(message)s", # RichHandler adds level + time columns
datefmt="[%X]",
handlers=[RichHandler(rich_tracebacks=True, show_path=False)],
)
rich_tracebacks=True turns an unhandled exception into a readable, source-highlighted panel instead of a wall of monochrome text — a big usability win for the people running your tool. Rich writes to its own console (stderr by default), so the stdout/stderr split still holds. If your CLI already uses Rich for progress bars and other terminal UI, reusing its handler keeps every message visually consistent. Fall back to the plain formatter when output is redirected, so log files stay free of color escape codes.
Production notes
- Set the level, don't gate at the call site. Never wrap
log.debug(...)inif verbose:. Set the logger level once and let the framework filter — that's the entire point. - Don't
basicConfiginside a library. If your CLI is also importable, configure logging only in the entry-point/main(), never at import time. Libraries should add aNullHandlerand let the application decide. - Interpolate lazily. Write
log.info("got %s rows", n), notlog.info(f"got {n} rows"). The%-args are only formatted if the record actually passes the level filter. - One
setup_logging()call. Clear handlers before adding new ones so re-initialization (in tests or plugins) doesn't double every line. - Capture in tests with
caplog. pytest'scaplogfixture records emitted logs so you can assert on level and message without parsing terminal text.