Manage Configuration

Problem

Hardcoded configuration makes applications inflexible. Different environments need different settings without code changes. Managing secrets securely while avoiding version control exposure is challenging. Type validation ensures configuration correctness.

This guide shows effective configuration management in Python.

Environment Variables

Reading Environment Variables

import os

database_url = os.getenv('DATABASE_URL')

port = os.getenv('PORT', '8080')

def get_required_env(key):
    value = os.getenv(key)
    if value is None:
        raise ValueError(f"Required environment variable not set: {key}")
    return value

api_key = get_required_env('API_KEY')

max_connections = int(os.getenv('MAX_CONNECTIONS', '10'))

debug = os.getenv('DEBUG', 'false').lower() in ('true', '1', 'yes')

Environment Variable Patterns

from dataclasses import dataclass
import os

@dataclass
class Config:
    database_url: str
    port: int
    debug: bool
    max_connections: int
    api_key: str

def load_config():
    return Config(
        database_url=get_required_env('DATABASE_URL'),
        port=int(os.getenv('PORT', '8080')),
        debug=os.getenv('DEBUG', 'false').lower() in ('true', '1'),
        max_connections=int(os.getenv('MAX_CONNECTIONS', '10')),
        api_key=get_required_env('API_KEY')
    )

def get_required_env(key):
    value = os.getenv(key)
    if value is None:
        raise ValueError(f"Required: {key}")
    return value

config = load_config()
print(f"Server running on port {config.port}")

python-dotenv for .env Files

Loading .env Files

from dotenv import load_dotenv
import os

load_dotenv()  # Loads .env from current directory

database_url = os.getenv('DATABASE_URL')

load_dotenv('.env.development')

load_dotenv(override=True)  # Override existing env vars

from pathlib import Path

env_path = Path('.') / '.env'
load_dotenv(dotenv_path=env_path)

.env file format:

DATABASE_URL=postgresql://localhost:5432/mydb
DB_MAX_CONNECTIONS=20

API_KEY=sk_test_abc123
API_BASE_URL=https://api.example.com

PORT=8080
DEBUG=true
LOG_LEVEL=info

.gitignore:

.env
.env.local
.env.*.local

!.env.example

Configuration Files

YAML Configuration

import yaml
from pathlib import Path

def load_yaml_config(filename):
    path = Path(filename)
    with path.open() as f:
        return yaml.safe_load(f)

"""
database:
  url: postgresql://localhost:5432/mydb
  max_connections: 20
  timeout: 30

api:
  base_url: https://api.example.com
  timeout: 10
  retry_attempts: 3

logging:
  level: info
  format: json
"""

config = load_yaml_config('config.yaml')
db_url = config['database']['url']
api_timeout = config['api']['timeout']

JSON Configuration

import json
from pathlib import Path

def load_json_config(filename):
    path = Path(filename)
    with path.open() as f:
        return json.load(f)

def save_json_config(filename, config):
    path = Path(filename)
    with path.open('w') as f:
        json.dump(config, f, indent=2)

"""
{
  "database": {
    "url": "postgresql://localhost:5432/mydb",
    "max_connections": 20
  },
  "debug": false
}
"""

config = load_json_config('config.json')

TOML Configuration

import tomli  # Python < 3.11
from pathlib import Path

def load_toml_config(filename):
    path = Path(filename)
    with path.open('rb') as f:
        return tomli.load(f)

"""
[database]
url = "postgresql://localhost:5432/mydb"
max_connections = 20

[api]
base_url = "https://api.example.com"
timeout = 10

[logging]
level = "info"
"""

config = load_toml_config('config.toml')

Pydantic Settings

Type-Safe Configuration

from pydantic_settings import BaseSettings
from pydantic import Field, validator

class Settings(BaseSettings):
    # Database
    database_url: str
    db_max_connections: int = 10

    # API
    api_key: str
    api_base_url: str = "https://api.example.com"

    # Application
    app_name: str = "MyApp"
    debug: bool = False
    port: int = 8080

    # Custom validation
    @validator('port')
    def validate_port(cls, v):
        if v < 1024 or v > 65535:
            raise ValueError('Port must be between 1024 and 65535')
        return v

    class Config:
        env_file = '.env'
        env_file_encoding = 'utf-8'
        case_sensitive = False

settings = Settings()

print(f"Database: {settings.database_url}")
print(f"Port: {settings.port}")

settings = Settings(_env_file='.env.production')

Nested Configuration

from pydantic_settings import BaseSettings
from pydantic import BaseModel

class DatabaseConfig(BaseModel):
    url: str
    max_connections: int = 10
    timeout: int = 30

class APIConfig(BaseModel):
    base_url: str
    key: str
    timeout: int = 10

class Settings(BaseSettings):
    database: DatabaseConfig
    api: APIConfig
    debug: bool = False

    class Config:
        env_file = '.env'
        env_nested_delimiter = '__'


settings = Settings()
print(settings.database.url)
print(settings.api.key)

Configuration Priority

Layered Configuration

from pydantic_settings import BaseSettings

class Settings(BaseSettings):
    database_url: str = "postgresql://localhost/default"
    port: int = 8080
    debug: bool = False

    class Config:
        # Priority (highest to lowest):
        # 1. Environment variables
        # 2. .env file
        # 3. Class defaults
        env_file = '.env'

Multiple Configuration Sources

import os
from pathlib import Path

def load_config():
    # 1. Load defaults
    config = {
        'port': 8080,
        'debug': False,
        'max_connections': 10,
    }

    # 2. Load from config file
    config_file = Path('config.yaml')
    if config_file.exists():
        import yaml
        with config_file.open() as f:
            file_config = yaml.safe_load(f)
            config.update(file_config)

    # 3. Override with environment variables
    env_mapping = {
        'PORT': 'port',
        'DEBUG': 'debug',
        'MAX_CONNECTIONS': 'max_connections',
    }

    for env_key, config_key in env_mapping.items():
        value = os.getenv(env_key)
        if value is not None:
            # Type conversion based on default
            if isinstance(config[config_key], bool):
                config[config_key] = value.lower() in ('true', '1', 'yes')
            elif isinstance(config[config_key], int):
                config[config_key] = int(value)
            else:
                config[config_key] = value

    return config

config = load_config()

argparse for Command-Line Args

Basic Argument Parsing

import argparse

def parse_args():
    parser = argparse.ArgumentParser(
        description='My Application'
    )

    # ✅ Positional argument
    parser.add_argument('input', help='Input file path')

    # ✅ Optional argument
    parser.add_argument(
        '--output', '-o',
        default='output.txt',
        help='Output file path'
    )

    # ✅ Boolean flag
    parser.add_argument(
        '--verbose', '-v',
        action='store_true',
        help='Verbose output'
    )

    # ✅ Integer argument
    parser.add_argument(
        '--port', '-p',
        type=int,
        default=8080,
        help='Server port'
    )

    # ✅ Choices
    parser.add_argument(
        '--format',
        choices=['json', 'xml', 'csv'],
        default='json',
        help='Output format'
    )

    return parser.parse_args()

args = parse_args()
print(f"Input: {args.input}")
print(f"Output: {args.output}")
print(f"Verbose: {args.verbose}")

Security Best Practices

Avoiding Hardcoded Secrets

import os

API_KEY = "sk_live_secret123"  # NEVER
DATABASE_PASSWORD = "password"  # NEVER

def get_api_key():
    key = os.getenv('API_KEY')
    if not key:
        raise ValueError("API_KEY not set")
    return key

class Settings(BaseSettings):
    api_key: str  # Required - will raise error if missing
    db_password: str  # Required

    class Config:
        env_file = '.env'

settings = Settings()

Secret Management

import boto3
import json

def get_secret(secret_name):
    client = boto3.client('secretsmanager')
    response = client.get_secret_value(SecretId=secret_name)
    return json.loads(response['SecretString'])

db_credentials = get_secret('prod/database')
database_url = db_credentials['url']
password = db_credentials['password']

class SecretCache:
    def __init__(self):
        self._cache = {}

    def get(self, key):
        if key not in self._cache:
            self._cache[key] = get_secret(key)
        return self._cache[key]

cache = SecretCache()
db_creds = cache.get('prod/database')

Configuration Validation

Validating Configuration

from pydantic_settings import BaseSettings
from pydantic import validator, Field
from typing import Literal

class Settings(BaseSettings):
    port: int = Field(..., ge=1024, le=65535)
    max_connections: int = Field(..., gt=0, le=100)
    log_level: Literal['DEBUG', 'INFO', 'WARNING', 'ERROR']
    database_url: str

    @validator('database_url')
    def validate_database_url(cls, v):
        if not v.startswith(('postgresql://', 'mysql://')):
            raise ValueError('Invalid database URL')
        return v

    @validator('max_connections')
    def validate_connections(cls, v, values):
        # Cross-field validation
        if values.get('debug') and v > 10:
            raise ValueError('Max 10 connections in debug mode')
        return v

try:
    settings = Settings()
except ValueError as e:
    print(f"Configuration error: {e}")

Summary

Configuration management in Python uses environment variables as the primary mechanism with python-dotenv for development convenience. Environment variables work across deployment platforms and keep secrets out of version control.

python-dotenv loads .env files automatically without requiring environment variable setup on each machine. Production uses actual environment variables. Never commit .env files - use .env.example templates showing required variables.

Configuration files in YAML, JSON, or TOML formats provide structured configuration. YAML offers readability with comments, JSON integrates with web APIs, TOML provides clear syntax for nested configuration. Choose based on complexity and tooling.

Pydantic Settings combines environment variables, .env files, and type validation. Define configuration as typed classes with validators, pydantic handles parsing and validation. Nested models organize complex configuration hierarchically.

Configuration priority follows environment variables > config files > defaults. This ordering lets operators override settings at runtime. Pydantic Settings implements this priority automatically with env_file and class defaults.

argparse handles command-line arguments with type checking and help text generation. Parse arguments at startup, combine with environment variables for flexible configuration. Use for CLI applications and scripts.

Never hardcode secrets in source code. Load from environment variables, secret management services (AWS Secrets Manager, HashiCorp Vault), or encrypted files. Fail immediately if required secrets are missing rather than using defaults.

Validation ensures configuration correctness at startup. Check required fields exist, validate ranges for numbers, verify URLs and connection strings. Use Pydantic validators for automatic validation with clear error messages.

Type-safe configuration with Pydantic provides autocomplete, type checking, and validation. Define fields with types, defaults, and constraints. Validation catches configuration errors during initialization, not during request handling.

Configuration best practices include explicit environment variable naming, .env files for development, separate configurations per environment, and validation on startup. These patterns produce reliable, secure, maintainable configuration management.

Related Content

Last updated