Input & UX

Adding Verbose and Quiet Logging Flags

Map -v/-vv and --quiet flags to Python logging levels in a CLI, set sane defaults, route logs to stderr, and keep stdout clean for pipes and scripts.

Updated

Users want to control how much your CLI says without editing config or setting env vars. The convention they already know is -v for more detail (-vv for even more) and -q/--quiet for near-silence. This page shows how to map those flags onto Python logging levels with a single small function, wire them into a Click or Typer callback, make verbose and quiet mutually exclusive, and keep everything on stderr so stdout stays pipe-safe.

TL;DR

  • Count -v: none → WARNING, -vINFO, -vvDEBUG. --quietERROR.
  • Default to WARNING, not INFO — a quiet tool that only speaks up when something's wrong is the right default.
  • Put the mapping in one pure function so it's trivial to unit-test.
  • Route logs to stderr; never let verbosity touch stdout, which belongs to the result.
  • Reject -v and -q used together, and disable color when NO_COLOR is set or output isn't a TTY.

The level-mapping function

Keep the policy in one pure function that turns "verbose count" and "quiet flag" into a logging level. No I/O, no globals — just arithmetic on the level constants, which makes it a joy to test:

from __future__ import annotations
import logging

def resolve_level(verbose: int = 0, quiet: bool = False) -> int:
    """Map -v/-vv and --quiet onto a logging level.

    default -> WARNING, -v -> INFO, -vv -> DEBUG, --quiet -> ERROR.
    """
    if quiet:
        return logging.ERROR
    return {
        0: logging.WARNING,
        1: logging.INFO,
    }.get(verbose, logging.DEBUG)   # 2 or more -> DEBUG

The default of WARNING is deliberate. A CLI that prints an INFO line for every step is noisy out of the box; users learn to ignore it, which defeats the point. Start quiet, let -v opt into the narrative, and reserve the default channel for warnings and errors that actually need attention. -vv bottoms out at DEBUG; there's no level below it, so any higher count stays there.

Wiring it into a Click callback

Click counts repeated flags with count=True, so -vvv arrives as verbose=3. Configure logging in the group callback before any subcommand runs, so every command inherits the chosen level:

import logging
import sys
import click

def setup_logging(level: int) -> None:
    handler = logging.StreamHandler(sys.stderr)          # logs -> stderr
    handler.setFormatter(logging.Formatter("%(levelname)s: %(message)s"))
    root = logging.getLogger()
    root.handlers.clear()
    root.addHandler(handler)
    root.setLevel(level)

@click.group()
@click.option("-v", "--verbose", count=True, help="Increase verbosity (-v, -vv).")
@click.option("-q", "--quiet", is_flag=True, help="Only show errors.")
@click.pass_context
def cli(ctx: click.Context, verbose: int, quiet: bool) -> None:
    if verbose and quiet:
        raise click.UsageError("Pass either -v or --quiet, not both.")
    setup_logging(resolve_level(verbose, quiet))

@cli.command()
def sync() -> None:
    log = logging.getLogger("mycli.sync")
    log.debug("connecting")          # shown only at -vv
    log.info("syncing")              # shown at -v and -vv
    log.warning("slow response")     # shown by default
    click.echo("done")               # result -> stdout, always
$ mycli sync                 # default: WARNING and up
WARNING: slow response
done
$ mycli -v sync              # INFO and up
INFO: syncing
WARNING: slow response
done
$ mycli --quiet sync         # errors only
done

Setting the level once on the root logger means logging.getLogger("mycli.sync") — and every other module logger — inherits it. You never thread a verbosity value into your business logic; you set the threshold and log unconditionally. This is the same single-setup_logging discipline described in the structured logging overview.

The same thing in Typer and argparse

Typer exposes the count with count=True on an int option and gives you a callback the same way:

import logging
import typer

app = typer.Typer()

@app.callback()
def main(
    verbose: int = typer.Option(0, "-v", "--verbose", count=True),
    quiet: bool = typer.Option(False, "-q", "--quiet"),
) -> None:
    if verbose and quiet:
        raise typer.BadParameter("Pass either -v or --quiet, not both.")
    setup_logging(resolve_level(verbose, quiet))

With argparse, count with action="count" and reject the combination with a mutually exclusive group so the parser rejects -v -q before your code runs:

import argparse

parser = argparse.ArgumentParser()
group = parser.add_mutually_exclusive_group()
group.add_argument("-v", "--verbose", action="count", default=0)
group.add_argument("-q", "--quiet", action="store_true")
args = parser.parse_args()
setup_logging(resolve_level(args.verbose, args.quiet))

add_mutually_exclusive_group() is the cleanest guard of the three: argparse emits its own usage error if both are passed, so you delete the hand-written if verbose and quiet check. If you're weighing these frameworks against each other, the Typer vs Click comparison covers where each fits.

Keep stdout clean and respect NO_COLOR

Verbosity must never leak into stdout. The whole reason to build this is so a user can crank -vv while debugging and still pipe the tool's real output somewhere. The handler above targets sys.stderr for exactly that reason:

$ mycli -vv export 2>debug.log | jq '.'   # DEBUG noise in debug.log, clean JSON to jq

Two more environment signals matter for well-behaved output. Honor NO_COLOR — if it's set, don't colorize, period. And when stderr isn't a TTY (piped, CI, a log file), drop color and decoration so log files don't fill with escape sequences:

import os
import sys

def use_color() -> bool:
    if os.environ.get("NO_COLOR"):
        return False
    return sys.stderr.isatty()

Feed use_color() into whichever handler you build — a plain Formatter when it's False, or a RichHandler when it's True. This is a rendering decision layered on top of the level; the two are independent, just like the --log-format switch in the JSON logging guide.

Also accepting an explicit --log-level

Counting flags is the ergonomic default, but power users and CI configs often want to name a level directly: --log-level DEBUG. Offer both, and let the explicit name win when it's given, falling back to the -v/-q count otherwise:

import logging
import click

@click.option("-v", "--verbose", count=True)
@click.option("-q", "--quiet", is_flag=True)
@click.option("--log-level", type=click.Choice(
    ["DEBUG", "INFO", "WARNING", "ERROR"], case_sensitive=False), default=None)
def cli(verbose: int, quiet: bool, log_level: str | None) -> None:
    if log_level is not None:
        level = getattr(logging, log_level.upper())
    else:
        level = resolve_level(verbose, quiet)
    setup_logging(level)

getattr(logging, "DEBUG") resolves the name to the numeric constant — the stdlib exposes each level as a module attribute, so you don't maintain a second lookup table. Keeping --log-level as an explicit override that beats the counted flags mirrors the general rule that a more specific input wins, the same principle behind config precedence. Don't offer three ways to say the same thing without deciding which wins — document that --log-level takes priority so a user passing both isn't surprised.

Testing the level mapping

Because resolve_level is pure, testing the whole policy is a one-liner per case. Parametrize the table and you've locked the behavior:

import logging
import pytest
from myapp.logging import resolve_level

@pytest.mark.parametrize("verbose, quiet, expected", [
    (0, False, logging.WARNING),
    (1, False, logging.INFO),
    (2, False, logging.DEBUG),
    (3, False, logging.DEBUG),   # caps at DEBUG
    (0, True,  logging.ERROR),   # quiet wins
])
def test_resolve_level(verbose, quiet, expected):
    assert resolve_level(verbose, quiet) == expected

To test that a command actually emits at the resolved level, use pytest's caplog fixture: run the command under caplog.at_level(logging.DEBUG) and assert on caplog.records. That checks the wiring end to end without scraping terminal text.

Production notes

  • Flags are one input among several. Verbosity may also come from an env var or config file. Resolve it through the same precedence chain you use for everything else — see config precedence so a --verbose flag beats a MYCLI_VERBOSE env var beats a config setting.
  • Configure in the callback, not at import. Set the level in the group/main callback so an importer of your package never has logging silently reconfigured underneath it.
  • -q should still show errors. Map quiet to ERROR, not CRITICAL — a user who silenced a tool still needs to know when it failed, which ties into your exit-code strategy.
  • Document the mapping in --help. Spell out what each level shows so users aren't guessing whether -v is enough.
  • Idempotent setup. Clear handlers before adding one so a second setup_logging call (tests, plugins) doesn't double every line.