Posted on 30 Dec 2025

Any application, regardless of its size or domain, is always composed of two fundamental parts:
The code defines what the application does, while the configuration defines how it behaves and in which context it runs. Keeping these two aspects clearly separated is a cornerstone of modern software engineering and becomes critical as soon as an application needs to run in multiple environments (development, staging, production). See principle 3 of the 12-Factor App.
Configuration itself can be further divided into two categories:
This article builds a reusable Python configuration module that plugs into virtually any project. As a concrete example, we use the Running Races application introduced in Building a Flask Application Using Three-Tier Architecture with Python and SQLAlchemy.
Although the reference project is a Flask application, the configuration approach is framework-agnostic and reusable as-is in other Python applications. The tool doing the heavy lifting is pydantic-settings, a library built on top of Pydantic specifically designed for managing application configuration.
You should read this article if:

Non-secret configuration parameters live in a dedicated configuration file named config.yml, written in YAML.
If you are not familiar with YAML, you can refer to these two introductory articles:
Check out the config.yml file of our application.
Although JSON is widely used, it is primarily designed for data payloads exchanged over the network, not for configuration files. JSON has several limitations in this context:
YAML, on the other hand, is explicitly designed for configuration:
For these reasons, YAML is a much better fit for application configuration.
Secrets should never be stored in configuration files committed to version control. Instead, they should be injected via environment variables, especially in containerized or cloud-native applications.
This approach:

Pydantic-settings provides a clean and powerful way to load configuration from multiple sources (YAML files, environment variables) and map it directly into typed Pydantic models.
Its main advantages are:
.env filesA good practice when designing configuration files is to split them into logical sections, each corresponding to a subsystem of the application. For example:
database:
host: <hostname>
port: <port>
user: <user>
sslmode: <ssl mode>
log:
level: "INFO"
console: true
file: "logs/races.log"
rotation: "10 MB"
retention: "7 days"
compression: "zip"
Each section can then be mapped to its own Pydantic model. In our reference application, the database is SQLite, so the configuration is much simpler:
database:
relative_path: "instance/dev.db"
The application-specific parameters are defined as follows:
app:
# secret_key is loaded from environment variables
debug: true
host: "0.0.0.0"
port: 5001
One of the key benefits of Pydantic Settings is validation. Configuration errors should be caught as early as possible, ideally at startup.
Whenever feasible, parameters should have sensible default values to reduce the likelihood of runtime failures.

Each configuration section is represented by a dedicated Pydantic Settings class. At a high level, the structure looks like this:
AppConfig → application-level settingsDatabaseConfig → database configurationLoggingConfig → logging behaviorSettings → root object aggregating all sectionsEach class defines:
Here an example of AppConfig:
class AppConfig(BaseSettings):
"""Application configuration settings."""
debug: bool = Field(default=True, description="Debug mode")
host: str = Field(default="0.0.0.0", description="Host address") # nosec B104
port: int = Field(default=5001, description="Port number")
secret_key: str | None = Field(
default=None, description="Secret key from environment"
)
model_config: ClassVar[SettingsConfigDict] = SettingsConfigDict(extra="ignore")
As you can notice, this class represents the app section in the config.yml. It defines each parameter with its own type, description, and default value.
The secret_key is a security-sensitive parameter used by Flask to sign session cookies and other cryptographic data. For this reason, it is not expected to be stored in the YAML file, but injected at runtime from environment variables.
The model_config attribute defines how pydantic-settings handles configuration loading for this model. With extra="ignore", any undeclared configuration parameters are silently ignored instead of causing a startup failure. This makes the configuration layer more resilient to user errors and environment-specific noise.
The YAML configuration file is loaded once and used to populate the Pydantic models. Secrets are injected from the environment, not from the configuration file itself.
For brevity, this article focuses on the structure and concepts. The complete implementation for DatabaseConfig and LoggingConfig can be found in the referenced project files.
The Settings class is the single entry point for application configuration. It aggregates all configuration sections and delegates parsing, typing, validation, and default handling to pydantic-settings.
class Settings(BaseSettings):
app: AppConfig
database: DatabaseConfig
log: LogConfig
Each attribute maps to a section of config.yml, making the configuration structure explicit and self-documenting.
Configuration loading is entirely handled by Pydantic — no manual parsing logic is required:
model_config: ClassVar[SettingsConfigDict] = SettingsConfigDict(
yaml_file="config.yml",
env_file=".env",
env_file_encoding="utf-8",
extra="ignore",
)
With this setup:
Configuration is validated at startup. If a required field is missing, has the wrong type, or a section is malformed, the application fails immediately. This guarantees that if the application is running, configuration is valid.
Default values defined in AppConfig, DatabaseConfig, and LogConfig are applied automatically, reducing boilerplate and startup errors.
Secrets are injected from environment variables after initialization:
def model_post_init(self, __context: Any) -> None:
secret_key = os.getenv("APP_SECRET_KEY")
if secret_key:
self.app.secret_key = secret_key
This keeps secrets out of YAML while preserving centralized validation.
Configuration is instantiated once:
settings: Settings = Settings()
From that point on, settings becomes the single source of truth for the entire application.
In short, the Settings class provides a clean, declarative configuration layer where parsing, validation, and defaults are handled in one place, and the application either starts with a valid configuration or does not start at all.
Check out the running-races repository on GitHub.

An important architectural detail is the dependency direction between configuration and logging.
This means:
Reversing this dependency would create a circular problem: logging would be needed before configuration is available. Keeping configuration at the lowest level avoids this issue entirely.
Secrets are loaded exclusively from environment variables. The configuration module is responsible for:
This ensures that:
By delegating this responsibility to Pydantic Settings, we also benefit from automatic type validation and error reporting.
In containerized environments, configuration is commonly injected through environment variables. This works well for **secrets, which should never be stored in files or committed to version control.
However, using environment variables for all configuration parameters quickly becomes hard to manage as the number of options grows. Configuration is scattered across deployment manifests and it becomes difficult to get a clear, complete view of how the application is configured.
Using a config.yml file for non-secret parameters solves this problem. YAML provides a structured, self-documented view of the entire configuration, with comments explaining the purpose of each parameter. Environment variables can then be reserved exclusively for secrets.
Different configurations for staging and production can be managed using Kubernetes ConfigMaps, as described in How I use Kubernetes ConfigMaps to manage configurations. This hybrid approach keeps configuration clear, scalable, and secure.
In this article we covered:
config.yml YAML fileAppConfig, DatabaseConfig, LogConfig)Settings class centralises loading, validation, and defaults in one placeThis approach scales naturally from small scripts to complex, containerized applications, while keeping configuration explicit, validated, and under control. The next article in the series builds on this foundation — stay tuned.
If you enjoyed this article, don’t forget to give it a clap 👏, share it with your friends 🔗, and follow me for more tips and tutorials on software development 📘. Your support helps me create more content like this — thank you! 🙌