Environment variables are half of a CLI's interface and usually the undocumented half. Options appear in --help automatically; environment variables appear wherever someone remembered to mention them — a README section that is two releases out of date, a comment in the code, a Slack message. Yet they are exactly what CI pipelines, containers and cron jobs use to configure tools, because nobody wants a token on a command line. Users then discover MYTOOL_REGION by reading the source, or never discover MYTOOL_DEBUG at all. This guide makes environment variables first-class in help: showing the variable next to each option it can set, naming them predictably, documenting the variables that are not tied to options in an "Environment" section, adding an env command that shows what is currently set, and testing that no variable goes undocumented. It belongs to the help output and documentation topic.
Prerequisites
- A Click or Typer CLI that reads configuration from the environment, ideally following config precedence: flags, env, files, defaults.
Two kinds of environment variable
Option-backed variables supply a value for a specific option — MYTOOL_REGION for --region. The framework reads them, validates them like command-line values, and can display them in help. Free-standing variables affect behaviour without a matching option: MYTOOL_CONFIG (where the config file lives), MYTOOL_DEBUG, MYTOOL_NO_UPDATE_CHECK, and the conventions your tool honours, such as NO_COLOR and DO_NOT_TRACK. The framework knows nothing about these, so you must document them yourself.
The recipe: option-backed variables
Click: show_envvar and auto_envvar_prefix
# src/mytool/cli.py
import click
@click.group(context_settings={"auto_envvar_prefix": "MYTOOL", "show_default": True})
def cli() -> None:
"""Deploy services."""
@cli.command()
@click.option("--region", default="eu-west-1", show_envvar=True, help="Region.")
@click.option("--token", envvar="MYTOOL_TOKEN", show_envvar=True, help="API token.")
def deploy(region: str, token: str | None) -> None:
"""Deploy the current build."""
Options:
--region TEXT Region. [env var: MYTOOL_DEPLOY_REGION; default: eu-west-1]
--token TEXT API token. [env var: MYTOOL_TOKEN]
auto_envvar_prefix gives every option an environment variable named PREFIX_COMMAND_OPTION automatically — MYTOOL_DEPLOY_REGION here — and show_envvar=True prints it in help. Explicit envvar= names override the automatic scheme for variables that should be shared across commands, such as a token. There is no context setting that turns show_envvar on for every option at once, so set it per option — or define shared option decorators with it already enabled, as in sharing common options across commands.
Typer: shown by default
@app.command()
def deploy(
region: Annotated[str, typer.Option(envvar="MYTOOL_REGION", help="Region.")] = "eu-west-1",
token: Annotated[str, typer.Option(envvar="MYTOOL_TOKEN", help="API token.")] = "",
) -> None:
...
Typer displays [env var: MYTOOL_REGION] for any option with envvar= unless you pass show_envvar=False. Showing the name of a secret's variable is fine and helpful — it is the value that must never appear, and help never shows values from the environment.
Naming
Pick one scheme and keep it: an upper-case tool prefix, then a name that matches the option (MYTOOL_REGION for --region). Click's automatic PREFIX_COMMAND_OPTION scheme is predictable but produces long names for nested commands; explicit names are shorter but must be maintained. Whichever you choose, never reuse a generic name like REGION or TOKEN — other tools in the same environment will collide with it.
The recipe: free-standing variables
For variables the framework does not know about, keep a single registry in code and generate documentation from it:
# src/mytool/envvars.py
from __future__ import annotations
import os
from dataclasses import dataclass
@dataclass(frozen=True)
class EnvVar:
name: str
description: str
secret: bool = False
ENV_VARS = [
EnvVar("MYTOOL_CONFIG", "Path to the configuration file."),
EnvVar("MYTOOL_TOKEN", "API token (prefer `mytool login`).", secret=True),
EnvVar("MYTOOL_DEBUG", "Set to 1 for full tracebacks and debug logs."),
EnvVar("MYTOOL_NO_UPDATE_CHECK", "Set to 1 to disable update notices."),
EnvVar("NO_COLOR", "Disable coloured output (see no-color.org)."),
]
def environment_epilog() -> str:
width = max(len(v.name) for v in ENV_VARS)
lines = ["Environment:"] + [f" {v.name:<{width}} {v.description}" for v in ENV_VARS]
return "\n".join(lines)
def current_values() -> list[tuple[str, str]]:
rows = []
for v in ENV_VARS:
value = os.environ.get(v.name)
if value is None:
shown = "(not set)"
elif v.secret:
shown = "(set, hidden)"
else:
shown = value
rows.append((v.name, shown))
return rows
Use the epilog on the root command (with a formatter that keeps line breaks — Click's \b marker or Typer's Rich epilog), and add a small command that shows the current state:
@cli.command("env")
def env_cmd() -> None:
"""Show the environment variables mytool reads and their current values."""
for name, shown in current_values():
click.echo(f"{name:<24} {shown}")
mytool env answers the support question "what is actually configured on that CI runner?" in one paste, without ever printing a secret, as recommended in redacting secrets from CLI output and logs.
Showing where each value came from
Documentation tells users which variables exist; the hardest debugging question is which one won. Click records the source of every parameter value, so a command can report it — the most useful addition to a --verbose mode or a dedicated config show command:
@cli.command("show-config")
@click.option("--region", default="eu-west-1", show_envvar=True)
@click.option("--retries", default=3, type=int, show_envvar=True)
@click.pass_context
def show_config(ctx: click.Context, region: str, retries: int) -> None:
"""Print each setting with the place its value came from."""
for name, value in ctx.params.items():
source = ctx.get_parameter_source(name)
click.echo(f"{name}={value!r} ({source.name.lower()})")
$ MYTOOL_SHOW_CONFIG_REGION=us-east-1 mytool show-config --retries 5
region='us-east-1' (environment)
retries=5 (commandline)
get_parameter_source distinguishes the command line, environment variables, default_map (configuration files loaded as in Click option callbacks and eager options) and declared defaults. With Typer, the same method exists on the context, but compare source.name rather than importing Click's enum, because Typer uses its own vendored copy of Click.
UX considerations
- Document precedence once, briefly. "Command-line options override environment variables, which override the config file" in the root help saves many questions.
- Validate environment values like flags. Option-backed variables are validated by the framework; for free-standing ones, a bad value should produce a clear error naming the variable, not a mysterious failure later.
- Treat names as API. Renaming a variable breaks every pipeline that sets it; support the old name with a deprecation warning for a release, as with flags in versioning and deprecating CLI flags.
- Keep the list short. Every variable is something users must discover and you must support; prefer options with
envvar=over free-standing variables when a value maps to an option anyway.
Testing the behaviour
Two tests keep the documentation honest. One checks that help shows the variables; the other scans the source for os.environ reads of MYTOOL_* names and fails if any are missing from the registry:
# tests/test_envvars.py
import re
from pathlib import Path
from click.testing import CliRunner
from mytool.cli import cli
from mytool.envvars import ENV_VARS, current_values
SRC = Path(__file__).resolve().parents[1] / "src" / "mytool"
READ = re.compile(r"""os\.environ(?:\.get)?[\[(]\s*["'](MYTOOL_[A-Z0-9_]+)["']""")
def test_option_envvars_are_shown_in_help():
output = CliRunner().invoke(cli, ["deploy", "--help"]).output
assert "env var: MYTOOL_DEPLOY_REGION" in output
def test_every_free_standing_variable_is_documented():
documented = {v.name for v in ENV_VARS}
used = {m for f in SRC.rglob("*.py") for m in READ.findall(f.read_text(encoding="utf-8"))}
assert used <= documented, f"undocumented: {sorted(used - documented)}"
def test_secrets_are_never_shown(monkeypatch):
monkeypatch.setenv("MYTOOL_TOKEN", "s3cr3t")
assert ("MYTOOL_TOKEN", "(set, hidden)") in current_values()
The source scan is crude but effective: it catches the common case of someone adding os.environ.get("MYTOOL_FOO") in a new module and forgetting to tell anyone.
Conclusion
Environment variables deserve the same visibility as options. Show option-backed variables in help with show_envvar (Click) or the default display (Typer), name them predictably under a tool prefix, keep free-standing variables in one registry that generates an "Environment" help section and an env command, never display secret values, and test that every variable the code reads is documented. CI pipelines and containers will thank you.
Frequently asked questions
Should every option have an environment variable?
No. Options that configure the environment — credentials, endpoints, regions, output preferences — benefit; options that describe a single invocation, such as which file to process, rarely do. auto_envvar_prefix gives every option one for free, which is convenient but makes the interface larger than you may want to support.
How do boolean environment variables work?
Click and Typer accept common truthy and falsy strings (1, true, yes, 0, false, no) for boolean options. For free-standing variables, parse them with the same rules in one helper, so MYTOOL_DEBUG=yes and MYTOOL_DEBUG=1 behave the same.
Can I generate a man page section from the registry?
Yes — the same list can feed an ENVIRONMENT section in generated man pages or Markdown docs, as described in generating man pages and docs from a CLI.
What about .env files?
Loading a .env file is a separate decision with its own trade-offs; if your CLI supports it, list that in the Environment section too, and make the file's location and precedence explicit, as in reading secrets from env and files.
Do environment variables work on Windows the same way?
Yes, though users set them differently ($env:MYTOOL_REGION = "us-east-1" in PowerShell, set MYTOOL_REGION=us-east-1 in cmd), and names are case-insensitive there. Show the PowerShell form next to the POSIX one in your docs, since copying export ... into PowerShell fails.