Python settings management often feels like a tangled mess of .env files, hardcoded defaults, and environment variables that magically appear. pydantic-settings cuts through this chaos by treating your application’s configuration as just another Python object.

Imagine you’re building a web service. It needs a database URL, a secret key, and maybe a port number. Instead of scattering these around, you define them as a Pydantic model:

from pydantic_settings import BaseSettings, SettingsConfigDict

class AppSettings(BaseSettings):
    database_url: str
    secret_key: str
    port: int = 8000  # Default value if not provided

    model_config = SettingsConfigDict(
        env_file=".env",
        env_file_encoding="utf-8",
    )

settings = AppSettings()

print(f"Connecting to: {settings.database_url}")
print(f"Using port: {settings.port}")

Now, if you have a .env file in the same directory like this:

DATABASE_URL="postgresql://user:password@host:port/dbname"
SECRET_KEY="supersecretkey123"

When AppSettings() is instantiated, pydantic-settings automatically loads these values. The env_file directive tells it where to look for a .env file, and env_file_encoding ensures it reads it correctly.

The real magic is how it prioritizes sources. By default, it looks in this order:

  1. Environment Variables: These are the highest priority. If DATABASE_URL is set in your shell, it will be used, overriding anything in .env.
  2. .env File: If not found in environment variables, it checks the specified .env file.
  3. Pydantic Model Defaults: If neither environment variables nor the .env file provide a value, the default values defined in the Pydantic model (like port=8000) are used.

This layered approach gives you incredible flexibility. You can have sensible defaults in your code, override them with a .env file for local development, and then use environment variables for deployment in different environments (staging, production, etc.).

Let’s see it in action. Create a file named main.py:

from pydantic_settings import BaseSettings, SettingsConfigDict
from typing import Optional

class ServiceConfig(BaseSettings):
    api_key: str
    timeout_seconds: int = 30
    debug_mode: bool = False
    log_level: str = "INFO"
    optional_setting: Optional[str] = None

    model_config = SettingsConfigDict(
        env_prefix="MYAPP_", # Prefix for env vars and .env
        env_file=".env",
        env_file_encoding="utf-8",
    )

# --- Test Cases ---

# Case 1: All values in .env
print("--- Case 1: All values in .env ---")
# Create a temporary .env file
with open(".env", "w") as f:
    f.write("MYAPP_API_KEY=env_api_key_123\n")
    f.write("MYAPP_DEBUG_MODE=True\n")
    f.write("MYAPP_OPTIONAL_SETTING=hello\n")

config1 = ServiceConfig()
print(f"API Key: {config1.api_key}")
print(f"Timeout: {config1.timeout_seconds}")
print(f"Debug Mode: {config1.debug_mode}")
print(f"Log Level: {config1.log_level}")
print(f"Optional Setting: {config1.optional_setting}")
import os
os.remove(".env") # Clean up

# Case 2: Override with environment variables
print("\n--- Case 2: Override with environment variables ---")
with open(".env", "w") as f:
    f.write("MYAPP_API_KEY=env_api_key_456\n")
    f.write("MYAPP_TIMEOUT_SECONDS=60\n")
    f.write("MYAPP_LOG_LEVEL=DEBUG\n")

# Set environment variables
os.environ["MYAPP_API_KEY"] = "env_var_api_key_789"
os.environ["MYAPP_DEBUG_MODE"] = "False" # Override from .env

config2 = ServiceConfig()
print(f"API Key: {config2.api_key}") # This should be from env var
print(f"Timeout: {config2.timeout_seconds}") # This should be from .env
print(f"Debug Mode: {config2.debug_mode}") # This should be from env var
print(f"Log Level: {config2.log_level}") # This should be from .env
print(f"Optional Setting: {config2.optional_setting}") # This should be None (not set)

# Clean up env vars and .env file
del os.environ["MYAPP_API_KEY"]
del os.environ["MYAPP_DEBUG_MODE"]
os.remove(".env")

# Case 3: Use defaults
print("\n--- Case 3: Use defaults ---")
# No .env file, no environment variables set for these
config3 = ServiceConfig()
print(f"API Key: {config3.api_key}") # This will raise a ValidationError because MYAPP_API_KEY is required and not set.
# To make this runnable, let's set a required value.
os.environ["MYAPP_API_KEY"] = "default_api_key_abc"
config3_runnable = ServiceConfig()
print(f"API Key (runnable): {config3_runnable.api_key}")
print(f"Timeout: {config3_runnable.timeout_seconds}") # Default
print(f"Debug Mode: {config3_runnable.debug_mode}") # Default
print(f"Log Level: {config3_runnable.log_level}") # Default
print(f"Optional Setting: {config3_runnable.optional_setting}") # Default

del os.environ["MYAPP_API_KEY"]

Running this script, you’ll see how the values change based on whether they are provided via environment variables, the .env file, or fall back to defaults. Notice the env_prefix="MYAPP_". This is incredibly useful for avoiding name collisions with other environment variables in your system. It means pydantic-settings will look for MYAPP_API_KEY in the environment or .env file, rather than just API_KEY.

The model_config attribute is where the SettingsConfigDict lives, allowing you to customize behavior. Beyond env_file and env_prefix, you can specify env_file_encoding, env_nested_delimiter (for nested settings), and secrets_dir (to load secrets from files).

The validation power of Pydantic is also available for your settings. If timeout_seconds is expected to be an integer but you provide "sixty" in your .env file, Pydantic will raise a ValidationError during instantiation, telling you exactly what’s wrong and where. This catches configuration errors early.

The primary mechanism for loading is a simple lookup: environment variables are checked first, then the .env file, then defaults. If a required setting isn’t found in any of these, Pydantic will raise a ValidationError. The env_file attribute in SettingsConfigDict is a string path to your .env file. If you want to load from multiple .env files or specify a different location, you can provide a list of paths.

pydantic-settings also handles type coercion automatically. If you define port: int and your .env file has PORT=8080, it will correctly parse "8080" into the integer 8080. This extends to booleans, floats, and even complex types if you define them with Pydantic’s support for nested models or custom types.

One of the most powerful, yet often overlooked, features is the ability to load secrets from files specified in the environment. If you set MYAPP_SECRET_KEY_FILE=/path/to/my/secret.txt, pydantic-settings will read the content of that file and use it as the value for secret_key. This is a much more secure way to handle sensitive credentials than putting them directly into .env files or environment variables, especially in containerized environments where secrets can be mounted as files.

The next step is often managing different configurations for different environments (development, staging, production) without duplicating your BaseSettings class.

Want structured learning?

Take the full Python course →