BaseSettings (from pydantic-settings) is a BaseModel subclass that reads
field values from environment variables automatically. It gives you typed,
validated configuration without manual os.getenv() calls.
from pydantic_settings import BaseSettings
class Settings(BaseSettings):
database_url: str
secret_key: str
debug: bool = False
max_connections: int = 10
settings = Settings()
# reads DATABASE_URL, SECRET_KEY, DEBUG, MAX_CONNECTIONS from env
Benefits over raw os.getenv():
- Pydantic validates types and raises a clear error at startup if required vars are missing.
- Settings are documented as a class — easy to audit.
- Works with
.envfiles, Docker secrets, and AWS Parameter Store.
Rule of thumb: put all configuration in a BaseSettings class; never scatter
os.getenv() calls across the codebase.
Configure model_config with env_file:
from pydantic_settings import BaseSettings
class Settings(BaseSettings):
model_config = {"env_file": ".env", "env_file_encoding": "utf-8"}
database_url: str
secret_key: str
debug: bool = False
.env file:
DATABASE_URL=postgresql+asyncpg://user:pass@localhost/mydb
SECRET_KEY=supersecret
DEBUG=true
Multiple .env files (later overrides earlier):
model_config = {"env_file": (".env", ".env.local")}
Rule of thumb: commit .env.example with dummy values and add .env to
.gitignore — never commit real secrets to version control.
Wrap the Settings() constructor in @lru_cache and inject it via Depends:
from functools import lru_cache
from fastapi import Depends
@lru_cache
def get_settings() -> Settings:
return Settings() # reads env/file once; cached for process lifetime
@app.get("/info")
async def info(settings: Settings = Depends(get_settings)):
return {"debug": settings.debug}
@lru_cache makes get_settings() a singleton: the first call creates the
object; subsequent calls return the same instance. Safe because environment
variables don't change during a process lifetime.
Rule of thumb: always wrap settings construction in @lru_cache — reading env
vars is cheap, but parsing and validating with Pydantic on every request adds
unnecessary overhead.
By default case-insensitive on all platforms. DATABASE_URL,
database_url, and Database_Url all resolve to the database_url field.
class Settings(BaseSettings):
database_url: str # matches DATABASE_URL, database_url, etc.
To enforce case-sensitive env var names:
model_config = {"case_sensitive": True}
Rule of thumb: use UPPERCASE for environment variable names by convention (Linux, 12-factor apps); keep field names lowercase — the case-insensitive matching bridges them automatically.
Use nested Pydantic models. BaseSettings reads nested values via a delimiter
prefix in the env var name:
from pydantic import BaseModel
from pydantic_settings import BaseSettings
class DatabaseSettings(BaseModel):
url: str
pool_size: int = 5
class Settings(BaseSettings):
model_config = {"env_nested_delimiter": "__"}
database: DatabaseSettings
debug: bool = False
# env vars: DATABASE__URL=postgres://..., DATABASE__POOL_SIZE=10
Rule of thumb: use env_nested_delimiter="__" for nested settings — it's the
conventional double-underscore pattern in 12-factor apps.
Point secrets_dir to the directory where secret files live. Each file named
after the env var contains the secret value.
from pydantic_settings import BaseSettings
class Settings(BaseSettings):
model_config = {"secrets_dir": "/run/secrets"}
database_password: str # reads /run/secrets/database_password
api_key: str # reads /run/secrets/api_key
Docker Swarm and Kubernetes both mount secrets as files at a known path.
This approach keeps secrets out of environment variables (less visible in
ps output and container inspection).
Rule of thumb: prefer secrets-as-files over env var secrets in containerised deployments — they integrate cleanly with Kubernetes Secrets and Docker secrets.
Override the get_settings dependency on the test app:
from fastapi.testclient import TestClient
from app.main import app
from app.config import get_settings, Settings
def get_test_settings():
return Settings(
database_url="sqlite:///:memory:",
secret_key="test-secret",
debug=True,
)
app.dependency_overrides[get_settings] = get_test_settings
client = TestClient(app)
Alternatively, set environment variables before the settings are loaded:
import os
os.environ["DATABASE_URL"] = "sqlite:///:memory:"
Rule of thumb: use dependency_overrides — it's explicit, isolated per test
file, and doesn't pollute os.environ for other tests.
Yes — BaseSettings inherits from BaseModel, so @field_validator and
@model_validator work identically:
from pydantic import field_validator
from pydantic_settings import BaseSettings
class Settings(BaseSettings):
allowed_hosts: list[str] = ["*"]
cors_origins: str = ""
@field_validator("cors_origins", mode="before")
@classmethod
def parse_cors(cls, v: str) -> list[str]:
return [origin.strip() for origin in v.split(",") if origin.strip()]
This pattern lets you store a comma-separated env var and parse it into a list.
Rule of thumb: use @field_validator(mode="before") in BaseSettings to parse
compound env vars (comma-separated lists, JSON strings) into the target type.
A module-level singleton:
# config.py
settings = Settings() # created once at import time
This works in production but breaks in tests: if os.environ is patched after
import, the singleton already holds the old values.
The Depends + @lru_cache pattern is safer:
@lru_cache
def get_settings() -> Settings:
return Settings()
# In tests:
app.dependency_overrides[get_settings] = lambda: Settings(debug=True)
The lru_cache singleton is invalidated between tests by clearing the cache:
get_settings.cache_clear()
Rule of thumb: use Depends(get_settings) with @lru_cache for testability;
avoid module-level settings = Settings() in anything you'll need to test.
All Pydantic-supported types work. Pydantic coerces the string value from the env var to the declared type:
| Python type | Env var example |
|---|---|
str |
SECRET_KEY=abc |
int |
PORT=8000 |
float |
TIMEOUT=30.5 |
bool |
DEBUG=true / DEBUG=1 |
list[str] |
TAGS=["a","b"] (JSON) or use @field_validator |
HttpUrl |
BASE_URL=https://example.com |
SecretStr |
PASSWORD=secret (masked in repr) |
from pydantic import SecretStr, HttpUrl
class Settings(BaseSettings):
database_password: SecretStr # hidden in logs
api_base_url: HttpUrl # validated URL
Rule of thumb: use SecretStr for passwords and tokens — it masks the value in
repr() and str(), preventing accidental logging.
Load different .env files based on an APP_ENV env var:
import os
from pydantic_settings import BaseSettings
class Settings(BaseSettings):
model_config = {
"env_file": f".env.{os.getenv('APP_ENV', 'development')}"
}
database_url: str
debug: bool = False
File layout:
.env.development → local dev DB, DEBUG=true
.env.staging → staging DB, DEBUG=false
.env.production → prod secrets (not committed)
In CI/CD and production, inject all values directly as environment variables rather than relying on files.
Rule of thumb: in production, always prefer environment variables over .env
files — files can be accidentally committed or left on disk.
Since Pydantic v2, BaseSettings has been moved to a separate package:
pydantic-settings. It must be installed separately:
pip install pydantic-settings
Import:
from pydantic_settings import BaseSettings
In Pydantic v1, BaseSettings was part of pydantic itself
(from pydantic import BaseSettings) — a common migration mistake is forgetting
to install pydantic-settings after upgrading.
Rule of thumb: add pydantic-settings to requirements.txt or pyproject.toml
explicitly — it is not pulled in by fastapi or pydantic alone.
More Pydantic & Validation interview questions
More ways to practice
The self-quiz is live. Get notified when mock interviews and new question packs drop.