Skip to content

Latest commit

History

History
1169 lines (890 loc) · 30.3 KB

File metadata and controls

1169 lines (890 loc) · 30.3 KB

Python CLI Arguments and Environment Variables

This guide defines patterns for handling command-line arguments and environment variables in Python applications.

Overview

Applications need configuration from two primary sources:

  1. Environment variables - Container config, CI/CD, deployment-specific values
  2. Command-line arguments - Runtime overrides, developer options, script parameters

Modern Python applications combine both sources with proper validation and type safety.

When to Use Each Approach

Environment Variables

Use for:

  • Deployment-specific configuration (database URLs, API endpoints)
  • Container/cloud environments (Docker, Kubernetes)
  • CI/CD pipelines
  • Secrets and credentials (with secret management)
  • Values that rarely change per deployment

Example:DATABASE_URL, API_KEY, LOG_LEVEL

Command-Line Arguments

Use for:

  • Runtime behavior changes (debug mode, batch size)
  • Developer/operator overrides
  • Script parameters that vary per execution
  • One-off operations

Example:--debug, --batch-size 100, --dry-run

Combining Both

Best practice: Load defaults from environment, override with CLI arguments

port=int(os.getenv('PORT', '8080')) # Default from env# CLI --port overrides env value

Configuration Precedence

Configuration sources are applied in this order (later sources override earlier):

1. Code defaults → port: int = 8080
2. .env file → PORT=9000 (loaded by Pydantic/python-dotenv)
3. Environment vars → export PORT=3000
4. CLI arguments → --port 4000

Example flow:

# 1. Code default: port = 8080# 2. .env file contains: PORT=9000 → port = 9000# 3. Shell: export PORT=3000 → port = 3000# 4. CLI: --port 4000 → port = 4000 (final value)

Key points:

  • Environment variables override .env file (deployment flexibility)
  • CLI arguments have highest priority (developer/operator override)
  • Pydantic BaseSettings follows this order automatically
  • Always document which sources your app supports

Recommended Approaches

Option 1: Pydantic BaseSettings (Recommended for Applications)

Best for web services, daemons, and production applications.

frompydanticimportBaseSettings, Field, validatorclassAppConfig(BaseSettings):
# Environment variables with validationdatabase_url: str=Field(..., env="DATABASE_URL")
api_key: str=Field(..., env="API_KEY")
port: int=Field(8080, env="PORT")
debug: bool=Field(False, env="DEBUG")
workers: int=Field(4, env="WORKERS", ge=1, le=32)
service_hosts: list[str] =Field(..., env="SERVICE_HOSTS")
@validator("service_hosts", pre=True)defparse_hosts(cls, v):
ifisinstance(v, str):
returnv.split(",")
returnvclassConfig:
env_file=".env"env_file_encoding="utf-8"defmain():
config=AppConfig() # Validates and loads from env/.envprint(f"Starting server on port {config.port}")
print(f"Connecting to services: {config.service_hosts}")
# ... rest of application

Advantages:

  • ✅ Type-safe with runtime validation
  • ✅ Auto-loads from .env file
  • ✅ Clear errors if config invalid
  • ✅ Single source of truth
  • ✅ Integrates with FastAPI automatically

See:python-pydantic-guide.md for BaseSettings details

Option 2: argparse (Recommended for Scripts/CLI Tools)

Best for command-line tools, scripts, and utilities.

importargparseimportosdefparse_args():
parser=argparse.ArgumentParser(
description='Process orders from message queue',
formatter_class=argparse.ArgumentDefaultsHelpFormatter
)
# Arguments with env var defaultsparser.add_argument(
'--port',
type=int,
default=int(os.getenv('PORT', '8080')),
help='Server port'
)
parser.add_argument(
'--service-hosts',
default=os.getenv('SERVICE_HOSTS', 'localhost:8080'),
help='Comma-separated service host list'
)
parser.add_argument(
'--debug',
action='store_true',
default=os.getenv('DEBUG', '').lower() =='true',
help='Enable debug logging'
)
parser.add_argument(
'--workers',
type=int,
default=int(os.getenv('WORKERS', '4')),
help='Number of worker threads'
)
args=parser.parse_args()
# Post-process complex typesifisinstance(args.service_hosts, str):
args.service_hosts=args.service_hosts.split(',')
returnargsdefmain():
args=parse_args()
print(f"Port: {args.port}")
print(f"Service hosts: {args.service_hosts}")
print(f"Debug: {args.debug}")
# ... rest of applicationif__name__=='__main__':
main()

Advantages:

  • ✅ Stdlib (no dependencies)
  • ✅ Auto-generated help text
  • ✅ Type conversion built-in
  • ✅ Supports subcommands
  • ✅ Familiar to CLI users

Option 3: typer (Modern CLI Framework)

Best for complex CLI applications with multiple commands.

importtyperimportosapp=typer.Typer()
@app.command()defserve(
port: int=typer.Option(
int(os.getenv('PORT', '8080')),
help="Server port"
),
service_hosts: str=typer.Option(
os.getenv('SERVICE_HOSTS', 'localhost:8080'),
help="Comma-separated service hosts"
),
debug: bool=typer.Option(
False,
help="Enable debug logging"
),
workers: int=typer.Option(
4,
min=1,
max=32,
help="Number of worker threads"
),
):
"""Start the application server"""hosts=service_hosts.split(',')
typer.echo(f"Starting server on port {port}")
typer.echo(f"Service hosts: {hosts}")
# ... rest of applicationif__name__=='__main__':
app()

Advantages:

  • ✅ Type-hint driven (minimal boilerplate)
  • ✅ Auto-generated help
  • ✅ Rich terminal output
  • ✅ Great for multi-command CLIs

Anti-Patterns to Avoid

❌ Bad: getopt (Legacy, Verbose)

importgetoptimportsys# ❌ DON'T: Use getopt (legacy, verbose, error-prone)defmain(argv):
opts, args=getopt.getopt(argv, 'hp:', [
'port=',
'service-hosts=',
'debug',
])
port=8080hosts=Nonedebug=Falseforopt, arginopts:
ifopt=='-h':
print('Usage: ...')
sys.exit()
elifoptin ('-p', '--port'):
port=int(arg)
elifoptin ('--service-hosts'):
hosts=arg.split(',')
elifoptin ('--debug'):
debug=True# ... rest

Why it's bad:

  • Manual option parsing (error-prone)
  • No automatic help text
  • No type validation
  • Verbose boilerplate
  • Hard to maintain

Fix: Use argparse or typer instead

❌ Bad: Wrong Type Annotations

importos# ❌ DON'T: Type annotation doesn't match realityhosts: list[str] =os.getenv('SERVICE_HOSTS')
# Returns str | None, not list[str]!# ✅ DO: Correct type handlinghosts: list[str] |None=Noneservice_hosts_env=os.getenv('SERVICE_HOSTS')
ifservice_hosts_env:
hosts=service_hosts_env.split(',')

❌ Bad: No Validation

importos# ❌ DON'T: No validation, crashes laterport=int(os.getenv('PORT')) # Crashes if PORT not set or invalid# ✅ DO: Validate and provide defaultsport_str=os.getenv('PORT', '8080')
try:
port=int(port_str)
ifport<1orport>65535:
raiseValueError(f"Port must be 1-65535, got {port}")
exceptValueErrorase:
print(f"Invalid PORT: {e}")
sys.exit(1)
# ✅ BETTER: Use Pydantic for automatic validationclassConfig(BaseSettings):
port: int=Field(8080, ge=1, le=65535)

❌ Bad: Logging Secrets

importosimportloggingpassword=os.getenv('DATABASE_PASSWORD')
# ❌ DON'T: Log actual secretslogging.info(f"Database password: {password}")
# ✅ DO: Log presence/length onlylogging.info(f"Database password configured: {passwordisnotNone}")
logging.info(f"Database password length: {len(password) ifpasswordelse0}")

See:python-logging-guide.md for safe logging patterns

❌ Bad: No None Handling

importospassword=os.getenv('PASSWORD') # Returns None if not set# ❌ DON'T: Assume value existsprint(f"Password length: {len(password)}") # Crashes if None# ✅ DO: Handle None safelyifpasswordisNone:
print("ERROR: PASSWORD environment variable not set")
sys.exit(1)
print(f"Password length: {len(password)}")

❌ Bad: Reading Env at Import Time

# config.py# ❌ DON'T: Read env vars at module import timeDATABASE_URL=os.getenv('DATABASE_URL') # Evaluated when module is importedAPI_KEY=os.getenv('API_KEY')
# Problem: Tests can't override these values after import# Problem: Values are "frozen" at import time# Problem: Circular import issues in complex apps
# ✅ DO: Read env vars in functions or use lazy loading# Option 1: Function that reads on demanddefget_database_url() ->str:
returnos.getenv('DATABASE_URL', 'sqlite:///default.db')
# Option 2: Class with lazy initializationclassConfig:
_instance: 'Config | None'=Nonedef__init__(self):
self.database_url=os.getenv('DATABASE_URL')
self.api_key=os.getenv('API_KEY')
@classmethoddefget(cls) ->'Config':
ifcls._instanceisNone:
cls._instance=cls()
returncls._instance# Option 3: Pydantic BaseSettings (recommended)classSettings(BaseSettings):
database_url: strapi_key: str# Instantiate in main(), not at module leveldefmain():
settings=Settings()

Why import-time reading is bad:

  • Tests can't monkeypatch values after import
  • Configuration is "frozen" at import time
  • Hard to debug when values don't change
  • Prevents configuration from different sources

Common Patterns

Pattern 1: Environment with CLI Override

importargparseimportosdefparse_args():
parser=argparse.ArgumentParser()
# Load default from env, allow CLI overrideparser.add_argument(
'--database-url',
default=os.getenv('DATABASE_URL'),
help='Database connection string'
)
args=parser.parse_args()
# Validate required argumentsifnotargs.database_url:
parser.error("DATABASE_URL must be set via env or --database-url")
returnargs

Pattern 2: Boolean Environment Variables

importosdefenv_bool(key: str, default: bool=False) ->bool:
"""Parse boolean from environment variable"""value=os.getenv(key, '').lower()
ifvaluein ('true', '1', 'yes', 'on'):
returnTrueifvaluein ('false', '0', 'no', 'off', ''):
returndefaultraiseValueError(f"Invalid boolean value for {key}: {value}")
# Usagedebug=env_bool('DEBUG', default=False)
feature_enabled=env_bool('FEATURE_ENABLED', default=False)

Pattern 3: List from Environment

importosdefenv_list(key: str, separator: str=',', default: list[str] |None=None) ->list[str]:
"""Parse list from environment variable"""value=os.getenv(key)
ifvalueisNone:
returndefaultor []
return [item.strip() foriteminvalue.split(separator) ifitem.strip()]
# Usagehosts=env_list('SERVICE_HOSTS', default=['localhost:8080'])
# SERVICE_HOSTS="host1:8080,host2:8080" → ['host1:8080', 'host2:8080']

Pattern 4: Required vs Optional

importosimportsysdefrequire_env(key: str) ->str:
"""Get required environment variable or exit"""value=os.getenv(key)
ifvalueisNone:
print(f"ERROR: {key} environment variable must be set")
sys.exit(1)
returnvalue# Usageapi_key=require_env('API_KEY') # Exits if not setlog_level=os.getenv('LOG_LEVEL', 'INFO') # Optional with default

Pattern 5: Pydantic with CLI Override

Combine Pydantic BaseSettings with argparse for best of both:

frompydanticimportBaseSettings, FieldimportargparseclassConfig(BaseSettings):
port: int=Field(8080, env="PORT")
debug: bool=Field(False, env="DEBUG")
workers: int=Field(4, env="WORKERS")
classConfig:
env_file=".env"defparse_args():
# Load config from env firstconfig=Config()
# Allow CLI overridesparser=argparse.ArgumentParser()
parser.add_argument('--port', type=int, default=config.port)
parser.add_argument('--debug', action='store_true', default=config.debug)
parser.add_argument('--workers', type=int, default=config.workers)
args=parser.parse_args()
# Update config with CLI overridesconfig.port=args.portconfig.debug=args.debugconfig.workers=args.workersreturnconfigdefmain():
config=parse_args()
print(f"Port: {config.port}")

Pattern 6: Enum-Based Configuration

fromenumimportEnumfrompydanticimportBaseSettings, validatorclassEnvironment(str, Enum):
DEVELOPMENT="development"STAGING="staging"PRODUCTION="production"classLogLevel(str, Enum):
DEBUG="DEBUG"INFO="INFO"WARNING="WARNING"ERROR="ERROR"classConfig(BaseSettings):
environment: Environment=Environment.DEVELOPMENTlog_level: LogLevel=LogLevel.INFO@validator("environment", pre=True)defparse_environment(cls, v):
ifisinstance(v, str):
returnv.lower()
returnv# Usageconfig=Config() # ENV=production LOG_LEVEL=DEBUGifconfig.environment==Environment.PRODUCTION:
# Production-specific behaviorpass

Advantages:

  • Type-safe, IDE autocompletion
  • Prevents invalid values
  • Self-documenting allowed values
  • Clear comparison logic

Pattern 7: Path Handling with pathlib

frompathlibimportPathfrompydanticimportBaseSettings, validatorclassConfig(BaseSettings):
data_dir: Path=Path("./data")
log_file: Path=Path("./logs/app.log")
config_file: Path|None=None@validator("data_dir", "log_file", pre=True)defparse_path(cls, v):
ifisinstance(v, str):
returnPath(v).expanduser().resolve()
returnv@validator("data_dir")defensure_dir_exists(cls, v):
v.mkdir(parents=True, exist_ok=True)
returnv# Usageconfig=Config()
# DATA_DIR=~/mydata → /home/user/mydata (expanded and resolved)# pathlib operationsforfileinconfig.data_dir.glob("*.json"):
print(file.name)

Key points:

  • Use pathlib.Path instead of str for filesystem paths
  • Call .expanduser() to handle ~ home directory
  • Call .resolve() to get absolute paths
  • Create directories with .mkdir(parents=True, exist_ok=True)

Configuration Validation

Startup Validation

frompydanticimportBaseSettings, Field, ValidationError, validatorimportsysclassConfig(BaseSettings):
port: int=Field(..., ge=1, le=65535)
workers: int=Field(..., ge=1, le=128)
database_url: str@validator('database_url')defvalidate_database_url(cls, v):
ifnotv.startswith(('postgresql://', 'mysql://')):
raiseValueError('Database URL must start with postgresql:// or mysql://')
returnvclassConfig:
env_file=".env"defmain():
try:
config=Config()
exceptValidationErrorase:
print("Configuration error:")
forerrorine.errors():
print(f" {error['loc']}: {error['msg']}")
sys.exit(1)
print("Configuration valid")
# ... start application

Logging Configuration on Startup

importloggingfrompydanticimportBaseSettingsclassConfig(BaseSettings):
port: int=8080debug: bool=Falseservice_hosts: list[str]
api_key: strdefmain():
config=Config()
# Configure logging based on configifconfig.debug:
logging.getLogger().setLevel(logging.DEBUG)
# Log configuration (safely)logging.info(f"Port: {config.port}")
logging.info(f"Service hosts: {config.service_hosts}")
logging.info(f"API key configured: {config.api_keyisnotNone}")
logging.info(f"Debug mode: {config.debug}")

Exception Handling in CLI Applications

Use Specific Exception Handlers Before Broad Catch-All

Constraint: MUST handle specific exception types before generic except Exception, and include KeyboardInterrupt handler.

Rationale: Specific handlers provide better error messages and appropriate exit codes. Broad catch-all should only handle truly unexpected errors.

Examples:

# [GOOD] - Specific exception types with appropriate error messagesimportsysimportyamlfrompydanticimportValidationErrordefmain():
args=parse_args()
# Load configuration with specific error handlingtry:
config=Config()
exceptValidationErrorase:
logger.error("Configuration error:")
forerrorine.errors():
logger.error(f" {error['loc']}: {error['msg']}")
sys.exit(1)
# Execute command with specific error handlingtry:
ifargs.command=="process":
process_files(args.file_path)
exceptFileNotFoundErrorase:
logger.error(f"File not found: {e}")
sys.exit(1)
exceptyaml.YAMLErrorase:
logger.error(f"YAML parsing error: {e}")
sys.exit(1)
exceptRuntimeErrorase:
logger.error(f"Runtime error: {e}")
sys.exit(1)
except (OSError, IOError) ase:
logger.error(f"I/O error: {e}")
sys.exit(1)
exceptKeyboardInterrupt:
logger.info("Interrupted by user")
sys.exit(130) # Standard UNIX exit code for SIGINTexceptException:
logger.exception("Unexpected error occurred")
sys.exit(1)
# [BAD] - Only broad catch-alldefmain():
try:
config=Config()
process_files(args.file_path)
exceptExceptionase:
print(f"Error: {e}") # No context about what failedsys.exit(1)

Exit Code Conventions

  • 0 - Success
  • 1 - General error (configuration, runtime, unexpected)
  • 2 - Command-line usage error (argparse handles this)
  • 130 - Interrupted by Ctrl+C (128 + SIGINT signal number)

Reference: netcup-dns project (src/netcup_dns/__main__.py) demonstrates comprehensive exception handling for CLI tools.

CLI Command Module Organization

Command Module Pattern for Subcommands

Constraint: CLI applications with subcommands MUST organize each command as a separate module with a single public function.

Rationale: Keeps commands isolated, testable, and maintainable; clear separation of concerns.

Structure:

src/
package/
__main__.py # CLI routing and setup
commands/
__init__.py
backup.py # def backup_photos(...)
info.py # def show_device_info(...)
list_devices.py # def list_connected_devices(...)

Implementation:

# src/package/__main__.py"""Entry point for the application."""importargparseimportloggingimportsysfrompydanticimportValidationErrorfrompackage.commands.backupimportbackup_photosfrompackage.commands.infoimportshow_device_infofrompackage.commands.list_devicesimportlist_connected_devicesfrompackage.configimportConfigfrompackage.logging_setupimportconfigure_logginglogger=logging.getLogger(__name__)
defparse_args() ->argparse.Namespace:
"""Parse command-line arguments."""parser=argparse.ArgumentParser(
description="Application description",
formatter_class=argparse.ArgumentDefaultsHelpFormatter,
)
parser.add_argument(
"--log-level",
default="INFO",
choices=["DEBUG", "INFO", "WARNING", "ERROR"],
help="Logging level",
)
parser.add_argument(
"--config",
default="config.yaml",
help="Configuration file path",
)
# Subcommandssubparsers=parser.add_subparsers(dest="command", required=True)
# backup subcommandbackup_parser=subparsers.add_parser("backup", help="Backup data")
backup_parser.add_argument("-d", "--backup-dir", help="Backup directory")
# info subcommandsubparsers.add_parser("info", help="Show information")
# list-devices subcommandsubparsers.add_parser("list-devices", help="List connected devices")
returnparser.parse_args()
defmain() ->None:
"""Main entry point."""args=parse_args()
# Load configuration with error handlingtry:
config=Config(config_file=args.config)
exceptValidationErrorase:
configure_logging("ERROR")
logger.error("Configuration error:")
forerrorine.errors():
field=".".join(str(x) forxinerror["loc"])
logger.error(f" {field}: {error['msg']}")
sys.exit(1)
# Configure loggingconfigure_logging(args.log_level)
# Route to command modulestry:
ifargs.command=="backup":
backup_photos(args.backup_dir, config.config_file)
elifargs.command=="info":
show_device_info(config.config_file)
elifargs.command=="list-devices":
list_connected_devices(config.config_file)
exceptFileNotFoundErrorase:
logger.error(f"File not found: {e}")
sys.exit(1)
exceptRuntimeErrorase:
logger.error(f"Runtime error: {e}")
sys.exit(1)
exceptKeyboardInterrupt:
logger.info("Interrupted by user")
sys.exit(130)
exceptException:
logger.exception("Unexpected error occurred")
sys.exit(1)
if__name__=="__main__":
main()
# src/package/commands/backup.py"""Backup command implementation."""importloggingfrompackage.backupimportBackupServicelogger=logging.getLogger(__name__)
defbackup_photos(backup_dir: str|None, config_file: str) ->None:
"""Backup all photos. Args: backup_dir: Backup directory path (None to use config default) config_file: Configuration file path """logger.info("Starting backup")
backup=BackupService(backup_dir, config_file)
success=backup.run()
ifnotsuccess:
raiseRuntimeError("Backup failed")

Key patterns:

  • Each command = one module with one public function
  • Command functions take simple arguments (not argparse.Namespace)
  • __main__.py handles routing, logging setup, and exception boundaries
  • Command modules focus on business logic delegation only
  • Exception handling at routing layer, not in command modules

main.py Module Pattern

Constraint: CLI applications MUST use src/package/__main__.py as entry point to enable python -m package execution.

Rationale: Standard Python pattern for executable modules; supports both python -m and console script execution; keeps main() testable.

Examples:

# [GOOD] - __main__.py as entry point# src/package/__main__.py"""Entry point for package."""importsysdefmain() ->None:
"""Main entry point."""# ... implementationif__name__=="__main__":
main()

Usage:

# Method 1: python -m
python -m package backup --backup-dir /tmp
# Method 2: console script (configured in pyproject.toml)
package-cli backup --backup-dir /tmp

pyproject.toml configuration:

[project.scripts]
package-cli = "package.__main__:main"

Benefits:

  • Enables python -m package execution
  • Consistent with Python module execution conventions
  • main() function is importable for testing
  • Works with both installed and development mode

Command Routing Pattern

Constraint: Command routing MUST use if/elif chain or dict dispatch, NOT dynamic imports.

Rationale: Explicit routing is easier to debug, type-check, and navigate.

Examples:

# [GOOD] - Explicit routing with if/elifdefmain() ->None:
args=parse_args()
config=load_config(args.config)
ifargs.command=="backup":
backup_photos(args.backup_dir, config)
elifargs.command=="restore":
restore_photos(args.restore_dir, config)
elifargs.command=="list":
list_photos(config)
# [GOOD] - Dict dispatch for many commandsCOMMANDS= {
"backup": backup_photos,
"restore": restore_photos,
"list": list_photos,
"verify": verify_photos,
}
defmain() ->None:
args=parse_args()
config=load_config(args.config)
command_fn=COMMANDS.get(args.command)
ifcommand_fnisNone:
raiseValueError(f"Unknown command: {args.command}")
command_fn(args, config)
# [BAD] - Dynamic import (hard to type-check and debug)defmain() ->None:
args=parse_args()
module=__import__(f"package.commands.{args.command}")
command_fn=getattr(module, f"run_{args.command}")
command_fn(args)

Reference: iphone-image-backup project (src/iphone_backup/__main__.py) demonstrates complete command module pattern with subcommands.

Decision Framework

Which Approach to Use?

Use CaseRecommended ApproachWhy
Web service (FastAPI, Flask)Pydantic BaseSettingsAuto-validates, integrates with FastAPI, type-safe
Daemon/background workerPydantic BaseSettingsCentralized config, validation, .env support
CLI tool with subcommandstyperRich CLI, type-hints, minimal boilerplate
Simple scriptargparseStdlib, familiar, sufficient for simple cases
Legacy codebaseargparseEasy migration from getopt, no new dependencies

Environment vs CLI Arguments?

Environment variables when:

  • Config varies by deployment (dev/staging/prod)
  • Running in containers/cloud
  • Values are secrets or rarely change
  • Used by CI/CD pipelines

CLI arguments when:

  • Need runtime control (debug mode, dry-run)
  • Developer/operator overrides
  • Values vary per execution
  • One-off operations or testing

Both (env with CLI override) when:

  • Need deployment defaults but allow runtime override
  • Developer flexibility + production stability
  • Example: PORT=8080 in prod, --port 3000 in dev

Testing Configuration

Testing with Environment Variables

importosimportpytestdeftest_config_from_env(monkeypatch):
monkeypatch.setenv('PORT', '9000')
monkeypatch.setenv('DEBUG', 'true')
config=Config()
assertconfig.port==9000assertconfig.debugisTruedeftest_config_validation(monkeypatch):
monkeypatch.setenv('PORT', '99999') # Invalid portwithpytest.raises(ValueError):
Config()

Testing CLI Arguments

importargparseimportpytestdeftest_parse_args():
parser=create_parser()
args=parser.parse_args(['--port', '3000', '--debug'])
assertargs.port==3000assertargs.debugisTruedeftest_required_argument_missing():
parser=create_parser()
withpytest.raises(SystemExit):
parser.parse_args([]) # Missing required args

Edge Cases and Gotchas

Empty String vs Unset

importos# These are DIFFERENT:# - VAR="" → os.getenv('VAR') returns ""# - VAR not set → os.getenv('VAR') returns Nonevalue=os.getenv('VAR')
# ❌ DON'T: Treat empty string as unsetifnotvalue: # True for both "" and Nonevalue='default'# ✅ DO: Distinguish between empty and unsetifvalueisNone:
value='default'# Only when truly unset# ✅ DO: Use explicit default if empty should also use defaultvalue=os.getenv('VAR') or'default'# Treats "" as unset

Whitespace in Environment Variables

importos# Shell: export NAME=" Alice "name=os.getenv('NAME') # Returns " Alice " (with spaces)# ✅ DO: Strip whitespace for string valuesname=os.getenv('NAME', '').strip()
# ✅ DO: Strip items in listsdefenv_list(key: str) ->list[str]:
value=os.getenv(key, '')
return [item.strip() foriteminvalue.split(',') ifitem.strip()]

Boolean Value Ambiguity

# These are all used in the wild:# DEBUG=true, DEBUG=True, DEBUG=TRUE# DEBUG=1, DEBUG=yes, DEBUG=on# DEBUG=false, DEBUG=0, DEBUG=no, DEBUG=offdefenv_bool(key: str, default: bool=False) ->bool:
"""Parse boolean with common variations"""value=os.getenv(key, '').lower().strip()
ifvaluein ('true', '1', 'yes', 'on'):
returnTrueifvaluein ('false', '0', 'no', 'off', ''):
returndefaultraiseValueError(f"Invalid boolean for {key}: '{value}'")
# ✅ Pydantic handles this automatically with proper typingclassConfig(BaseSettings):
debug: bool=False# Parses "true", "1", "yes", etc.

Integer Parsing Edge Cases

importos# ❌ DON'T: Crash on invalid inputport=int(os.getenv('PORT')) # Crashes if "abc" or None# ✅ DO: Validate with clear errorsdefenv_int(key: str, default: int|None=None) ->int:
value=os.getenv(key)
ifvalueisNone:
ifdefaultisNone:
raiseValueError(f"{key} must be set")
returndefaulttry:
returnint(value.strip())
exceptValueError:
raiseValueError(f"{key} must be integer, got: '{value}'")

Case Sensitivity

# Environment variables are case-sensitive on Unix, case-insensitive on Windows# ❌ DON'T: Assume case behavioros.getenv('database_url') # May not match DATABASE_URL on Unix# ✅ DO: Use consistent casing (UPPER_SNAKE_CASE is convention)os.getenv('DATABASE_URL')

Related Concepts

Summary

  • Combine env vars + CLI args - Env for defaults, CLI for overrides
  • Use Pydantic BaseSettings for applications (type-safe, validated)
  • Use argparse for scripts (stdlib, familiar, sufficient)
  • Use typer for complex CLIs (modern, type-hint driven)
  • Avoid getopt (legacy, verbose, error-prone)
  • Validate at startup - Fail fast with clear errors
  • Never log secrets - Log presence/length only
  • Handle None safely - Check before using env var values
  • Provide clear defaults - Document expected env vars
  • Type annotations must match reality - os.getenv() returns str | None