Skip to main content

Typer Config Files: Managing Application Settings

Professional CLI tools need configuration files to persist settings across runs. Users expect to configure defaults, API endpoints, and preferences without passing flags every invocation. This article covers loading, validating, and merging configuration from files and environment variables.

Why Config Files Matter

Configuration files separate user preferences from code and let your CLI tool scale from single users to teams. Instead of myapp --api-url https://api.example.com --timeout 30 every time, users set those once in a config file and run myapp cleanly.

Loading YAML Configuration

YAML is human-readable and ideal for config files:

import typer
from pathlib import Path
import yaml
from typing import Optional

app = typer.Typer()

def load_config(config_file: Optional[Path] = None) -> dict:
"""Load configuration from file."""
if config_file is None:
config_file = Path.home() / ".myapp" / "config.yaml"

if not config_file.exists():
return {}

with open(config_file) as f:
return yaml.safe_load(f) or {}

@app.command()
def start(
config: Optional[Path] = typer.Option(None, help="Config file path"),
):
"""Start the application."""
cfg = load_config(config)

api_url = cfg.get("api_url", "http://localhost:8000")
timeout = cfg.get("timeout", 10)

typer.echo(f"API URL: {api_url}, Timeout: {timeout}")

if __name__ == "__main__":
app()

Install PyYAML first: pip install pyyaml. Users create ~/.myapp/config.yaml:

api_url: https://api.production.com
timeout: 30
retries: 5

JSON Configuration

JSON is also supported and requires no external dependencies:

import typer
from pathlib import Path
import json
from typing import Optional

app = typer.Typer()

def load_config(config_file: Optional[Path] = None) -> dict:
"""Load JSON configuration."""
if config_file is None:
config_file = Path.home() / ".myapp" / "config.json"

if not config_file.exists():
return {}

with open(config_file) as f:
return json.load(f)

@app.command()
def status(
config: Optional[Path] = typer.Option(None),
):
"""Show application status."""
cfg = load_config(config)

for key, value in cfg.items():
typer.echo(f"{key}: {value}")

if __name__ == "__main__":
app()

Users create ~/.myapp/config.json:

{
"api_url": "https://api.production.com",
"timeout": 30,
"retries": 5
}

Merging Defaults, File, and CLI Arguments

Professional tools merge three config sources: built-in defaults, config file, and CLI arguments (in increasing precedence):

import typer
from pathlib import Path
import json
from typing import Optional
from dataclasses import dataclass, asdict

@dataclass
class Config:
api_url: str = "http://localhost:8000"
timeout: int = 10
debug: bool = False

def load_config(config_file: Optional[Path] = None) -> Config:
"""Load and merge configuration."""
# Start with defaults
cfg = asdict(Config())

# Merge from file if it exists
if config_file is None:
config_file = Path.home() / ".myapp" / "config.json"

if config_file.exists():
with open(config_file) as f:
file_cfg = json.load(f)
cfg.update(file_cfg)

return Config(**cfg)

app = typer.Typer()

@app.command()
def deploy(
service: str = typer.Argument(...),
api_url: Optional[str] = typer.Option(None),
debug: bool = typer.Option(False),
):
"""Deploy a service."""
cfg = load_config()

# CLI args override config file
if api_url:
cfg.api_url = api_url
if debug:
cfg.debug = debug

typer.echo(f"Deploying {service} to {cfg.api_url}")
if cfg.debug:
typer.echo("[DEBUG MODE]")

if __name__ == "__main__":
app()

This approach respects user expectations: defaults work out of the box, config files reduce repetition, and CLI flags override everything for one-off overrides.

Environment Variables as Config

Environment variables provide another config layer, useful in CI/CD:

import typer
import os
from typing import Optional

app = typer.Typer()

def get_config_value(key: str, default: str = "") -> str:
"""Get config from env var or default."""
env_key = f"MYAPP_{key.upper()}"
return os.getenv(env_key, default)

@app.command()
def status():
"""Show app status from env config."""
api_url = get_config_value("api_url", "http://localhost:8000")
timeout = get_config_value("timeout", "10")

typer.echo(f"API URL: {api_url}")
typer.echo(f"Timeout: {timeout}s")

if __name__ == "__main__":
app()

Users run: MYAPP_API_URL=https://prod.com MYAPP_TIMEOUT=30 python myapp.py status. This is standard practice in containerized and cloud environments.

Validating Config Values

Validate configuration at load time to catch errors early:

import typer
from pathlib import Path
import json
from dataclasses import dataclass

@dataclass
class Config:
api_url: str
port: int

def validate(self):
"""Validate configuration."""
if not self.api_url.startswith("http"):
raise ValueError("api_url must start with http or https")
if not (1 <= self.port <= 65535):
raise ValueError(f"port must be 1-65535, got {self.port}")

def load_config(config_file: Path) -> Config:
"""Load and validate config."""
with open(config_file) as f:
data = json.load(f)

cfg = Config(**data)
cfg.validate()
return cfg

app = typer.Typer()

@app.command()
def start(config: Path = typer.Option(...)):
"""Start with validated config."""
try:
cfg = load_config(config)
typer.echo(f"Starting on {cfg.api_url}:{cfg.port}")
except ValueError as e:
typer.echo(f"Config error: {e}", err=True)
raise typer.Exit(code=1)

if __name__ == "__main__":
app()

Validation errors are caught and reported clearly before the app runs.

Comparison Table: Config Sources

SourceFormatPrecedenceUse Case
Hardcoded defaultsCodeLowestFallback values
Config fileYAML/JSONMediumUser preferences
Environment variablesStringHighCI/CD, containers
CLI argumentsCommand-lineHighestOne-off overrides

Key Takeaways

  • Load configs from standard locations like ~/.appname/config.json to respect user conventions.
  • Support multiple formats (YAML for humans, JSON for simplicity) depending on your user base.
  • Merge sources in order: defaults, file, environment, CLI args—each with increasing precedence.
  • Validate configuration values at load time to report errors clearly.
  • Use environment variables in CI/CD and containerized environments; config files for local development.

Frequently Asked Questions

Where should users place their config files?

On Linux/Mac, use ~/.appname/ or ~/.config/appname/ (XDG standard). On Windows, use %APPDATA%\appname\. Use Path.home() to find the home directory portably.

How do I handle config file migrations when the schema changes?

Add a version field to your config and write migration logic that transforms old schemas to new ones. Document changes in a CHANGELOG.

Should I commit config files to version control?

Never commit actual config files with secrets. Commit a template file (e.g., config.example.json) showing the structure, and ignore actual configs with .gitignore.

Can I reload config without restarting the app?

Yes, but it's tricky for long-running services. Use a file watcher or a reload command. For CLI tools run per-invocation, this is automatic.

How do I provide a --init-config command to generate templates?

Use rich.panel.Panel to display a config template, then write it to disk: Path(config_file).write_text(template_yaml).

Further Reading