Skip to content

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:

  1. Environment variables (highest priority)
  2. A .env file
  3. 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())
Output
app_name='My App' debug=False port=8000

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
Output
app_name='Shop API' debug=True port=9000
Type of port: int
  • Each field reads the environment variable with the same name: port reads PORT (upper or lower case doesn't matter by default).
  • Environment variables are always text. Pydantic converted "true" to True and "9000" to 9000, 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"]
Output
   ('port',): Input should be greater than or equal to 1024
   ('workers',): Input should be a valid integer, unable to parse string as an integer

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"]
Output
currency='INR' tax_percent=12.0

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)
Output
api_key=SecretStr('**********') allowed_hosts=['localhost', '127.0.0.1', 'myshop.in'] mail=MailSettings(host='smtp.gmail.com', port=465)
Real key   : sk-123-very-secret
Hosts      : ['localhost', '127.0.0.1', 'myshop.in']
Mail host  : smtp.gmail.com 465
  • SecretStr hides 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). With env_nested_delimiter="__", a double underscore means "go one level down": DEMO_MAIL__HOST sets mail.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 lineField in settings.py
TUTORIAL_APP_NAME=Pydantic Tutorialsettings.app_name
TUTORIAL_PAGE_SIZE=25settings.page_size (checked: 1 to 100)
TUTORIAL_DB__SERVER=...\SQLEXPRESSsettings.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 .env file contain other variables without causing errors.
  • @lru_cache on get_settings() means the .env file 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)
Output (with the values from .env.example)
App name   : Pydantic Tutorial
Debug      : True
Page size  : 25
DB server  : DESKTOP-PFD5S9B\SQLEXPRESS
DB name    : PydanticTutorialDB
ODBC text  : DRIVER={ODBC Driver 18 for SQL Server};SERVER=DESKTOP-PFD5S9B\SQLEXPRESS;DATABASE=PydanticTutorialDB;TrustServerCertificate=yes;Trusted_Connection=yes;
Same object every time? True
Page size with env variable: 50
Page size passed in code    : 5
  • debug is True and page_size is 25: the .env file beat the defaults (False and 20).
  • Setting TUTORIAL_PAGE_SIZE=50 as an environment variable beat the .env file. 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 .env to .gitignore (this project does).
  • Commit a .env.example with 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:

  1. Add a field max_upload_mb: int = Field(default=10, ge=1, le=100) to Settings in settings.py, set TUTORIAL_MAX_UPLOAD_MB=200 in .env and run 07_settings.py. Read the error, then fix the value.
  2. Set TUTORIAL_DB__ECHO_SQL=true in .env and run lesson 05: you will see every SQL statement SQLAlchemy sends to SQL Server.
  3. Add a nested group CacheSettings(enabled: bool, minutes: int) and set it from .env with TUTORIAL_CACHE__ENABLED and TUTORIAL_CACHE__MINUTES.

Summary

  • BaseSettings is a Pydantic model that reads its values from environment variables and a .env file.
  • 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_prefix keeps your variable names unique; env_nested_delimiter="__" fills nested groups.
  • SecretStr hides 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 cached get_settings().

You have finished the Pydantic series. Next, see these models in action in a real API: Basic CRUD in FastAPI.