Computed fields in frozen Pydantic models
Published on 2024-03-24
A conflict may arise when using Pydantic settings: deriving the value of a field in a frozen model from another field when, by definition, the value of frozen fields is immutable. Here I present a solution that toggles the Settings class' 'frozen' config to allow derivation while preserving the object's hashability and pseudo-immutability.
The problem
We must reconcile two clashing aims:
- Using a Pydantic model (BaseSettings) in a FastAPI dependable with
@cache
for improved performance.
This decorator requires we configure the settings object withfrozen=True
. 'Freezing' the model makes it pseudo-immutable and has the desirable side-effect of making instances hashable, allowing us to cache it. - Deriving the value of a field in our Settings object from a different field in the same model.
Consider our starting point – a dotenv file where we'll store values to pass to our application following Twelve-Factor best practice, and a Settings class:
.envSPECIFIED_SETTING=some_value
settings.pyfrom pydantic_settings import BaseSettings
class Settings(BaseSettings):
specified_setting: str
# not in dotenv, computed from `specified_setting`
derived_setting: int | None = None
model_config = ConfigDict(frozen=True)
For the purpose of this demonstration we seek to set derived_setting
to the length of the value of specified_setting
.
A first attempt might try: derived_setting = len(specified_setting)
, which disappointingly results in NameError: name 'specified_setting' is not defined
when the Settings object is instantiated.
A solution
A simple solution uses a @model_validator
(Pydantic v2) to 'unfreeze' (thaw?) the Settings object, generate the desired value from the supplied setting, set the derived field and re-freeze the model in order to maintain the desired pseudo-immutability and hash method:
settings.pyfrom pydantic_settings import BaseSettings
class Settings(BaseSettings):
specified_setting: str
# not in dotenv, computed from `specified_setting`
derived_setting: int | None = None
@model_validator(mode="after")
def derived_setting(self) -> int:
self.model_config["frozen"] = False
provided_value = getattr(self, "specified_setting")
self.derived_setting = len(provided_value)
self.model_config["frozen"] = True
return self
model_config = ConfigDict(frozen=True)
The rationale for using an 'after' validator (mode="after"
) is that they are more type safe given that they run once Pydantic has carried out its own parsing of the model. Unlike 'before' & 'wrap' validators, 'after' validators are instance methods, meaning the Settings object is available for modification, and the values of the specified setting(s) we wish to use for derived fields are readily accessible.
A snag
A word of caution regarding the behaviour of ConfigDict's frozen
parameter with regards to the conflict described above.
Since this parameter is what prevents us from modifying the Settings object after instantiation, it is tempting to set this to False. When used with a cached dependable:
dependables.pyclass Settings(BaseSettings):
...
model_config = ConfigDict(frozen=False)
settings.pyfrom functools import cache
@cache
def get_settings() -> Settings:
return Settings()
a not-frozen Settings object results in a TypeError: unhashable type: 'Settings'
. This happens because Pydantic does not know to generate a __hash__
method for it. Caching works by storing the arguments in a dict, which requires hashability.
If you wish to manipulate a Pydantic model's 'frozen' state in a way beyond the one described here it should prove useful to know where in Pydantic's internals a frozen class is given a hash function: _model_construction.py#L181 (v2.6.4).
I hope this guide has been of service and helps you resolve the apparent conflict between cached Pydantic settings and derived fields.
Lorem impsum
Dropcap background: Four fruits (1862) by William Morris, licensed under CC0.