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.
Frequently asked questions
Should -v be a counted flag or should I ship --log-level?
Ship both, and make them the same setting. Counted -v is what users reach for interactively and it is faster to type than a level name; an explicit --log-level is what scripts and CI configuration want because it is unambiguous. Map both into one level in the callback, and let the explicit option win if somebody passes both.
Where should logging be configured?
In the group callback, before any command runs, and exactly once. A log call that happens before configuration goes through the root logger's default handler, which means it is formatted differently and often to the wrong stream. Configuring in each command instead of the callback means the same three lines drift apart over time.
Why do log messages go to stderr when the tool prints to stdout?
Because logs are not the result. Anything a user might reasonably pipe into another program is the result and belongs on stdout; everything about how the run is going belongs on stderr. Keeping them separate is what allows mytool export > data.json to produce a clean file while the user still sees progress and warnings on screen.
How do I stop a chatty third-party library from flooding -vv?
Set that library's logger level explicitly after configuring your own. logging.getLogger("urllib3").setLevel(logging.WARNING) keeps its retry chatter out of your debug output while leaving your own modules at DEBUG. It is worth doing for the two or three libraries you know are noisy rather than filtering broadly.
What should --quiet actually silence?
Status, not failures. Quiet mode is for cron jobs and pipelines, where the useful behaviour is to print nothing when everything worked and something specific when it did not, so map it to ERROR rather than to CRITICAL or to complete silence. A tool that fails silently under --quiet is one nobody can debug.