Code of the Day
IntermediateClick and Subcommands

Configuration files

Support config files, environment variables, and CLI flags with a clear precedence order — the foundation of a well-behaved tool.

UtilitiesIntermediate5 min read
By the end of this lesson you will be able to:
  • 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.toml

On 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 = false

Load 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.

Finished reading? Mark it complete to track your progress.

On this page