Pydantic & core / Chapter 4 of 5

Settings and companion packages

Configure pydantic-settings with environment variables and nested settings. Validate email addresses and use additional domain-specific types.

3 topicsUpdated
29 / 44

pydantic-settings: environment-based configuration#

Load configuration once, validate it early, and keep credentials out of output.

In v2, BaseSettings lives in the separate pydantic-settings package. It reads environment variables, dotenv files, and secret files into typed fields. Settings defaults are validated by default, unlike normal BaseModel defaults. Use env_prefix to avoid unrelated environment names and env_nested_delimiter for nested configuration.

PythonNested database settings and environment overrides
import os
from pydantic import BaseModel, Field, SecretStr, ValidationError
from pydantic_settings import BaseSettings, SettingsConfigDict, SettingsError

class DatabaseSettings(BaseModel):
    host: str
    port: int = Field(default=5432, ge=1, le=65535)
    password: SecretStr

class Settings(BaseSettings):
    model_config = SettingsConfigDict(
        env_prefix='CHEATSHEET_', env_nested_delimiter='__',
        env_file='.env', env_file_encoding='utf-8', extra='ignore'
    )
    database: DatabaseSettings
    debug: bool = False

os.environ.update({
    'CHEATSHEET_DATABASE': '{"host":"db.internal","password":"demo-password"}',
    'CHEATSHEET_DATABASE__PORT': '6432',
    'CHEATSHEET_DEBUG': 'true',
})
settings = Settings(_env_file=None)
print(settings.database.host, settings.database.port, settings.debug)
print(settings.database.password)
assert settings.database.port == 6432
os.environ['CHEATSHEET_DATABASE__PORT'] = '70000'
try:
    Settings(_env_file=None)
except ValidationError as exc:
    print(exc.errors()[0]['loc'], exc.errors()[0]['type'])
os.environ['CHEATSHEET_DATABASE'] = '{broken JSON'
try:
    Settings(_env_file=None)
except SettingsError:
    print('SettingsError: environment JSON could not be parsed')
TextExpected output
db.internal 6432 True
**********
('database', 'port') less_than_equal
SettingsError: environment JSON could not be parsed
Highest to lowest priorityDefault settings behavior
CLI argumentsOnly if CLI parsing is enabled
Constructor argumentsExplicit Settings(...) values
Environment variablesNested variables override matching keys in a JSON environment value
Dotenv fileLoaded from the configured file; ordinary environment values win
Secret directoryFiles configured with secrets_dir
Field defaultsUsed when no source provides the field
PythonComma-separated hosts with NoDecode
import os
from typing import Annotated
from pydantic import BeforeValidator, Field
from pydantic_settings import BaseSettings, NoDecode, SettingsConfigDict

def split_hosts(value):
    if isinstance(value, str):
        return [item.strip() for item in value.split(',') if item.strip()]
    return value

HostList = Annotated[list[str], NoDecode, BeforeValidator(split_hosts)]

class Settings(BaseSettings):
    model_config = SettingsConfigDict(env_prefix='CHEATSHEET_')
    allowed_hosts: HostList = Field(default_factory=list)

os.environ['CHEATSHEET_ALLOWED_HOSTS'] = 'api.journal.org, dashboard.journal.org'
print(Settings().allowed_hosts)
TextExpected output
['api.journal.org', 'dashboard.journal.org']
Source parsing happens before field validation

Complex environment values are decoded as JSON by default. A CSV value can fail in the settings source before a field validator runs; NoDecode lets your own parser receive the raw text. ForceDecode opts a field back into JSON decoding. SettingsError reports source/parsing failures; ValidationError reports typed value failures.

PythonControl source priority and load a secret directory
import os
from pathlib import Path
from tempfile import TemporaryDirectory
from pydantic import SecretStr
from pydantic_settings import BaseSettings, SettingsConfigDict

class Settings(BaseSettings):
    model_config = SettingsConfigDict(env_prefix='CHEATSHEET_')
    region: str = 'default'
    api_key: SecretStr

    @classmethod
    def settings_customise_sources(cls, settings_cls, init_settings,
                                  env_settings, dotenv_settings, file_secret_settings):
        # Deliberately make deployment environment values outrank constructor values.
        return env_settings, init_settings, dotenv_settings, file_secret_settings

os.environ['CHEATSHEET_REGION'] = 'asia'
with TemporaryDirectory() as folder:
    Path(folder, 'CHEATSHEET_API_KEY').write_text('demo-key', encoding='utf-8')
    settings = Settings(region='local', _secrets_dir=folder)
    print(settings.region, settings.api_key)
TextExpected output
asia **********
Settings lifetime

Cache settings at startup when the process should reuse one instance. Avoid mutating it, create a new instance when reloading, and clear the cache in tests. Keep dotenv secrets out of source control. Use extra="ignore" for shared dotenv files or extra="forbid" to catch unknown keys. Explicit aliases are normally unprefixed.

PythonLoad a dotenv file and observe source priority
import os
from pathlib import Path
from tempfile import TemporaryDirectory
from pydantic import Field
from pydantic_settings import BaseSettings, SettingsConfigDict

class LocalSettings(BaseSettings):
    model_config = SettingsConfigDict(env_prefix='CHEATSHEET_FILE_', extra='forbid')
    port: int = Field(default=8000, ge=1, le=65535)
    debug: bool = False

with TemporaryDirectory() as folder:
    dotenv = Path(folder, '.env')
    dotenv.write_text('CHEATSHEET_FILE_PORT=9200\nCHEATSHEET_FILE_DEBUG=false\n', encoding='utf-8')
    settings = LocalSettings(_env_file=dotenv)
    print('dotenv:', settings.port, settings.debug)
    os.environ['CHEATSHEET_FILE_DEBUG'] = 'true'
    settings = LocalSettings(_env_file=dotenv)
    print('environment wins:', settings.port, settings.debug)
    print('constructor wins:', LocalSettings(port=9300, _env_file=dotenv).port)
TextExpected output
dotenv: 9200 False
environment wins: 9200 True
constructor wins: 9300

Settings parses dotenv text into declared types. Constructor values normally outrank environment values, which outrank the dotenv file. _env_file selects a file for one call; None disables it. Relative paths resolve from the working directory.

Nested environment values override matching keys in the JSON object. Invalid JSON raises SettingsError before field validation; NoDecode allows custom parsing. Custom sources set precedence. env_prefix_target controls whether prefixes apply to variable names, aliases, or both.

Official reference: pydantic-settings: environment-based configuration.

30 / 44

email-validator and EmailStr#

Validate and normalize email syntax, then separately decide whether to check deliverability.

EmailStr and NameEmail use the optional email-validator package. Install it with pydantic[email] or email-validator. Pydantic’s built-in email validation checks syntax and normalization without a DNS deliverability check. It does not prove mailbox ownership; send a verification email for that.

PythonNormalize an email address and reject broken syntax
from pydantic import BaseModel, EmailStr, NameEmail, ValidationError
from email_validator import validate_email, EmailNotValidError

class Contact(BaseModel):
    email: EmailStr
    sender: NameEmail

contact = Contact(email='Editor@JOURNAL.ORG', sender='Mira <editor@journal.org>')
print(contact.email, contact.sender.name, contact.sender.email)
normalized = validate_email('Editor@JOURNAL.ORG', check_deliverability=False)
print(normalized.normalized)
try:
    Contact(email='not-an-email', sender='Mira <editor@journal.org>')
except ValidationError as exc:
    print(exc.errors()[0]['loc'], exc.errors()[0]['type'])
try:
    validate_email('not-an-email', check_deliverability=False)
except EmailNotValidError:
    print('Direct email-validator rejected invalid syntax')
TextExpected output
Editor@journal.org Mira editor@journal.org
Editor@journal.org
('email',) value_error
Direct email-validator rejected invalid syntax
Deliverability is an optional network operation

Direct validate_email(address, check_deliverability=True) can query DNS. Timeouts and DNS changes make this unsuitable for a deterministic syntax-only unit test or every hot-path model validation. Use a cached resolver and a separate workflow if you need it. DNS success still cannot prove that a particular mailbox exists or belongs to the user. Do not indiscriminately lowercase the local part.

Official reference: email-validator and EmailStr.

31 / 44

pydantic-extra-types and other companions#

Add specialized types only when they match your application’s domain.

ShellInstall optional domain and development packages
python -m pip install pydantic-extra-types pycountry
# Only for a feature that needs these packages:
python -m pip install phonenumbers
# Schema-to-model generation:
python -m pip install datamodel-code-generator
# Validation-focused tests:
python -m pip install pytest hypothesis
PackageUseful taskBoundary
pydantic-extra-typesCountry codes, colors, coordinates, currency codes, phone numbers, and other domain valuesSome modules need additional dependencies; inspect the type’s official reference
annotated-typesPortable constraints such as Gt, Len, and predicatesAlready installed with Pydantic; does not validate by itself
datamodel-code-generatorGenerate model code from JSON Schema or OpenAPIReview generated defaults, unions, and business rules
pytest / hypothesisRegression and generated-input testsExercise boundary cases and serialize/validate round trips
logfireOptional validation observabilityReview captured values and privacy settings before enabling instrumentation
PythonUse a country-code type and coordinate validation
from pydantic import BaseModel, ValidationError
from pydantic_extra_types.country import CountryAlpha2
from pydantic_extra_types.coordinate import Latitude, Longitude

class ShippingLocation(BaseModel):
    country: CountryAlpha2
    latitude: Latitude
    longitude: Longitude

location = ShippingLocation(country='BD', latitude=23.8103, longitude=90.4125)
print(location.model_dump(mode='json'))
try:
    ShippingLocation(country='BD', latitude=100, longitude=90)
except ValidationError as exc:
    print(exc.errors()[0]['loc'], exc.errors()[0]['type'])
TextExpected output
{'country': 'BD', 'latitude': 23.8103, 'longitude': 90.4125}
('latitude', 'constrained-float') less_than_equal
Keep the abstraction useful

A country code validator cannot decide whether you ship there. A phone number parser cannot verify possession. Prefer a built-in or Annotated type when it already expresses your rule; add a companion package for a concrete domain need.

Official reference: pydantic-extra-types and other companions.