Error Handling in CLI Tools: Validation & User Feedback
Poor error handling ruins user experience. When a CLI crashes with a traceback or gives unclear error messages, users abandon it. This article covers validation strategies, user-friendly errors, and proper exit codes that make your CLI professional and trustworthy.
Input Validation Patterns
Validate input early and loudly. Here's the basic pattern:
import typer
from pathlib import Path
app = typer.Typer()
@app.command()
def process_file(input_file: Path):
"""Process a file."""
if not input_file.exists():
typer.echo(f"Error: File not found: {input_file}", err=True)
raise typer.Exit(code=1)
if not input_file.is_file():
typer.echo(f"Error: Not a file: {input_file}", err=True)
raise typer.Exit(code=1)
content = input_file.read_text()
typer.echo(f"Processed {len(content)} bytes")
if __name__ == "__main__":
app()
Use err=True to print errors to stderr (standard for errors). Return exit code 1 to signal failure. Check preconditions before attempting operations.
Custom Error Messages with BadParameter
Typer's BadParameter exception provides formatted error messages:
import typer
app = typer.Typer()
def validate_port(value: int) -> int:
"""Port must be in valid range."""
if value < 1 or value > 65535:
raise typer.BadParameter(
f"Port must be 1-65535, got {value}"
)
return value
@app.command()
def start_server(
port: int = typer.Option(8000, callback=validate_port),
):
"""Start server on a port."""
typer.echo(f"Starting on port {port}")
if __name__ == "__main__":
app()
Run with invalid port: python myapp.py --port 99999. Typer displays a clean error message without a traceback.
Rich Error Output
Use Rich to format error messages beautifully:
import typer
from rich.console import Console
from rich.panel import Panel
console = Console()
app = typer.Typer()
@app.command()
def deploy(service: str, env: str):
"""Deploy a service."""
valid_envs = ["dev", "staging", "prod"]
if env not in valid_envs:
console.print(
Panel(
f"[red]Invalid environment: {env}[/red]\n\n"
f"Valid options: {', '.join(valid_envs)}",
title="Deployment Error",
border_style="red",
)
)
raise typer.Exit(code=1)
console.print(f"[green]Deploying {service} to {env}[/green]")
if __name__ == "__main__":
app()
Rich panels make errors stand out and provide context. Users understand what went wrong and how to fix it.
Handling File and Permission Errors
File operations often fail. Handle them gracefully:
import typer
from pathlib import Path
app = typer.Typer()
@app.command()
def save_config(filename: str = "config.json"):
"""Save configuration."""
try:
config_file = Path(filename)
config_file.write_text('{"api_url": "http://localhost:8000"}')
typer.echo(f"Config saved: {config_file}")
except PermissionError:
typer.echo(
f"Error: Permission denied writing to {filename}",
err=True,
)
raise typer.Exit(code=1)
except IOError as e:
typer.echo(f"Error: {e}", err=True)
raise typer.Exit(code=1)
if __name__ == "__main__":
app()
Catch specific exceptions and provide meaningful messages. Users see "Permission denied" instead of a Python traceback.
Validation Classes and Constraints
For complex validation, encapsulate logic in classes:
import typer
import re
from dataclasses import dataclass
@dataclass
class Email:
"""Validate and store email addresses."""
value: str
def __post_init__(self):
pattern = r"^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$"
if not re.match(pattern, self.value):
raise ValueError(f"Invalid email: {self.value}")
app = typer.Typer()
@app.command()
def subscribe(email: str = typer.Argument(...)):
"""Subscribe an email address."""
try:
email_obj = Email(email)
typer.echo(f"Subscribed: {email_obj.value}")
except ValueError as e:
typer.echo(f"Error: {e}", err=True)
raise typer.Exit(code=1)
if __name__ == "__main__":
app()
Validation logic is testable and reusable. Complex rules are centralized in one place.
Exit Codes and Semantics
Use meaningful exit codes so scripts can react:
import typer
app = typer.Typer()
# Define exit codes
EXIT_OK = 0
EXIT_INVALID_INPUT = 1
EXIT_NOT_FOUND = 2
EXIT_PERMISSION = 3
EXIT_INTERNAL_ERROR = 4
@app.command()
def process(filename: str):
"""Process a file."""
from pathlib import Path
file_path = Path(filename)
if not file_path.exists():
typer.echo(f"File not found: {filename}", err=True)
raise typer.Exit(code=EXIT_NOT_FOUND)
try:
content = file_path.read_text()
typer.echo(f"Processed {len(content)} bytes")
raise typer.Exit(code=EXIT_OK)
except PermissionError:
typer.echo(f"Permission denied: {filename}", err=True)
raise typer.Exit(code=EXIT_PERMISSION)
except Exception as e:
typer.echo(f"Internal error: {e}", err=True)
raise typer.Exit(code=EXIT_INTERNAL_ERROR)
if __name__ == "__main__":
app()
Documented exit codes let shell scripts and CI/CD systems take appropriate action based on failure reason.
Comparison Table: Error Handling Approaches
| Approach | Effort | Message Quality | Debuggability |
|---|---|---|---|
| Basic try/except + print | Low | Poor | Low |
| BadParameter callbacks | Low | Good | Medium |
| Rich panels | Medium | Excellent | Medium |
| Validation classes | Medium-High | Excellent | High |
| Exit code semantics | Medium | Good | High |
Key Takeaways
- Validate input early before attempting operations; report errors with
err=Truefor stderr. - Use
typer.BadParameter()in callbacks for clean, formatted error messages. - Use Rich panels to highlight error context and suggestions.
- Catch specific exceptions (
PermissionError,IOError) rather than genericException. - Define exit codes strategically so scripts can distinguish failure types.
- Never expose raw Python tracebacks to users; format errors as user-friendly messages.
Frequently Asked Questions
How do I show errors in red text?
Use typer.echo("[red]error message[/red]", err=True) with Rich's markup, or use Rich's console.print() with Panel for more control.
Should I log errors as well as display them?
Yes. Display user-friendly messages on the CLI; write detailed logs to a file for debugging. Use the logging module to write to ~/.appname/logs/.
What exit code should I use for unknown errors?
Use exit code 1 for general errors, or define a code like 4 for unexpected internal errors. Avoid codes 128-255 which are reserved by the shell.
Can I catch and retry errors automatically?
Yes, but carefully. Retry logic is useful for transient failures (network timeouts). For user input errors, fail immediately—retrying won't help.
How do I show a helpful error message when a required argument is missing?
Typer shows this automatically. Make sure the argument is required (no default value) and Typer displays helpful text on --help.