Skip to content

Repository files navigation

Logo

CI PyPI - version Python - versions License

Overview

Every Python application — whether it's a simple API, notebook or multi-agent pipeline — needs the same thing under the hood: settings. Model names, URLs, database passwords, timeouts, feature flags. In any real app we have to work with settings — the question is why wirio-settings.

Here's why: our application settings, one line, done right. No more scattered os.environ calls, no more silent typos in environment variable names, no more manual .env parsing — just a typed Pydantic model, loaded from wherever our settings actually live and always up to date.

  • Great defaults from day one: It automatically looks for settings files and environment variables, with recommended configurations and one line of code.
  • Rust-powered core: Built with Rust under the hood for speed, reliability, and low runtime overhead.
  • Secret stores: Load secrets and certificates from Azure Key Vault, AWS Secrets Manager and GCP Secret Manager, with one line of code and safe authentication.
  • Automatic refreshes: Keep settings up to date by automatically refreshing them, with no need to restart the application or deploy a new version.
  • Pydantic models: Load application settings directly into models.
  • Configuration stores: Load settings and feature flags from pluggable configuration stores, such as Azure App Configuration.
  • A practical replacement: Replace pydantic-settings and python-dotenv with one centralized, provider-agnostic (no vendor lock-in) settings library.
  • Roadmap: Planned capabilities include more configuration stores, CDN-accelerated delivery, object storages, push refresh, Databricks support, prefixes, filters, custom delimiters and aliases.

Table of contents

📦 Installation

uv add wirio-settings

🚀 Get started

In this mini-tutorial, we configure a small application step by step. Each step builds on the previous one, and the final result is a fully typed settings model that works in local and in production.

1. Introduction

We'll use SettingsManager, which by default reads:

  • Environment variables.
  • The settings.local.yaml file when it exists, that we'll use for local development.

YAML is a modern alternative to .env files that supports typed values and structured settings.

The file name is standardized by well-known frameworks and tools such as Claude Code and GitHub Copilot, enabling environment-specific configuration. As we'll see later, local is the environment we use when developing on our machines.

2. Read settings

We create a settings.local.yaml file in our working directory (it's usually the root of the repository) with the following contents:

openai_api_key: secretkey
openai_model: gpt-5
timeout_seconds: 30
postgresql_connection_string: postgresql+asyncpg://user:password@localhost/database

Warning

Never commit secrets to version control. This file is for local development only, and it should be ignored in .gitignore.

And read the settings using SettingsManager:

from wirio_settings import SettingsManager


settings_manager = SettingsManager()

openai_api_key = settings_manager.get_value("openai_api_key")

Values are returned as strings unless we pass a type as the second argument, which validates and converts the value.

We also can load optional settings with try_get_value, which returns None when the setting is missing.

Take into account that, independently of the origin of the setting, it'll always be converted to snake_case because it's the Python convention. For example, the environment variable POSTGRESQL_CONNECTION_STRING maps to the key postgresql_connection_string.

Note

If we're comfortable with this simplified approach, or we're prototyping (for example from a Jupyter notebook), we can stop here. The rest of the mini-tutorial is about production-ready practices.

3. Bind the settings to a Pydantic model

Reading key by key is fine for a couple of values. For an application, we usually want one validated object instead of using the Magic Strings anti-pattern:

from pydantic import BaseModel
from wirio_settings import SettingsManager


class ApplicationSettings(BaseModel):
    openai_api_key: str
    openai_model: str
    timeout_seconds: int
    postgresql_connection_string: str


application_settings = SettingsManager().get_model(ApplicationSettings)

We'll use typed and Pydantic capabilities to express optional values, defaults and nested models.

4. Add environment-specific settings

As explained in Default providers, SettingsManager loads settings.yaml, settings.{environment}.yaml, and environment variables. If the files are missing, they are skipped.

We use different settings per environment. For example, we may want to use a different database connection string, URL or API key in production.

So, we just create a settings file for each environment we want to support. For example:

  • settings.local.yaml for local development.
  • settings.staging.yaml for staging.
  • settings.production.yaml for production.

The environment will be detected (details in Environments) and the proper file will be loaded automatically.

Talking about the settings.yaml file, it's used for shared settings that are common to all environments. For example, we may want to use the same OpenAI model in all environments. Using this file we can avoid repeating the same value in all the environment-specific files.

Now our settings are tracked in version control, and we can have different values for each environment without changing the application code or giving developers excessive cloud permissions just to change a setting.

Warning

Never commit secrets to version control. The tracked settings files should contain only non-sensitive values. To load secrets when we deploy (when we're not developing in local), we'll use a secret store (e.g. Azure Key Vault or AWS Secrets Manager) or a different mechanism, as explained in the next section.

5. Read the secrets securely

When we're not developing in local, we want to read secrets from a secure location instead of exposing them in a file.

Choose the provider (more in Providers) that matches how the application receives its secrets. Some common providers are:

  • Azure Key Vault for Azure workloads.
  • AWS Secrets Manager for AWS workloads.
  • GCP Secret Manager for GCP workloads.
  • Setting per file when the runtime mounts secrets as files, such as Docker or Kubernetes secret volumes.
  • Environment variables when the deployment platform injects secret values, often through a cloud secret store link or using Kubernetes External Secrets Operator. The application reads the injected value; the platform is responsible for resolving the secret store reference.

For example, we can read the secrets from Azure Key Vault:

class ApplicationSettings(BaseModel):
    openai_api_key: str
    openai_model: str
    timeout_seconds: int
    postgresql_connection_string: str


application_settings = (
    SettingsManager()
    .add_azure_key_vault("https://example.vault.azure.net/")
    .get_model(ApplicationSettings)
)

Realize that Azure Key Vault only can store PascalCase or kebab-case secrets, but as they are normalized to snake_case, they're mapped to the Pydantic model fields without any extra code.

Note

We only need to add the secret store provider when we're not developing in local, so we usually add an if statement to check the environment and then adding the provider.

6. Summary

We have a single ApplicationSettings model that works in local and in production, with no extra code. The settings are loaded from the right provider depending on the environment, and we can add more providers if needed.

The next sections are very important to understand how the settings system works, and they include topics such as the core concepts, how to read values, or the providers themselves.

For a complete recommended usage of how to use wirio-settings in production, see Recommended usage.

Core concepts

Providers and priority

A provider is a source of settings, such as a YAML file, the environment variables, or a secret store. wirio-settings supports multiple providers at the same time, and it merges them into a single flat set of keys.

When the same key exists in several providers, the last added provider wins:

settings_manager = SettingsManager()  # Adds the default providers
settings_manager.add_azure_key_vault(  # Overrides the defaults
    "https://example.vault.azure.net/"
)

Default providers

SettingsManager adds the following providers, in this order:

  1. settings.yaml
  2. settings.{environment}.yaml
  3. Environment variables

Considerations:

  • The files are optional. If a file is not found, it's skipped.
  • The environment variables have a higher priority than the files, because their provider is added last.
  • Any provider we add afterwards has a higher priority than all the defaults.

To start from an empty settings manager, disable the defaults:

settings_manager = SettingsManager(add_default_providers=False)

We can also add the defaults later with add_default_providers(), for example to place them above a provider we added first.

Environments

By default, SettingsManager reads the PYTHONAPP_ENVIRONMENT environment variable to determine the environment name, and it defaults to local when the variable is not set. For example, PYTHONAPP_ENVIRONMENT=production loads settings.production.yaml, which is optional.

To use an environment variable with a different name, pass environment_key:

settings_manager = SettingsManager(environment_key="CUSTOM_ENVIRONMENT")

With CUSTOM_ENVIRONMENT=production, the default providers load settings.production.yaml.

Key naming and nesting

Every provider has its own naming convention, and not every store allows the same characters in a key. For example, neither Azure Key Vault nor GCP Secret Manager allow dots in the key name. wirio-settings normalizes all of them into the same shape:

  • Keys are converted to snake case. APP_NAME, appName, AppName, and app-name all map to app_name.

  • Sections are separated with ., as in database.host or logging.log_level.default. Some providers may use different separators internally, but they are normalized to . in the SettingsManager.

  • Each provider declares how sections are written in its own store. For example:

    Provider Section separator Example Setting key
    YAML file, JSON file Nested objects database: {host: …} database.host
    Environment variables __ DATABASE__HOST database.host
    Azure Key Vault, GCP Secret Manager -- Database--Host database.host
    AWS Secrets Manager Nested JSON {"database": {…}} database.host
    Setting per file None database.host file database.host

Sequences are flattened with their index, so the first item of the servers list is servers.0.

Content root

Relative file paths are resolved against the content root, which is the current working directory by default. To resolve them against another directory, pass an absolute content_root_path:

settings_manager = SettingsManager(content_root_path="/opt/orders-api")

Reading settings

Read one value

Use get_value when the key must exist. It raises a KeyError when the key is missing:

openai_api_key = settings_manager.get_value("openai_api_key")

Use try_get_value for optional keys. It returns None when the key is missing or its value is None:

openai_api_key = settings_manager.try_get_value("openai_api_key")

Typed values

By default, the settings system returns values as strings. To validate and convert to another type, pass the type as a second argument:

maximum_retries = settings_manager.get_value("maximum_retries", int)
enable_cache = settings_manager.try_get_value("enable_cache", bool)

The conversion is done internally by Pydantic, so an invalid value raises a validation error. Lists and dictionaries are read from several keys, so they are best read through a model instead of a single value.

Pydantic models

get_model builds a model from the settings, mapping each field name to a setting key:

from pydantic import BaseModel
from wirio_settings import SettingsManager


class ApplicationSettings(BaseModel):
    app_name: str
    port: int | None = None


application_settings = SettingsManager().get_model(ApplicationSettings)
  • If a field has a default, that default is used when no value is found. Here, port defaults to None when missing.
  • If a required field is missing, get_model raises a KeyError.

Nested models

A field annotated with another model is bound to the section with the same name:

database:
  host: localhost
  port: 5432
class DatabaseSettings(BaseModel):
    host: str
    port: int


class ApplicationSettings(BaseModel):
    database: DatabaseSettings

Lists and dictionaries

Lists are read from indexed keys, and dictionaries are read from the children of a section. Both work with scalars and with models:

ports:
  - 8080
  - 8081

servers:
  - name: api
    retries: 3
  - name: worker

services:
  api:
    url: https://api.example.com
  worker:
    url: https://worker.example.com
class Server(BaseModel):
    name: str
    retries: int = 3


class Service(BaseModel):
    url: str


class ApplicationSettings(BaseModel):
    ports: list[int]
    servers: list[Server]
    services: dict[str, Service]

Sections

Use get_section to read a group of settings that share a prefix. For example, we can read the next YAML:

logging:
  log_level: WARNING
logging_section = settings_manager.get_section("logging")
log_level = logging_section.get_value("log_level")

A section behaves like the settings manager itself, so it supports getting values, subsections and Pydantic models.

logging_settings = settings_manager.get_section("logging").get_model(LoggingSettings)

get_section raises a KeyError when the key is not a section.

Recommended usage

If we use environment variables for sensitive and non-sensitive settings, we don't have to do anything.

application_settings = SettingsManager().get_model(ApplicationSettings)

But we should read non-sensitive settings from settings files (settings.{environment}.yaml) tracked in version control.

To do that, we can add the PYTHONAPP_ENVIRONMENT environment variable to the deployed application. For example, PYTHONAPP_ENVIRONMENT=production, and the settings.production.yaml file will be loaded automatically.

Note

If we want to use another environment variable, we can pass environment_key to SettingsManager, as explained in Environments.

Now, we have all the pieces in place, but some of the integrations should only be activated when the application is deployed. In local, we don't want to touch secret stores, instrument libraries, send telemetry to the cloud, use HSTS, add CORS, enable caching, use some authentication mechanisms, etc. so we have to add a simple environment check.

This might sound like an extra layer of complexity, but it's what we must do independently of the settings library we use.

For example, if we use the PYTHONAPP_ENVIRONMENT environment variable to detect the current environment, we can add a secret volume in this way:

from os

from fastapi import FastAPI
from wirio_settings import SettingsManager


settings_manager = SettingsManager()

if os.getenv("PYTHONAPP_ENVIRONMENT", "local") != "local":
    settings_manager.add_setting_per_file("/run/secrets")

    # Enable telemetry, etc.

application_settings = settings_manager.get_model(ApplicationSettings)
app = FastAPI()

Providers

YAML file

settings_manager.add_yaml_file("settings.yaml")

Comments are supported in YAML files. The filename may be a relative path, such as ../settings.yaml, which is resolved against the content root. Absolute paths are used as they are.

Options:

  • optional=True skips the file if it is missing. The file is required by default.
  • refresh_enabled=True refreshes values when the file changes.

JSON file

settings_manager.add_json_file("settings.json")

Comments are not supported in JSON files. The filename may be a relative path, such as ../settings.json, which is resolved against the content root. Absolute paths are used as they are.

Options:

  • optional=True skips the file if it is missing. The file is required by default.
  • refresh_enabled=True refreshes values when the file changes.

Environment variables

settings_manager.add_environment_variables()

Keys are normalized to snake case, and __ is replaced with .. For example, DATABASE__HOST maps to database.host.

Azure Key Vault

Read secrets and certificates from Azure Key Vault.

settings_manager.add_azure_key_vault(
    "https://example.vault.azure.net",
)

Note

For authentication options, see Azure credentials.

Azure permissions: Usually, the Key Vault Secrets User role is used to read secrets.

Secret names use -- for sections, so Database--Host maps to database.host.

To periodically refresh the loaded secrets, use the refresh_interval parameter, described in Refresh on an interval.

Azure App Configuration

Overview

Read from Azure App Configuration:

  • Settings (also called configuration): Key-value pairs (raw and JSON values), Key Vault references and snapshot references.
  • Snapshots.
  • Enhanced feature flags.
settings_manager.add_azure_app_configuration(
    "https://example.azconfig.io",
)

By default, it loads all settings without a label.

To load enhanced feature flags, set feature_flag_enabled=True. By default, it'll load enhanced feature flags without a label.

Note

For authentication options, see Azure credentials.

Azure permissions: Usually, the App Configuration Data Reader role is used to read settings. The same identity is also used to resolve Azure Key Vault references, which generally require the Key Vault Secrets User role on each referenced vault.

Settings and enhanced feature flags are normalized to snake case.

JSON settings with the application/json content type are parsed into nested settings under the setting key. For example, a service setting with {"logging":{"level":"information"}} becomes service.logging.level.

Enhanced feature flags are loaded as a JSON string at feature_management, ready for use with the official featuremanagement Microsoft package. For example, using a key instead of a Pydantic model:

import json

from featuremanagement import FeatureManager

feature_manager = FeatureManager(
    json.loads(settings_manager.get_value("feature_management"))
)

As feature_management is a JSON string, it needs to be parsed before use, as shown in the example above, so don't create a FeatureManager singleton.

To have a FeatureManager singleton, wirio-settings would have to provide a SettingsManager.create_feature_manager() method, which is currently not available. Technically speaking, it'd return a FeatureManager instance initialized with the parsed feature_management JSON string. It'd be parsed to a FeatureManagerConfiguration class (inheriting from Mapping[str, Any]), which would be refreshed whenever the feature_management JSON string is updated.

Selectors

Use selectors to control which settings and enhanced feature flags are loaded. A selector accepts a filter and an optional label. When we omit the label, it selects values without a label.

We could use setting selectors to only load the settings of a service, for example our payment API, and for that we create settings in Azure App Configuration with a prefix representing the service (payment) followed by a separator (. for example).

The following code would only load the settings starting with the payment prefix.

from wirio_settings import (
    SettingsManager,
)
from wirio_settings.azure.app_configuration import SettingSelector

settings_manager = SettingsManager().add_azure_app_configuration(
    "https://example.azconfig.io",
    selectors=[
        SettingSelector("payment.*"),
    ],
)

Trim key prefixes

To remove prefixes from settings, for example, to remove the payment. prefix from all payment-related settings (payment.api_key would become api_key), set trim_key_prefixes. Use feature_flag_trim_name_prefixes to remove prefixes from enhanced feature flag names in the same way.

from wirio_settings import SettingsManager
from wirio_settings.azure.app_configuration import FeatureFlagSelector, SettingSelector

settings_manager = SettingsManager().add_azure_app_configuration(
    "https://example.azconfig.io",
    selectors=[SettingSelector("payment.*")],
    trim_key_prefixes=["payment."],
    feature_flag_selectors=[FeatureFlagSelector("payment.*")],
    feature_flag_trim_name_prefixes=["payment."],
)

We can also add selectors for enhanced feature flags, also load common and specific settings, etc. More information is available in the Microsoft official documentation.

Snapshots

Snapshots are immutable sets of settings selected when the snapshot is created. They replace most per-application selector logic, make the resulting configuration easier to inspect, and support controlled rollouts and rollback to a Last Known-Good (LKG) configuration.

Load a snapshot by name with a SettingSelector. Changing the selected snapshot name requires a code change and redeployment.

Only snapshots with key composition are supported. This composition retains one value per key, and when multiple labels match, it retains the value from the last applicable filter. Snapshots with key_label composition are rejected because they can retain multiple values for one key.

from wirio_settings import SettingsManager
from wirio_settings.azure.app_configuration import SettingSelector


settings_manager = SettingsManager().add_azure_app_configuration(
    "https://example.azconfig.io",
    selectors=[SettingSelector(snapshot_name="payment-api-2026-10-15")],
)

Snapshot references

Snapshot references are regular settings that point to snapshots. wirio-settings resolves them automatically. Updating a reference to point at another immutable snapshot changes the configuration on the next refresh without changing application code or redeploying it.

The referenced snapshot's settings are merged at the reference's position in the selected results, so later settings can override them. References inside a referenced snapshot are not resolved.

AI settings

AI settings are JSON values, so wirio-settings parses them like every other JSON setting in Azure App Configuration.

AWS Secrets Manager

settings_manager.add_aws_secrets_manager(
    "secret-id",
)

Note

For authentication options, see AWS credentials.

The secret value must be a JSON object. wirio-settings reads and flattens that JSON into settings keys.

Options:

  • region selects the AWS region.
  • url overrides the service endpoint, which is useful when testing against a local emulator.

GCP Secret Manager

settings_manager.add_gcp_secret_manager("project-id")

Note

For authentication options, see GCP credentials.

Secret names use -- for sections, so Database--Host maps to database.host.

Setting per file

settings_manager.add_setting_per_file("/run/secrets")

Given a directory, each file name becomes a setting key and the file content becomes the setting value. The directory path must be absolute, because it is not resolved against the content root.

Options:

  • optional=True skips the directory if it is missing. The directory is required by default.
  • refresh_enabled=True refreshes values when directory contents change.

This provider is useful when secrets are mounted as files by the runtime instead of exposed as environment variables. It lets us keep application code unchanged while switching the secret delivery mechanism.

Common use cases:

  • Kubernetes with Secrets Store CSI Driver where providers such as Azure Key Vault mount each secret as a file into a volume.
  • Docker secret mounts (for example, /run/secrets).
  • Platform-managed secret volumes in production environments where file-based delivery is preferred.

Example directory:

/run/secrets/
    database_password
    openai_api_key

Then the values are available as database_password and openai_api_key.

This provider does not translate any separator, so the file name is used as the setting key. To read a nested key, include the . in the file name, as in database.host.

Automatic refreshes

Long-running applications, such as web servers or background jobs, can keep their settings up to date without restarting or redeploying.

Refresh on file change

The file and directory providers watch their source when refresh_enabled=True:

settings_manager.add_yaml_file("settings.yaml", refresh_enabled=True)

Refresh on an interval

Azure Key Vault refreshes its secrets in the background when refresh_enabled=True. The provider waits for refresh_interval between refresh attempts, and it keeps the last successfully loaded settings if a refresh fails:

settings_manager.add_azure_key_vault(
    "https://example.vault.azure.net", refresh_enabled=True
)

Pydantic model refreshes

Models returned by get_model() are automatically updated when a provider refreshes its values, so there is no need to call get_model() again:

from pydantic import BaseModel
from wirio_settings import SettingsManager


class ApplicationSettings(BaseModel):
    port: int


application_settings = (
    SettingsManager()
    .add_yaml_file("settings.yaml", refresh_enabled=True)
    .get_model(ApplicationSettings)
)

When settings.yaml changes its contents, application_settings.port is updated in place. If the refreshed values don't validate against the model, the existing model values are retained.

Authentication

Each cloud provider uses its official SDK for authentication. The name of an authentication mechanism (and the way of adding it) can differ between providers and programming languages, even when it represents the same type of authentication mechanism. wirio-settings provides a simple and readable way to select the authentication mechanism, and it passes it to the provider SDK.

Default authentication

Default authentication is the simplest option: we provide no authentication code and the identity is discovered automatically by the provider SDK. This is the recommended option for most applications, because it works in local and in production without any extra code.

When we need a more explicit, stronger, and faster authentication path, we can pass the provider's authentication mechanism directly. This constrains the identity the application may use and avoids credential-provider discovery.

Azure credentials

Azure uses its default credential chain when we don't pass an AzureCredential.

The credential provider chain tries credentials in this order and uses the first one that succeeds:

  1. Environment credential (AZURE_CLIENT_ID, AZURE_CLIENT_SECRET, AZURE_TENANT_ID)
  2. Workload identity credential
  3. Developer tools credential (Azure CLI / Azure Developer CLI)
  4. Managed identity credential. This is the System-assigned managed identity by default. If we want to use a User-assigned managed identity, set the AZURE_CLIENT_ID environment variable.

Use AzureCredential to select an authentication mechanism:

  • Default() uses the default Azure credential provider chain.
  • AzureCli() authenticates through the Azure CLI.
  • AzureDeveloperCli() authenticates through the Azure Developer CLI.
  • ClientSecret(tenant_id, client_id, client_secret) uses service principal credentials.
  • ManagedIdentityCredential() uses a managed identity.
  • WorkloadIdentityCredential() uses a workload identity.

For example, to use explicit service principal credentials:

from wirio_settings.azure.identity import AzureCredential

settings_manager.add_azure_key_vault(
    "https://example.vault.azure.net",
    AzureCredential.ClientSecret("tenant-id", "client-id", "client-secret"),
)

AWS credentials

AWS uses its default credential provider chain when we don't pass an AwsCredential.

The credential provider chain can use an IAM role, the shared AWS configuration profile, or AWS_* environment variables.

Use AwsCredential to select an authentication mechanism:

  • Default() uses the default AWS credential provider chain.
  • EnvironmentVariable() only reads AWS_* environment variables.
  • Key(access_key_id, secret_access_key) uses long-lived access keys.
  • ProfileFile(profile_name=None) uses the default profile when no name is supplied.
  • Session(access_key_id, secret_access_key, session_token) uses temporary credentials.

For example, to use explicit access keys:

from wirio_settings.aws.identity import AwsCredential

settings_manager.add_aws_secrets_manager(
    "secret-id",
    AwsCredential.Key("access_key_id", "secret-access-key"),
)

GCP credentials

GCP uses Application Default Credentials (ADC) when we don't pass credentials. To use a specific authentication mechanism, pass its JSON credentials with the credentials_json parameter.

Explanatory notes

Environment identifier

Across the ecosystem, environment selectors commonly follow the TECHNOLOGY_ENVIRONMENT pattern. We use PYTHONAPP_ENVIRONMENT because Python's official environment variables start with the PYTHON prefix, as documented in the Python environment variables reference. The PYTHONAPP_ prefix keeps that convention while distinguishing application configuration from Python interpreter settings.

Troubleshooting

Debug settings

Use debug_repr() to inspect settings and their providers. When several providers contain the same key, the value from the provider with the highest priority is shown.

print(settings_manager.debug_repr())

Common errors

Error Cause
KeyError: Missing setting value for key '…' No provider contains the key. Check the spelling, the section separator, and the priority.
ValueError: Setting value for key '…' is None The key exists, but it has no value. For example, a YAML key declared without a value.
KeyError: Setting key '…' is not a section get_section was called with a key that has no children.
A validation error from Pydantic The value exists but it cannot be converted to the requested type.

Releases

Packages

Contributors

Languages