pythonpydantic-v2pydantic-settings

How can I handle initial settings with Pydantic Settings?


I have an app that is largely configured by environment variables. I use Pydantic Settings to define the settings available, and validate them. I have an initial set of settings, and the regular app settings.

The initial settings are ones that should not fail validation, and contain essential settings for starting the app.

For example, when my app starts up, if the regular Settings() can't be initialized because something in them failed validation, I still want to be able to send the error to Sentry. For that, I need SENTRY_DSN to configure Sentry. SENTRY_DSN can't be part of the regular settings, because if something unrelated in Settings fails validation, I won't have access to SENTRY_DNS either.

Right now, my settings look like this:

class InitialSettings(BaseSettings):
    model_config = SettingsConfigDict(
        env_file="settings.env",
        env_file_encoding="utf-8",
        extra="ignore",
        env_ignore_empty=True,
        env_nested_delimiter="__",
        case_sensitive=True,
    )

    SENTRY_DSN: Annotated[
        Optional[str],
        Field(None),
    ]

class Settings(BaseSettings):
    model_config = SettingsConfigDict(
        env_file="settings.env",
        env_file_encoding="utf-8",
        extra="ignore",
        env_ignore_empty=True,
        env_nested_delimiter="__",
        case_sensitive=True,
    )

    STORAGE: Annotated[
        LocalStorageSettings | S3StorageSettings,
        Field(..., discriminator="STORAGE_TYPE"),
    ]
    DEBUG: Annotated[DebugSettings, Field(default_factory=DebugSettings)]
    ...

This works. When my app starts up, I first initialize InitialSettings(), and then try to initialize Settings(). If Settings() fails, I can still use the SENTRY_DSN setting to send the error to Sentry.

The issues comes when I try to have both settings use the same env file (settings.env), AND enable the extra="forbid" feature on Settings().

I like the idea of having extra="forbid" enabled, but that also means that if I enable it on Settings(), it will always fail, because the env file will contain an entry for SENTRY_DSN, which Settings doesn't know about.

To fix, this I tried to add InitialSettings to Settings like this:

class Settings(BaseSettings):
    model_config = SettingsConfigDict(
        env_file="settings.env",
        env_file_encoding="utf-8",
        extra="forbid",
        env_ignore_empty=True,
        env_nested_delimiter="__",
        case_sensitive=True,
    )

    STORAGE: Annotated[
        LocalStorageSettings | S3StorageSettings,
        Field(..., discriminator="STORAGE_TYPE"),
    ]
    DEBUG: Annotated[DebugSettings, Field(default_factory=DebugSettings)]
    INIT: Annotated[InitialSettings, Field(default_factory=InitialSettings)]
    ...

Now Settings should know about all the settings defined in InitialSettings, and if there's any extra settings in the env file that aren't defined in either class, it should fail.

This almost works.

The problem is that when you call InitialSettings, the SENTRY_DSN in the env file is expected to just be called SENTRY_DSN. When you call InitialSettings, because InitialSettings is nested under INIT, it expects the sentry variable to be called INIT__SENTRY_DSN.

How do I configure Pydantic Settings so that all settings under InitialSettings always look for SENTRY_DSN, no matter if they are initialized using InitialSettings(), or Settings()?

Note: I still want the other nested settings classes under Settings, like STORAGE, to work the same - be prefixed with STORAGE__ in the env file.


Solution

  • The simplest solution is to use two different .env files: one for InitialSettings and one for Settings:

    # initial.env
    SENTRY_DSN=...
    
    # settings.env
    # your other `Settings` envs here without `SENTRY_DSN`
    
    class InitialSettings(BaseSettings):
        model_config = SettingsConfigDict(
            env_file="initial.env",
            env_file_encoding="utf-8",
            extra="ignore",
            env_ignore_empty=True,
            env_nested_delimiter="__",
            case_sensitive=True,
        )
    
        SENTRY_DSN: Annotated[
            Optional[str],
            Field(None),
        ]
    
    
    class Settings(CustomSettings):
        model_config = SettingsConfigDict(
            env_file="settings.env",
            env_file_encoding="utf-8",
            extra="forbid",
            env_ignore_empty=True,
            env_nested_delimiter="__",
            case_sensitive=True,
        )
    
        STORAGE: Annotated[
            LocalStorageSettings | S3StorageSettings, 
            Field(..., discriminator="STORAGE_TYPE"),
        ]
        DEBUG: Annotated[DebugSettings, Field(default_factory=DebugSettings)]
        INIT: Annotated[InitialSettings, Field(default_factory=InitialSettings)]
        ...
    

    Using two separate files with different environment variables in your case seems logical. You have two independent classes of settings, with different extra settings, so different validation will be performed. In Settings extra environment variables will throw a validation error, while InitialSettings will not. But, most importantly, using a separate file with environment variables will allow InitialSettings to initialize correctly both inside the Settings class and separately.


    Further, I'm assuming that you can't use multiple .env files, for whatever reason, and that you plan to leave the setting - extra=“forbid” in Settings. Based on this, I can suggest two solutions.

    The first solution is to use the INIT prefix in your settings.env for SENTRY_DSN. This would look something like this:

    # setting.env
    INIT__SENTRY_DSN=...
    # other envs
    

    Then your settings classes will look like this:

    class InitialSettings(BaseSettings):  
        model_config = SettingsConfigDict(  
            env_prefix="INIT__",  
            env_file="settings.env",  
            env_file_encoding="utf-8",  
            extra="ignore",  
            env_ignore_empty=True,  
            env_nested_delimiter="__",  
            case_sensitive=True,  
        )  
      
        SENTRY_DSN: Annotated[  
            Optional[str],        
            Field(None),  
        ]
    
    
    class Settings(BaseSettings):
        model_config = SettingsConfigDict(
            env_file="settings.env",
            env_file_encoding="utf-8",
            extra="forbid",
            env_ignore_empty=True,
            env_nested_delimiter="__",
            case_sensitive=True,
        )
    
        STORAGE: Annotated[
            LocalStorageSettings | S3StorageSettings,
            Field(..., discriminator="STORAGE_TYPE"),
        ]
        DEBUG: Annotated[DebugSettings, Field(default_factory=DebugSettings)]
        INIT: Annotated[InitialSettings, Field(default_factory=InitialSettings)]
        ...
    

    In this case, because we set SettingsConfigDict(env_prefix=“INIT__”, ...) in InitialSettings, both classes of settings: will look for one environment variable INIT__SENTRY_DSN in the settings.env file. Both classes will properly initialize SENTRY_DSN from INIT__SENTRY_DSN.


    Another possible solution could be to override settings_customize_sources by creating a subclass of BaseSettings and add its own DotEnvSettingsSource handler:

    # setting.env
    SENTRY_DSN=...
    # other envs
    
    from typing import Annotated, Optional
    
    from pydantic import Field
    from pydantic_settings import (
        BaseSettings,
        DotEnvSettingsSource,
        PydanticBaseSettingsSource,
        SettingsConfigDict,
    )
    
    
    class CustomDotEnvSettingsSource(DotEnvSettingsSource):
        def __call__(self):
            # This method may actually look simpler, since `InitialSettings`
            # can be initialized using an `settings.env` file
            # (but I like the option below better, it's more explicit):
            # def __call__(self):
            #     result = super().__call__()
            #     result.pop('SENTRY_DSN', None)
            #     return result
    
            result = super().__call__()
            if 'SENTRY_DSN' in result:
                value = result.pop('SENTRY_DSN')
                result.setdefault('INIT', {})['SENTRY_DSN'] = value
            return result
    
    
    class CustomSettings(BaseSettings):
        @classmethod
        def settings_customise_sources(
            cls,
            settings_cls: type[BaseSettings],
            init_settings: PydanticBaseSettingsSource,
            env_settings: PydanticBaseSettingsSource,
            dotenv_settings: PydanticBaseSettingsSource,
            file_secret_settings: PydanticBaseSettingsSource,
        ) -> tuple[PydanticBaseSettingsSource, ...]:
    
            return super().settings_customise_sources(
                settings_cls=settings_cls,
                init_settings=init_settings,
                env_settings=env_settings,
                # register custom `DotEnvSettingsSource` handler
                dotenv_settings=CustomDotEnvSettingsSource(settings_cls),
                file_secret_settings=file_secret_settings,
            )
    
    
    class InitialSettings(BaseSettings):
        model_config = SettingsConfigDict(
            env_file="settings.env",
            env_file_encoding="utf-8",
            extra="ignore",
            env_ignore_empty=True,
            env_nested_delimiter="__",
            case_sensitive=True,
        )
    
        SENTRY_DSN: Annotated[
            Optional[str],
            Field(None),
        ]
    
    
    class Settings(CustomSettings):
        model_config = SettingsConfigDict(
            env_file="settings.env",
            env_file_encoding="utf-8",
            extra="forbid",
            env_ignore_empty=True,
            env_nested_delimiter="__",
            case_sensitive=True,
        )
    
        STORAGE: Annotated[
            LocalStorageSettings | S3StorageSettings,
            Field(..., discriminator="STORAGE_TYPE"),
        ]
        DEBUG: Annotated[DebugSettings, Field(default_factory=DebugSettings)]
        INIT: Annotated[InitialSettings, Field(default_factory=InitialSettings)]
        ...
    

    All three methods described in this answer have been tested and should work for you.