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,-v→INFO,-vv→DEBUG.--quiet→ERROR. - Default to
WARNING, notINFO— 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 touchstdout, which belongs to the result. - Reject
-vand-qused together, and disable color whenNO_COLORis 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
--verboseflag beats aMYCLI_VERBOSEenv var beats a config setting. - Configure in the callback, not at import. Set the level in the group/
maincallback so an importer of your package never has logging silently reconfigured underneath it. -qshould still show errors. Map quiet toERROR, notCRITICAL— 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-vis enough. - Idempotent setup. Clear handlers before adding one so a second
setup_loggingcall (tests, plugins) doesn't double every line.