How to Manage Environment Variables with Pydantic in 2025

Read Time: 6 minute(s)

Introduction

Environment variables are the backbone of clean, configurable applications. Whether you’re building a web API, a data pipeline, or a CLI tool, hardcoding secrets or environment-specific values is a bad idea. Luckily, Python offers many solutions—like os.environ or .env files with python-dotenvBut if you’re using Pydantic (especially with FastAPI), there’s a better option: Pydantic Settings.

This tool provides a type-safe, declarative, and scalable way to manage configurations. Let’s explore how to use it effectively.

1. Why Pydantic Settings?

Traditionally, managing configs with python-dotenv and os.environ looks like this:

# config.py
from dotenv import load_dotenv
import os

load_dotenv()

APP_NAME = os.getenv("APP_NAME", "My App")
DEBUG = os.getenv("DEBUG", "false").lower() == "true"
DATABASE_URL = os.getenv("DATABASE_URL")

if not DATABASE_URL:
    raise ValueError("DATABASE_URL is required")

While this works, it’s manual and messy:

  • You must cast types yourself (e.g., DEBUG from string to bool).
  • Defaults require extra code.
  • Validation isn’t built-in.
  • Configs scatter across variables.
  • Maintenance gets harder as projects grow.

Compare that to Pydantic Settings:

from pydantic_settings import BaseSettings


class Settings(BaseSettings):
    app_name: str = "My App"
    debug: bool = False
    database_url: str

settings = Settings()

Here’s what you gain:

  • Automatic type casting (e.g., strings → bool).
  • Built-in validation for missing values.
  • A single schema for all configs.
  • Less repetitive code.

In short, Pydantic Settings scales better than manual approaches..

2. Installation

Getting started is simple:

pip install pydantic-settings

Note: pydantic-settings requires pydantic as a dependency.

3. Basic Usage of Pydantic Settings

When your model inherits from BaseSettings, it automatically pulls missing values from environment variables. For example:

from pydantic_settings import BaseSettings

class Settings(BaseSettings):
    app_name: str = "My App"
    debug: bool = False
    database_url: str

print(Settings().model_dump())

If DATABASE_URL is set in your environment, it’s used. Otherwise, the default applies (or raises an error if required).

4. Dotenv (.env) Support

Pydantic works seamlessly with .env files. You can load them in two ways:

Option 1: Model Config

from pydantic_settings import BaseSettings, SettingsConfigDict

class Settings(BaseSettings):
    model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8")

Option 2: Runtime Load

settings = Settings(_env_file="prod.env", _env_file_encoding="utf-8")

5. Customizing Variable Names

By default, environment variables match field names. To customize:

Add a Prefix

class Settings(BaseSettings):
    model_config = SettingsConfigDict(env_prefix="my_prefix_")
    database_url: str  # Reads from `my_prefix_database_url`

Use Aliases

from pydantic import Field

class Settings(BaseSettings):
    api_key: str = Field(alias="CUSTOM_API_KEY")  # Reads from `CUSTOM_API_KEY`

6. Case Sensitivity

By default, variable names are case-insensitive. To enforce sensitivity:

class Settings(BaseSettings):
    model_config = SettingsConfigDict(case_sensitive=True)
    database_url: str  # Only reads `database_url`, not `DATABASE_URL`

7. Parsing Environment Variables

Pydantic Settings shines when handling complex data types. By default, environment variables are parsed as-is, but you can customize this behavior for advanced use cases.

Handling Empty Values

To ignore empty environment variables and fall back to defaults, set env_ignore_empty=True:

class Settings(BaseSettings):
    model_config = SettingsConfigDict(env_ignore_empty=True)

Simple vs. Complex Types

  • Simple types (like intstrbool) are parsed directly.
  • Complex types (like listdict, or nested models) require JSON strings or structured variables.

For example, this environment variable:

export SUB_MODEL='{"v1": "json-1", "v2": "json-2"}'

Becomes a Python dict when loaded:

sub_model: dict  # Parsed as {'v1': 'json-1', 'v2': 'json-2'}

Pydantic maps these to:

{
    "sub_model": {
        "v2": "nested-2",
        "deep": {"v4": "v4"}
    }
}

Full Example

Here’s how to load nested settings:

from pydantic import BaseModel
from pydantic_settings import BaseSettings, SettingsConfigDict

class DeepSubModel(BaseModel):
    v4: str

class SubModel(BaseModel):
    v1: str
    v2: bytes
    v3: int
    deep: DeepSubModel

class Settings(BaseSettings):
    model_config = SettingsConfigDict(env_nested_delimiter="__")

    v0: str
    sub_model: SubModel

settings = Settings()
print(settings.model_dump())

Output:

{
    "v0": "0",
    "sub_model": {
        "v1": "json-1",
        "v2": "nested-2",
        "v3": 3,
        "deep": {"v4": "v4"}
    }
}

Conclusion

Pydantic Settings transforms environment variables from loose strings into structured, validated configuration. Here’s why it matters:

  • Safety First: Type hints and validation catch errors early, preventing runtime surprises.
  • Scalability: Declarative classes grow with your app—no more scattered os.getenv() calls.
  • Developer Experience: Automatic parsing, nested data support, and .env integration just work.

For modern Python apps, this isn’t just convenient—it’s responsible engineering. Stop guessing at configs; let Pydantic handle the details while you focus on building.

References

Pydantic documentation

Pydantic Settings documentation

Maicon Godinho
Maicon Godinho

Programmer with nearly a decade of experience, fueled by coffee and driven by a passion for code, cinema, and soccer. Sharing insights from technology, career growth, and everyday life to inspire your journey.

Leave a Reply

Your email address will not be published. Required fields are marked *