Settings with pydantic-settings and .env Files
Every real project has settings: the database server, the database name, debug mode, page size, API keys, e-mail server details. These values should not be written inside your code, because:
- they are different on every machine: your laptop, the test server, production,
- some of them are secrets (passwords, API keys) that must never be committed to Git,
- changing them should not require changing code.
The pydantic-settings package (installed with pip install pydantic-settings) solves this. You write a class just like a normal Pydantic model, but it inherits from BaseSettings. It then reads the values automatically from these places, in this order:
- Environment variables (highest priority)
- A
.envfile - Default values written in the class (lowest priority)
And because it is Pydantic, every value is validated and converted: "9000" becomes an int, "true" becomes a bool, and a wrong value stops the program at start-up with a clear message.
This lesson follows 07_settings.py, plus the project's own settings.py and .env files.
1. A simple settings class: defaults only
from pydantic_settings import BaseSettings
class AppSettings(BaseSettings):
app_name: str = "My App"
debug: bool = False
port: int = 8000
print(AppSettings())
No environment variables are set yet, so the defaults are used. So far it looks exactly like a BaseModel.
2. Environment variables override the defaults
An environment variable is a named value set in the operating system or terminal, outside your program. Normally you set them in the terminal, for example in PowerShell: $env:PORT = "9000". The lesson sets them from Python, just to show what happens:
import os
os.environ["APP_NAME"] = "Shop API"
os.environ["DEBUG"] = "true" # text "true" becomes bool True
os.environ["PORT"] = "9000" # text "9000" becomes int 9000
settings = AppSettings()
print(settings)
print("Type of port:", type(settings.port).__name__)
for name in ("APP_NAME", "DEBUG", "PORT"):
del os.environ[name] # clean up
- Each field reads the environment variable with the same name:
portreadsPORT(upper or lower case doesn't matter by default). - Environment variables are always text. Pydantic converted
"true"toTrueand"9000"to9000, exactly like in lesson 01.
3. Wrong values are caught at start-up
Settings can have Field() rules too:
from pydantic import Field, ValidationError
class ServerSettings(BaseSettings):
port: int = Field(default=8000, ge=1024, le=65535)
workers: int = Field(default=2, ge=1, le=16)
os.environ["PORT"] = "80"
os.environ["WORKERS"] = "many"
try:
ServerSettings()
except ValidationError as error:
for item in error.errors():
print(f" {item['loc']}: {item['msg']}")
del os.environ["PORT"], os.environ["WORKERS"]
This is a big advantage. A typo in a configuration file is found the moment the app starts, with a clear message, instead of causing a strange error hours later in the middle of the night.
4. A prefix, so your variables don't mix with others
Names like PORT or DEBUG are very common, and other programs on the same machine may use them too. A prefix makes your variables unique:
from pydantic_settings import SettingsConfigDict
class ShopSettings(BaseSettings):
model_config = SettingsConfigDict(env_prefix="SHOP_")
currency: str = "INR"
tax_percent: float = 18
os.environ["SHOP_TAX_PERCENT"] = "12"
print(ShopSettings()) # reads SHOP_TAX_PERCENT, not TAX_PERCENT
del os.environ["SHOP_TAX_PERCENT"]
Settings classes use SettingsConfigDict (instead of ConfigDict) for their configuration.
5. Secrets, lists and nested settings
from pydantic import BaseModel, SecretStr
class MailSettings(BaseModel): # a group of settings (normal BaseModel)
host: str = "smtp.example.com"
port: int = 587
class FullSettings(BaseSettings):
model_config = SettingsConfigDict(env_prefix="DEMO_", env_nested_delimiter="__")
api_key: SecretStr = SecretStr("") # hidden when printed
allowed_hosts: list[str] = ["localhost"] # read from JSON text
mail: MailSettings = MailSettings() # nested: DEMO_MAIL__HOST, DEMO_MAIL__PORT
os.environ["DEMO_API_KEY"] = "sk-123-very-secret"
os.environ["DEMO_ALLOWED_HOSTS"] = '["localhost", "127.0.0.1", "myshop.in"]'
os.environ["DEMO_MAIL__HOST"] = "smtp.gmail.com"
os.environ["DEMO_MAIL__PORT"] = "465"
full = FullSettings()
print(full) # api_key shows as **********
print("Real key :", full.api_key.get_secret_value())
print("Hosts :", full.allowed_hosts)
print("Mail host :", full.mail.host, full.mail.port)
SecretStrhides the value when printed or logged (**********), so a secret doesn't end up in your logs by accident. You get the real value only when you ask for it:.get_secret_value().- Lists (and dicts) are written in the environment variable as JSON text:
'["localhost", "127.0.0.1"]'. - Nested settings group related values in a normal
BaseModel(MailSettings). Withenv_nested_delimiter="__", a double underscore means "go one level down":DEMO_MAIL__HOSTsetsmail.host.
6. The settings of this project: settings.py and .env
Now the real thing. The PydanticTutorial project keeps its configuration in settings.py. This is the file that gives database.py its SQL Server connection in lessons 05 and 06:
from functools import lru_cache
from pathlib import Path
from pydantic import BaseModel, Field, SecretStr
from pydantic_settings import BaseSettings, SettingsConfigDict
PROJECT_DIR = Path(__file__).parent
class DatabaseSettings(BaseModel):
"""The SQL Server connection. In .env these are TUTORIAL_DB__SERVER, TUTORIAL_DB__NAME ..."""
server: str = r"DESKTOP-PFD5S9B\SQLEXPRESS"
name: str = "PydanticTutorialDB"
driver: str = "ODBC Driver 18 for SQL Server"
# Leave username empty to log in with your Windows account (Trusted_Connection)
username: str | None = None
password: SecretStr | None = None
echo_sql: bool = False # True prints every SQL statement SQLAlchemy runs
def odbc_connection_string(self, database: str | None = None) -> str:
"""ODBC connection string; pass database="master" to connect to the master database."""
text = (
f"DRIVER={{{self.driver}}};"
f"SERVER={self.server};"
f"DATABASE={database or self.name};"
"TrustServerCertificate=yes;"
)
if self.username:
password = self.password.get_secret_value() if self.password else ""
return text + f"UID={self.username};PWD={password};"
return text + "Trusted_Connection=yes;"
class Settings(BaseSettings):
model_config = SettingsConfigDict(
env_file=PROJECT_DIR / ".env", # read values from this file
env_prefix="TUTORIAL_", # app_name <- TUTORIAL_APP_NAME
env_nested_delimiter="__", # db.server <- TUTORIAL_DB__SERVER
extra="ignore", # ignore other variables in .env
)
app_name: str = "Pydantic Tutorial"
debug: bool = False
page_size: int = Field(default=20, ge=1, le=100)
db: DatabaseSettings = DatabaseSettings()
@lru_cache # read the settings only once, then reuse them
def get_settings() -> Settings:
return Settings()
And the matching values live in a .env file next to it. The project commits a copy without secrets called .env.example:
# Copy this file to .env and change the values for your PC.
# Names = TUTORIAL_ + field name. Nested fields use __ (db.server -> TUTORIAL_DB__SERVER)
TUTORIAL_APP_NAME=Pydantic Tutorial
TUTORIAL_DEBUG=true
TUTORIAL_PAGE_SIZE=25
TUTORIAL_DB__SERVER=DESKTOP-PFD5S9B\SQLEXPRESS
TUTORIAL_DB__NAME=PydanticTutorialDB
TUTORIAL_DB__DRIVER=ODBC Driver 18 for SQL Server
TUTORIAL_DB__ECHO_SQL=false
# Only for SQL Server logins; leave out to use Windows login
# TUTORIAL_DB__USERNAME=sa
# TUTORIAL_DB__PASSWORD=your-password
How a line in .env finds its field:
| .env line | Field in settings.py |
|---|---|
TUTORIAL_APP_NAME=Pydantic Tutorial | settings.app_name |
TUTORIAL_PAGE_SIZE=25 | settings.page_size (checked: 1 to 100) |
TUTORIAL_DB__SERVER=...\SQLEXPRESS | settings.db.server |
TUTORIAL_DB__PASSWORD=... | settings.db.password (a SecretStr) |
A few things worth noticing in settings.py:
- Settings classes can have methods.
odbc_connection_string()builds the SQL Server connection string from the settings. If a username is set it uses a SQL login; otherwise it uses Windows login (Trusted_Connection=yes). extra="ignore"lets the.envfile contain other variables without causing errors.@lru_cacheonget_settings()means the.envfile is read only once; every later call returns the same object.
Lesson 07 then uses these settings:
from settings import Settings, get_settings
settings = get_settings()
print("App name :", settings.app_name)
print("Debug :", settings.debug)
print("Page size :", settings.page_size)
print("DB server :", settings.db.server)
print("DB name :", settings.db.name)
print("ODBC text :", settings.db.odbc_connection_string())
# get_settings() uses @lru_cache: the .env file is read only once,
# every later call returns the SAME object.
print("Same object every time?", get_settings() is get_settings())
# An environment variable wins over the .env file:
os.environ["TUTORIAL_PAGE_SIZE"] = "50"
print("Page size with env variable:", Settings().page_size)
del os.environ["TUTORIAL_PAGE_SIZE"]
# You can also pass values directly (handy in tests):
print("Page size passed in code :", Settings(page_size=5).page_size)
debugisTrueandpage_sizeis25: the.envfile beat the defaults (Falseand20).- Setting
TUTORIAL_PAGE_SIZE=50as an environment variable beat the.envfile. That's how a server can override one value without editing files. - Values passed directly in code (
Settings(page_size=5)) win over everything, which is handy in unit tests.
The full priority order
Settings(page_size=5) 1. values passed in code (highest)
$env:TUTORIAL_PAGE_SIZE=50 2. environment variables
TUTORIAL_PAGE_SIZE=25 in .env 3. the .env file
page_size: int = 20 4. the default in the class (lowest)
Remember
- Put real passwords and keys only in
.env, and add.envto.gitignore(this project does). - Commit a
.env.examplewith the names but no real secrets, so other developers know what to set. - Create the settings once (
get_settings()) and import it wherever it's needed.
Exercises
From the end of 07_settings.py:
- Add a field
max_upload_mb: int = Field(default=10, ge=1, le=100)toSettingsinsettings.py, setTUTORIAL_MAX_UPLOAD_MB=200in.envand run07_settings.py. Read the error, then fix the value. - Set
TUTORIAL_DB__ECHO_SQL=truein.envand run lesson 05: you will see every SQL statement SQLAlchemy sends to SQL Server. - Add a nested group
CacheSettings(enabled: bool, minutes: int)and set it from.envwithTUTORIAL_CACHE__ENABLEDandTUTORIAL_CACHE__MINUTES.
Summary
BaseSettingsis a Pydantic model that reads its values from environment variables and a.envfile.- Priority: values in code, then environment variables, then
.env, then class defaults. - Values are validated and converted, so configuration mistakes are found at start-up.
env_prefixkeeps your variable names unique;env_nested_delimiter="__"fills nested groups.SecretStrhides secrets in prints and logs; use.get_secret_value()to read them.- Keep secrets in
.env(not in Git), commit.env.example, and load the settings once with a cachedget_settings().
You have finished the Pydantic series. Next, see these models in action in a real API: Basic CRUD in FastAPI.