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:

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 argument pre=True to be able to set the area before all the other validators notice the area is None.

  • We give the validator decorator the keyword argument always=True to make sure the validator is run even when the area is not given to the initializer.

  • If an area is given it should equal the product of the length of the width (the computed_area).

  • If an area hasn’t been given to the initializer the computed_area should be used as the area.

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'}