Python Configuration Management in 2026: pydantic-settings, Dynaconf, and Modern Secrets Handling

A hands-on guide to managing application configuration in Python projects using pydantic-settings, Dynaconf, and environment-aware secrets management for 2026.

Every Python application reads configuration from somewhere. A database URL, an API key, a port number, a feature flag. The real question is whether you are handling these settings in a way that survives the trip from a developer’s laptop to staging to production without something breaking silently.

Three tools dominate the Python configuration landscape right now: pydantic-settings, Dynaconf, and the classic python-dotenv with manual validation. Each one solves the problem differently, and picking the wrong one creates friction that compounds over time. This article walks through all three with real code, explains where each one earns its keep, and tackles the secrets management layer that production systems cannot skip.

Why configuration management matters more than you think

A small script that reads a single environment variable does not need a framework. But the moment your project grows past a handful of settings, things get messy fast. Database credentials, cache timeouts, feature flags, log levels, third-party API keys, deployment-specific overrides — these pile up quickly, and without structure they end up scattered across .env files, hardcoded defaults, and tribal knowledge.

The failure modes are predictable. A missing environment variable crashes your app at 2 AM. A typo in a config key goes unnoticed until a feature silently stops working. A secret committed to version control turns into a security incident that requires rotating every credential. Configuration management prevents all three by giving you a single source of truth backed by validation and type checking.

pydantic-settings: type-safe configuration with zero friction

pydantic-settings was extracted from the Pydantic ecosystem specifically to handle configuration. If you already use Pydantic for data validation, this is the obvious choice. Even if you do not, it remains one of the strongest options because it brings Pydantic’s type validation to environment variables and .env files.

Basic setup

Install the package and define a settings class:

# settings.py
from pydantic_settings import BaseSettings
from pydantic import Field

class AppSettings(BaseSettings):
    """Application configuration loaded from environment variables."""
    
    database_url: str = Field(
        default="sqlite:///./dev.db",
        description="Database connection string"
    )
    redis_url: str = Field(
        default="redis://localhost:6379/0",
        description="Redis connection URL"
    )
    api_key: str = Field(
        ...,
        description="Third-party API key (required)"
    )
    debug: bool = Field(default=False, description="Enable debug mode")
    log_level: str = Field(default="INFO", description="Logging level")
    max_connections: int = Field(default=10, ge=1, le=100)

    model_config = {
        "env_prefix": "APP_",
        "env_file": ".env",
        "env_file_encoding": "utf-8",
        "case_sensitive": False,
    }

Instantiating this class triggers a read from environment variables first, then falls back to the .env file, and finally applies the default values from the Field declarations. With env_prefix set to APP_, the database_url field reads from APP_DATABASE_URL, the api_key from APP_API_KEY, and so on.

# Usage
settings = AppSettings()
print(settings.database_url)   # from env or .env
print(settings.debug)          # False (default)
print(settings.max_connections)  # validated as int, 1-100 range

Validation happens automatically. Set APP_MAX_CONNECTIONS to "not-a-number" and you get a clear ValidationError at startup instead of a mysterious crash three layers deep in your application. Leave APP_API_KEY missing with no default and the application refuses to start with a message that tells you exactly what is missing.

Environment-specific overrides

pydantic-settings handles multiple environments through subclassing:

class DevelopmentSettings(AppSettings):
    debug: bool = True
    log_level: str = "DEBUG"
    model_config = {
        "env_prefix": "APP_",
        "env_file": ".env.dev",
    }

class ProductionSettings(AppSettings):
    debug: bool = False
    log_level: str = "WARNING"
    model_config = {
        "env_prefix": "APP_",
        "env_file": ".env.prod",
    }

A factory function picks the right class based on an environment variable:

import os

def get_settings() -> AppSettings:
    env = os.getenv("APP_ENV", "development")
    match env:
        case "production":
            return ProductionSettings()
        case "development":
            return DevelopmentSettings()
        case _:
            return AppSettings()

Nested configuration

Complex applications benefit from pydantic-settings’ support for nested models:

from pydantic import BaseModel

class DatabaseConfig(BaseModel):
    url: str = "sqlite:///./dev.db"
    pool_size: int = 5
    max_overflow: int = 10

class RedisConfig(BaseModel):
    url: str = "redis://localhost:6379/0"
    ttl: int = 3600

class AppSettings(BaseSettings):
    database: DatabaseConfig = DatabaseConfig()
    redis: RedisConfig = RedisConfig()
    api_key: str = ...

    model_config = {
        "env_prefix": "APP_",
        "env_nested_delimiter": "__",
    }

Set env_nested_delimiter to "__" and you configure the nested database URL with APP_DATABASE__URL. The environment variable namespace stays organized even when the number of settings grows past what fits in a flat structure.

Dynaconf: configuration for complex deployments

Dynaconf approaches the same problem from a different angle. While pydantic-settings prioritizes type safety and developer ergonomics, Dynaconf prioritizes deployment flexibility. It pulls configuration from multiple sources simultaneously — environment variables, TOML files, YAML files, INI files, and vault services. It also handles settings inheritance across environments in a way that scales to large teams juggling multiple deployment targets.

Basic setup

# settings.py
from dynaconf import Dynaconf

settings = Dynaconf(
    envvar_prefix="MYAPP",
    settings_files=["settings.toml", ".secrets.toml"],
    environments=True,
    load_dotenv=True,
)

The corresponding TOML files:

# settings.toml
[default]
debug = false
log_level = "INFO"
database_url = "sqlite:///./dev.db"

[development]
debug = true
log_level = "DEBUG"

[production]
debug = false
log_level = "WARNING"
database_url = "postgresql://prod-server/mydb"
# .secrets.toml (add to .gitignore)
[default]
api_key = "dev-key-123"

[production]
api_key = "prod-key-456"

Set ENV_FOR_DYNACONF to production and Dynaconf loads the [production] section, falling back to [default] for anything not defined there.

Environment variables and settings files together

Dynaconf’s layering system puts defaults in files and lets environment variables override them. The precedence runs: command-line arguments, environment variables, secrets files, settings files, default values. Production deployments can override any setting through environment variables without touching the settings files themselves.

# Accessing settings
print(settings.DEBUG)          # from settings.toml or env override
print(settings.API_KEY)        # from .secrets.toml or env override
print(settings.DATABASE_URL)   # from settings.toml or env override

Validators and hooks

Dynaconf ships with validators that run at startup to catch misconfiguration before it causes problems:

from dynaconf import Validator

settings.validators.register(
    Validator("DATABASE_URL", must_exist=True),
    Validator("LOG_LEVEL", is_in=["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]),
    Validator("MAX_CONNECTIONS", gte=1, lte=100),
)
settings.validators.validate()

A failing validator raises an error immediately, before your application ever touches the invalid configuration.

Integration with vault services

For production secrets, Dynaconf connects to HashiCorp Vault, AWS SSM Parameter Store, and Azure Key Vault:

settings = Dynaconf(
    envvar_prefix="MYAPP",
    settings_files=["settings.toml"],
    environments=True,
    vault_enabled=True,
    vault_url="http://vault.example.com:8200",
    vault_token="s.your-vault-token",
)

Secrets stored in Vault show up alongside your regular settings. Your application gets one interface for all configuration, regardless of whether a value came from a file, an environment variable, or a secrets manager.

python-dotenv: the lightweight option

For smaller projects or scripts that do not need a full configuration framework, python-dotenv handles the basics. It reads .env files and loads them into os.environ. Pair it with manual validation and you have a workable solution.

# settings.py
import os
from dotenv import load_dotenv

load_dotenv()

DATABASE_URL = os.environ["DATABASE_URL"]  # raises KeyError if missing
API_KEY = os.environ["API_KEY"]
DEBUG = os.getenv("DEBUG", "false").lower() in ("true", "1", "yes")
LOG_LEVEL = os.getenv("LOG_LEVEL", "INFO")
MAX_CONNECTIONS = int(os.getenv("MAX_CONNECTIONS", "10"))

This works fine for projects with fewer than twenty settings. Beyond that, you spend more time writing boilerplate than the framework would have required. Type validation is manual, error messages are unhelpful (a missing variable produces a raw KeyError), and there is no built-in support for nested configuration or environment-specific overrides.

The tradeoff is simplicity. No extra dependencies past python-dotenv, no class definitions to maintain, no framework-specific patterns to learn. For a utility script or a small project with a known deployment target, that simplicity is genuinely appealing.

Choosing between the three

Project scope and team size drive the decision.

Use pydantic-settings when your project already uses Pydantic for data models, you want type-safe configuration with automatic validation, and your deployment model is straightforward — a few environments with environment variable overrides. The developer experience is polished, validation errors are readable, and integration with the broader Pydantic ecosystem lets your configuration models reference shared types.

Use Dynaconf when you manage multiple deployment environments across different teams, you need configuration from multiple sources (files, environment variables, vault services), or your settings structure is complex enough to benefit from hierarchical overrides. Dynaconf’s environment management and vault integration make it the stronger pick for production systems with serious DevOps requirements.

Use python-dotenv when your project is small, your settings are few, and you do not need type validation or environment-specific overrides. Personal projects, prototypes, and scripts running in a single known environment are its sweet spot.

Secrets management patterns

Regardless of which configuration framework you pick, secrets need special handling. API keys, database passwords, and tokens must never appear in version control. They should load from a source that restricts access in production.

The .env file approach

The simplest pattern: add a .env file to .gitignore. This covers local development and small deployments. Create a .env.example that lists the required variables without their values:

# .env.example
DATABASE_URL=sqlite:///./dev.db
API_KEY=your-api-key-here
REDIS_URL=redis://localhost:6379/0

Developers copy .env.example to .env and fill in their local values. The .env file never gets committed. Both pydantic-settings and Dynaconf read .env files without additional configuration.

Cloud secrets managers

Production deployments should use a dedicated secrets manager. The major cloud providers all offer managed services:

  • AWS Systems Manager Parameter Store stores secrets as parameters with access policies and automatic rotation. boto3 provides the Python interface.
  • HashiCorp Vault runs as a centralized service for secret storage with fine-grained access control and audit logging.
  • Google Cloud Secret Manager integrates with IAM for access control and supports versioning and rotation.

The pattern holds across providers: your application authenticates with the cloud provider (through IAM roles, service accounts, or tokens), fetches secrets at startup, and exposes them through your configuration class.

Environment variables in containers

For containerized deployments, environment variables remain the most portable mechanism. Kubernetes Secrets inject environment variables into pods. Docker Compose supports .env files and secrets configuration. AWS ECS and Google Cloud Run accept environment variables directly.

The principle is straightforward: secrets enter the container through the runtime environment, not through the code or the image. Your configuration framework reads them from the environment regardless of how they arrived.

Real-world configuration structure

A production Python application typically needs fifteen to thirty configuration values spread across several domains. Here is a structure that holds up under real use:

from pydantic_settings import BaseSettings
from pydantic import Field

class DatabaseSettings(BaseModel):
    url: str
    pool_size: int = Field(default=5, ge=1, le=50)
    pool_timeout: int = Field(default=30, ge=5)
    echo: bool = False

class RedisSettings(BaseModel):
    url: str = "redis://localhost:6379/0"
    ttl: int = Field(default=3600, ge=60)

class EmailSettings(BaseModel):
    smtp_host: str = "smtp.gmail.com"
    smtp_port: int = 587
    username: str = ""
    password: str = ""
    from_address: str = ""

class LogSettings(BaseModel):
    level: str = Field(default="INFO", pattern="^(DEBUG|INFO|WARNING|ERROR|CRITICAL)$")
    format: str = "%(asctime)s - %(name)s - %(levelname)s - %(message)s"
    file: str | None = None

class Settings(BaseSettings):
    app_name: str = "MyApp"
    debug: bool = False
    database: DatabaseSettings
    redis: RedisSettings = RedisSettings()
    email: EmailSettings = EmailSettings()
    logging: LogSettings = LogSettings()

    model_config = {
        "env_prefix": "APP_",
        "env_nested_delimiter": "__",
        "env_file": ".env",
    }

Each domain gets its own model with sensible defaults. Environment variables override any value through the nested delimiter convention: APP_DATABASE__URL, APP_LOGGING__LEVEL, APP_EMAIL__SMTP_HOST. The .env file provides defaults for local development, and production overrides everything through environment variables injected by your deployment platform.

Migration strategy

If your project currently uses raw environment variables or a config.py with manual parsing, migrate incrementally. Define a pydantic-settings or Dynaconf class that mirrors your existing configuration structure. Add the .env.example file. Replace one configuration access point at a time, testing after each change. A single-commit rewrite of your entire configuration system is a recipe for a long debugging session.

The first module to migrate is whichever one reads the most settings or has the most validation logic. That is where the framework pays for itself immediately. Secondary modules can follow in later commits once the pattern is established and your teammates have seen it in action.

Configuration testing

Both pydantic-settings and Dynaconf support testing with overridden values. Write tests that verify your settings class accepts valid configurations and rejects invalid ones:

def test_settings_require_api_key():
    """Settings should raise an error if API key is missing."""
    import os
    os.environ.pop("APP_API_KEY", None)
    try:
        Settings(_env_file=None)
        assert False, "Should have raised ValidationError"
    except ValidationError as e:
        assert "api_key" in str(e)

def test_debug_mode_from_env():
    """Debug flag should read from environment."""
    os.environ["APP_DEBUG"] = "true"
    settings = Settings(_env_file=None)
    assert settings.debug is True
    del os.environ["APP_DEBUG"]

These tests catch configuration regressions before they reach production. Add them to your CI pipeline so that changes to the settings class or environment variables get validated automatically.

Putting it all together

Configuration management is not exciting work, but it is the foundation every other part of your application depends on. A well-structured configuration system prevents the “it works on my machine” class of bugs, makes deployments predictable, and keeps secrets out of version control.

Start with pydantic-settings if you are building something new. It gives you type safety, clear error messages, and a good developer experience with minimal setup. Move to Dynaconf if your deployment needs grow complex enough to require multi-source configuration and vault integration. Use python-dotenv for small projects where the overhead of a framework does not justify the benefits.

Whichever approach you choose, enforce a few non-negotiable rules. Every required setting produces a clear error message when it is missing. Secrets live outside version control, loaded at runtime from environment variables or a secrets manager. Configuration gets validated at startup, not at the point of use. Stick to these rules and you eliminate an entire category of deployment failures, freeing yourself to focus on the code that actually matters.

Spread The Article

Share this guide

Send this article to your network or keep a copy of the direct link.

X Facebook LinkedIn Reddit Telegram

Discussion

Leave a comment

No comments yet

Be the first to start the conversation.