Configuration is part of every production application. Database URLs, API endpoints, logging levels, and feature flags often differ between local development, testing, and production.
A reliable configuration system makes these differences explicit, validates required values, and prevents the application from starting with an invalid setup.
Why Configuration Needs Structure
A small script might read an environment variable directly:
import os
database_url = os.getenv("DATABASE_URL")
This works, but it leaves several questions unanswered. What happens if the variable is missing? Is the value valid? Are related settings consistent? Will the application fail immediately or much later when it attempts to connect to the database?
Configuration should be treated as input that needs validation.
Use a Dedicated Settings Object
For a small application, a dataclass can provide a clear starting point:
from dataclasses import dataclass
import os
@dataclass(frozen=True)
class Settings:
database_url: str
debug: bool
def load_settings() -> Settings:
database_url = os.getenv("DATABASE_URL")
if not database_url:
raise ValueError("DATABASE_URL is required")
debug_value = os.getenv("DEBUG", "false").lower()
if debug_value not in {"true", "false"}:
raise ValueError("DEBUG must be true or false")
return Settings(
database_url=database_url,
debug=debug_value == "true",
)
The application can load its settings once during startup and pass the resulting object to the components that need it.
Using an immutable settings object reduces accidental changes after initialization.
Validate More Than Presence
A non-empty string is not necessarily a valid setting. Depending on the application, validation may include:
- Checking that URLs have an expected scheme.
- Ensuring port numbers are within valid ranges.
- Rejecting unsupported environment names.
- Verifying that required feature combinations are compatible.
- Ensuring production settings do not enable development-only behavior.
Keep validation close to configuration loading so invalid settings fail early.
For larger projects, libraries such as Pydantic Settings can provide type conversion, environment-variable mapping, nested configuration, and structured validation errors.
Separate Defaults from Required Values
Defaults are appropriate when a setting has a safe, well-understood value.
For example, a development logging level might default to INFO. A production database password should not silently default to an empty string.
Distinguish between optional settings, sensible defaults, and mandatory values. Document these choices so operators know which settings must be supplied during deployment.
Avoid Loading Configuration Everywhere
Repeatedly calling os.getenv() throughout the codebase makes dependencies difficult to discover.
Instead, load configuration in a defined place and pass the relevant values into services or application components. This also makes testing easier because tests can supply explicit settings rather than modifying global environment state.
Be careful not to expose secrets when printing or serializing the settings object.
Test Configuration Failures
Test both valid and invalid configurations:
- Required variables are missing.
- Boolean values contain unsupported strings.
- URLs are malformed.
- Environment-specific settings conflict.
- Optional values use their expected defaults.
These tests are inexpensive and can prevent confusing startup failures in production.
Conclusion
A robust Python configuration system validates external input, distinguishes required settings from defaults, and makes dependencies explicit. Start with a dedicated settings object and introduce a configuration library when the application needs more advanced validation or environment handling.
Failing early with a useful error is far better than discovering a configuration problem during a production request.