Configuration files
Support config files, environment variables, and CLI flags with a clear precedence order — the foundation of a well-behaved tool.
- Explain the ~/.config/tool/config.toml convention for user configuration
- Understand Click's auto_envvar_prefix for environment variable overrides
- Describe the correct precedence order — defaults < config file < env var < CLI flag
A tool that only accepts input from command-line flags forces users to repeat
themselves. If every invocation of report --output /data/reports --format json
is the same, the user should be able to put those values in a config file and
forget about them.
Well-behaved CLI tools support configuration at multiple levels, with a clear and predictable precedence order.
The precedence ladder
CLI flag (highest priority — the user said it explicitly right now)
|
Environment variable (set in the shell session or CI environment)
|
Config file (user's persistent preferences)
|
Hardcoded default (lowest priority — what the code says if nothing else)Each level overrides everything below it. A user who sets MYTOOL_FORMAT=json
in their .bashrc but passes --format table on the command line gets table.
The CLI flag always wins.
The config file convention
Most tools follow the XDG Base Directory Specification:
~/.config/<toolname>/config.tomlOn Linux and macOS this is the standard location. Windows uses
%APPDATA%\<toolname>\config.toml. Python's platformdirs library provides
user_config_dir("mytool") to get the correct path on any platform.
A typical config file:
[defaults]
output = "/data/reports"
format = "json"
verbose = falseLoad it at startup and merge with CLI arguments, letting CLI values take precedence:
import tomllib
import pathlib
def load_config(tool_name: str) -> dict:
config_path = pathlib.Path.home() / ".config" / tool_name / "config.toml"
if config_path.exists():
with open(config_path, "rb") as f:
return tomllib.load(f).get("defaults", {})
return {}Environment variables with auto_envvar_prefix
Click's auto_envvar_prefix parameter automatically maps options to
environment variables. With auto_envvar_prefix="MYTOOL", the option
--api-key is automatically read from MYTOOL_API_KEY if the flag is not
passed:
@click.command(context_settings={"auto_envvar_prefix": "MYTOOL"})
@click.option("--api-key", help="API key for authentication.")
@click.option("--output", default="table")
def report(api_key, output):
...Now export MYTOOL_API_KEY=abc123 makes the key available without passing it
on every invocation — and without hardcoding it in a config file where it might
be accidentally committed to version control.
Putting the levels together
import click
import pathlib
import tomllib
def load_config() -> dict:
p = pathlib.Path.home() / ".config" / "mytool" / "config.toml"
if p.exists():
with open(p, "rb") as f:
return tomllib.load(f).get("defaults", {})
return {}
@click.command(context_settings={"auto_envvar_prefix": "MYTOOL"})
@click.option("--format", default=None, help="Output format.")
@click.pass_context
def report(ctx, format):
config = load_config()
# CLI flag > env var (handled by Click) > config file > hardcoded default
effective_format = format or config.get("format", "table")
click.echo(f"Format: {effective_format}")Never store secrets (API keys, passwords) in config files that live in
version-controlled directories. The ~/.config/tool/ location is
intentionally outside of any project directory for this reason. Secrets
belong in environment variables or dedicated secret managers.
Where to go next
Next: lab — Click tool — build a multi-subcommand CLI with a config file, rich output, and a confirmation prompt.