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
.envloading
👉 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:
| Package | Responsibility |
|---|---|
pydantic | Models & validation |
pydantic-core | Rust-based validation engine |
pydantic-settings | Environment 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,
.envfiles, and defaults.
BaseModel vs BaseSettings
| Feature | BaseModel | BaseSettings |
|---|---|---|
| JSON input | ✅ | ❌ |
| Auto env loading | ❌ | ✅ |
.env support | ❌ | ✅ |
| Config priority | ❌ | ✅ |
⭐ Value Resolution Order (Critical)
BaseSettings resolves values in this order:
- OS environment variables
.envfiledefault/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
| Option | Characteristics | Example |
|---|---|---|
default | Fixed value. Determined at class declaration time. Be careful with mutable objects. | service_name: str = "pubsub_service" |
default_factory | Function 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", "")) |
defaultis suitable for constants or immutable typesdefault_factoryis ideal for environment variables, lists, dicts, random values, or other dynamic defaults
💡 One-line takeaway:
defaultis a fixed value, whiledefault_factoryruns 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
❌ v1-Style (Not Recommended)
@field_validator("gcp_project_id")
@classmethod
def validate_project_id(cls, v):
return v
✅ v2-Style (Recommended)
@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@classmethodis unnecessary by default.
6. When and How Should Settings Be Loaded?
Recommended: Load Once at App Startup
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,boolauto-conversion) - Full 12-factor app compliance
- Clean separation of config and code
8. Spring Boot Analogy (Interview-Friendly)
| Spring Boot | Pydantic |
|---|---|
@ConfigurationProperties | BaseSettings |
application.yml | .env |
@Validated | field_validator |
@Value | Field(...) |
Final One-Sentence Summary
In Pydantic v2,
BaseSettingsprovides a type-safe, environment-driven configuration model,
whileSettingsConfigDictand pure-function validators make configuration explicit, fast, and robust.