Software Engineer's Blog

The Canonical Way to Manage Config with Pydantic v2

The Canonical Way to Manage Config with Pydantic v2

Environment Variables · .env · Validation

When building backend services, configuration management is unavoidable.

  • Where should config live?
  • Environment variables or .env?
  • When should validation happen?
  • When should the app fail if config is wrong?

Pydantic v2 provides a clean, explicit, and production-ready answer to these questions.
This article walks through the canonical pattern for implementing configuration in Pydantic v2.

1. Why pydantic_settings Exists in v2

In Pydantic v1

from pydantic import BaseSettings

Pydantic handled everything:

  • Data validation
  • JSON schema
  • Environment variables
  • .env loading

👉 The library became too heavy and tightly coupled.

The v2 Design Philosophy

“Separate the core validation engine from auxiliary features.”

As a result, Pydantic was split into focused packages:

PackageResponsibility
pydanticModels & validation
pydantic-coreRust-based validation engine
pydantic-settingsEnvironment variables & .env

📌 In v2, configuration is handled by a dedicated package.

2. What Exactly Is BaseSettings?

from pydantic_settings import BaseSettings

One-line summary

A specialized model that builds configuration objects from environment variables, .env files, and defaults.

BaseModel vs BaseSettings

FeatureBaseModelBaseSettings
JSON input
Auto env loading
.env support
Config priority

⭐ Value Resolution Order (Critical)

BaseSettings resolves values in this order:

  1. OS environment variables
  2. .env file
  3. default / default_factory

👉 Production always wins (Aligned with the 12-factor app principle)

3. Canonical Pydantic v2 Settings Example (with default vs default_factory)

import os
from pydantic import Field, field_validator
from pydantic_settings import BaseSettings, SettingsConfigDict


class PubSubSettings(BaseSettings):
    # default: fixed constant value
    service_name: str = "pubsub_service"

    # default_factory: function called for each instance, dynamic value
    gcp_project_id: str = Field(
        default_factory=lambda: os.getenv("GCP_PROJECT_ID", ""),
        description="GCP Project ID (REQUIRED for Pub/Sub)",
        min_length=1,
    )

    @field_validator("gcp_project_id")
    def validate_gcp_project_id(v: str) -> str:
        if not v or not v.strip():
            raise ValueError(
                "GCP_PROJECT_ID environment variable must be set for Pub/Sub functionality."
            )
        return v.strip()

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

🔹 default vs default_factory

OptionCharacteristicsExample
defaultFixed value. Determined at class declaration time. Be careful with mutable objects.service_name: str = "pubsub_service"
default_factoryFunction generates the value, runs for each instance. Safe for mutable objects and dynamic defaults.gcp_project_id: str = Field(default_factory=lambda: os.getenv("GCP_PROJECT_ID", ""))
  • default is suitable for constants or immutable types
  • default_factory is ideal for environment variables, lists, dicts, random values, or other dynamic defaults

💡 One-line takeaway:

default is a fixed value, while default_factory runs a function for each instance to produce a dynamic default.

4. What Is SettingsConfigDict and Why Does It Matter?

v1 Style

class Config:
    env_file = ".env"

v2 Style

model_config = SettingsConfigDict(...)

Why the change?

  • Configuration is explicit and visible
  • Better IDE and type-checker support
  • Clear separation between model logic and configuration

Commonly Used Options in Practice

🔹 env_file

env_file=".env"
  • For local development
  • Rarely used in Docker / Cloud Run

🔹 env_file_encoding

env_file_encoding="utf-8"
  • Windows compatibility
  • Supports non-ASCII comments

🔹 extra="ignore"

extra="ignore"

Meaning: Ignore variables present in .env but not defined in the model.

👉 Prevents unnecessary failures across teams and CI/CD pipelines.

5. Validator Best Practices in Pydantic v2

@field_validator("gcp_project_id")
@classmethod
def validate_project_id(cls, v):
    return v
@field_validator("gcp_project_id")
def validate_project_id(v: str) -> str:
    return v
  • No cls
  • More Pythonic
  • Matches official documentation

Why @classmethod Is No Longer the Default in v2

This is not just a stylistic change —
it’s the result of a fundamental architectural shift in Pydantic v2.

Validators in v1

  • Treated as model class methods
  • Always received cls
  • Conceptually part of model behavior
def validate(cls, v): ...

Validators in v2

  • Field-bound pure functions
  • Registered as callbacks in the Rust engine (pydantic-core)
  • Responsible only for value transformation and validation
def validate(v): ...
  • Validators are no longer “model methods” —
    they are data transformation functions.

When Is cls Still Needed?

Only in specific cases:

  • Accessing model metadata (cls.model_fields)
  • Structural validation across multiple fields
  • Validation logic dependent on model definition

👉 For single-field validation, do not use cls.


One-line takeaway 🧠

In Pydantic v2, validators are pure functions registered with a Rust validation engine,
so @classmethod is unnecessary by default.

6. When and How Should Settings Be Loaded?

settings = PubSubSettings()
  • Validation happens immediately
  • Misconfiguration fails fast
  • Prevents runtime surprises

FastAPI Singleton Pattern

from functools import lru_cache

@lru_cache
def get_settings():
    return PubSubSettings()
  • Global, immutable settings
  • Ideal for dependency injection and testing

7. Practical Advantages of This Approach

  • Early failure on misconfiguration
  • Strong type safety (int, bool auto-conversion)
  • Full 12-factor app compliance
  • Clean separation of config and code

8. Spring Boot Analogy (Interview-Friendly)

Spring BootPydantic
@ConfigurationPropertiesBaseSettings
application.yml.env
@Validatedfield_validator
@ValueField(...)

Final One-Sentence Summary

In Pydantic v2, BaseSettings provides a type-safe, environment-driven configuration model,
while SettingsConfigDict and pure-function validators make configuration explicit, fast, and robust.