Computed Fields in Pydantic#
Note
The information in this blog is outdated. For the right way to do computed fields in Pydantic V2, check the official documentation .
Pydantic seem to be mainly used as a utility to parse input and output of data structures, but I also want to use it as a nice tool for some nice object oriented programming. One thing I’ve spend some time figuring out is having attributes in objects that are derived from other attributes, for example, the area of a rectangle that is multiplication of the length and width. A naive implementation of such a rectangle in Pydantic would be this:
from pydantic import BaseModel
class Rectangle(BaseModel):
length: float
width: float
area: float
There are a few problems/challenges that arise when you start with this. The
first one would be: How do you set area
for an object? And since area
is a product of length
and width
, how do you make sure it is not
initialized or set to the wrong value? For the record, this will be done
while also making sure the area
is still in the output formatted by the
correct json schema.
Note
Approaches that are not right:
Pydantic root validators: this will be removed in Pydantic V2. See #1 in https://pydantic-docs.helpmanual.io/blog/pydantic-v2/#removed-features-limitations
Pydantic private attributes: this will not return the private attribute in the output
Pydantic field aliases: that’s for input
@property
: won’t be in the schema and output
Accept and validate#
If we want the area to be in the output, there is not a way to mark this field as a private attribute. However, in Python, it’s already accepted to emulate private and protected attributes with leading underscores without actually restricting developers to change them anyway.
We should make computed fields optional anyway, so it’s probably better see
that as type hint float = None
as a mark that it’s computed.
Developers will be able to set it or not when initializing an instance, but
in both cases we should validate it by adding the following method to our
Rectangle
:
@validator("area", pre=True, always=True)
def set_area(cls, area, values, **kwargs):
computed_area = values.get("length") * values.get("width")
if area and area != computed_area:
raise ValueError("Invalid area")
return area or computed_area
Note
Make sure area
is defined after length and width in the Pydantic model
or you will not be able to get the values for the length
and the
width
for an instance in the validator, because values
in the
validator function will be filled in by the order it’s defined in the
Pydantic model.
The things you should know about this:
We give the
validator
decorator the keyword argumentpre=True
to be able to set thearea
before all the other validators notice thearea
isNone
.We give the
validator
decorator the keyword argumentalways=True
to make sure the validator is run even when thearea
is not given to the initializer.If an
area
is given it should equal the product of the length of the width (thecomputed_area
).If an
area
hasn’t been given to the initializer thecomputed_area
should be used as thearea
.
Keep validating#
Your Pydantic model can, like other classes, have other methods to alter the state. A good thing to know is that the validation is only automatically run after the initialization of an instance, so you have to explicitly call the validation functionality after changing attributes. An example of such an method with validation at the end, looks like this:
def double(self):
self.length *= 2
self.height *= 2
self.area = self.length * self.height
self.validate(self.dict())
It would be nice to create a decorator to do this automatically for every instance method that needs validation after execution.
Wrapping it up with a finishing touch#
Pydantic has some nice ways to automatically put constraints on fields, in this case it would be nice to hint and validate right away that the length and height should be higher than 0.
The eventual result would like this:
from pydantic import BaseModel, confloat, validator
class Rectangle(BaseModel):
length: confloat(gt=0)
height: confloat(gt=0)
area: confloat(gt=0) = None
@validator("area", pre=True, always=True)
def set_area(cls, area, values, **kwargs):
computed_area = values.get("length") * values.get("height")
if area and area != computed_area:
raise ValueError("Invalid area")
return area or computed_area
def double(self):
self.length *= 2
self.height *= 2
self.area = self.length * self.height
self.validate(self.dict())
with the following output for the schema (which also makes sure the area
is included when converting an instance to a dict
or json
string):
{'properties': {'area': {'exclusiveMinimum': 0,
'title': 'Area',
'type': 'number'},
'height': {'exclusiveMinimum': 0,
'title': 'Height',
'type': 'number'},
'length': {'exclusiveMinimum': 0,
'title': 'Length',
'type': 'number'}},
'required': ['length', 'height'],
'title': 'Rectangle',
'type': 'object'}