7 Approaches to Validate Class Attributes in Python | by Xiaoxu Gao | Towards Data Science

Generally speaking, type checking and value checking are handled by Python in a flexible and implicit way. Python has introduced typing module since Python3 which provides runtime support for type hints. But for value checking, there is no unified way to validate values due to its many possibilities.
One of the scenarios where we need value checking is when we initialize a class instance. We want to ensure valid input attributes in the first stage, for example, an email address should have the correct format xxx@xx.com, an age should not be negative, the surname should not exceed 20 characters, etc.
In this article, I want to demonstrate 7 out of many options to validate class attributes using either Python built-in modules or third-party libraries. I’m curious which option you prefer, please tell me in the comments. If you know other good options, you are welcome to share as well.
We start with the most straightforward solution: creating a validation function for each requirement. Here we have 3 methods to validate name, email, and age individually. The attributes are validated in sequence, any failed validation will immediately throw a ValueError exception and stop the program.
This option is simple, but on the other hand, it’s probably not the most Pythonic solution you’ve ever seen and many people prefer to have a clean __init__ as much as possible. Another problem is that the attribute can still be assigned to an invalid value after the initialization without raising an exception. For example, this could happen:
xiaoxu = Citizen("id1", "xiaoxu gao", "highsmallxu@gmail.com", 27)xiaoxu.email = "xiaoxugao@gmail.c" # This email is not valid, but still accepted by the code
The second option uses a built-in function: @property. It works as a decorator that is added to an attribute. According to Python documentation:
A property object has getter, setter, and deleter methods usable as decorators that create a copy of the property with the corresponding accessor function set to the decorated function.
At the first glance, it creates more code than the first option, but on the other hand, it relieves the responsibility of __init__. Each attribute has 2 methods (except for id), one with @property, the other one with setter. Whenever an attribute is retrieved like citizen.name, the method with @property is called. When an attribute value is set during initialization or updating like citizen.name="xiaoxu", the method with setter is called.
This option moves validation logic to the setter method of each attribute and therefore keeps __init__ very clean. Besides, the validation also applies to every update of each attribute after initialization. So the code in the previous example is not accepted anymore.
xiaoxu = Citizen("id1", "xiaoxu gao", "highsmallxu@gmail.com", 27)xiaoxu.email = "xiaoxugao@gmail.c" # ValueError: It's not an email address.
Attribute id is an exception here because it doesn’t have a setter method. This is because I want to tell the client that this attribute is not supposed to be updated after initialization. If you try to do that, you will get an AttributeError exception.
xiaoxu = Citizen("id1", "xiaoxu gao", "highsmallxu@gmail.com", 27)print(xiaoxu.id)# id1xiaoxu.id = "id2"# AttributeError: can't set attribute
The third option makes use of Python Descriptors which is a powerful but often overlooked feature. Maybe the community has realized this problem, since Python3.9, examples of using descriptors to validate attributes have been added to the documentation.
A descriptor is an object which defines the methods __get__(), __set__() or __delete__(). It changes the default behaviour of retrieving, setting, or deleting attributes.
Here is the code using descriptors. Every attribute becomes a descriptor which is a class with methods __get__ and __set__. When the attribute value is set like self.name=name, then __set__ is called. When the attribute is retrieved like print(self.name), then __get__ is called.
This solution is comparable to @property. It works better when the descriptors can be reused in multiple classes. For example, in the class of Employee, we can simply reuse previous descriptors without creating many boilerplate code.
A variant of option3 is to combine decorator and descriptor. The end result looks like the following where the rules are encapsulated in those decorators.
These decorators can be extended quite easily. For example, you can have more generic rules with multiple attributes applied such as @positive_number(attr1,attr2).
Until now, we have gone through 4 options using only built-in functions. In my opinion, Python built-in functions are already powerful enough to cover what we often need for data validation. But let’s also look around and see some third-party libraries.
Another way to create a class in Python is using @dataclass. Dataclass provides a decorator for automatically generating __init__() method.
Besides, @dataclass also introduces a special method called __post_init__(), which is invoked from the hidden __init__(). __post_init__ is the place to initialize a field based on other fields or include validation rules.
This option has the same effect as option 1, but using @dataclass style. If you prefer using @dataclass rather than the traditional class, then this option could be something for you.
Marshmallow is an object serialization Python library that converts complex data types to and from native Python datatypes. To understand how to serialize and validate the object, a user needs to build a schema that defines validation rules for each attribute. A couple of things, in my opinion, make this library powerful:
Provides many pre-cooked validation functions such as Length, Date, Range, Email, etc, which saves developers a lot of time to create on their own. Of course, you are free to create your own validator as well.
Supports nested schema.
ValidationError from Marshmallow contains all the failed validations while the previous approaches would throw an exception immediately when it finds the first error. This feature helps the user fix all the errors in one go.
For demo purpose, I included extra attributebirthday and nested schema HomeAddressSchema to show you different possibilities.
On top of the schema, we need to create the real Citizen class. I’m using @dataclass to skip some boilerplate codes of __init__. Marshmallow requires JSON object as the input, so asdict() is added to solve this problem.
However, this library “allows” attribute update with invalid value after the initialization. For example, in the line 23 and 24, I was able to update citizen object with invalid name and email.
For more possibilities, please refer to Marshmallow documentation.
Pydantic is a library similar to Marshmallow. It also follows the idea of creating a schema or model for the object and meanwhile provides many pre-cooked validation classes like PositiveInt, EmailStr, etc. Compared to Marshmallow, Pydantic integrates validation rules into the object class rather than creating a separate schema class.
Here is how we can achieve the same goal using Pydantic. ValidationError stores all 3 errors found in the object.
I personally prefer to have only one class with all the validation rules due to its clarity.
Actually, Pydantic could do much more than that. It could also export a schema file via schema_json method.
print(Citizen.schema_json(indent=2))
The schema is compliant with JSON Schema Core, JSON Schema Validation and OpenAPI. But similar to Marshmallow, this library also “allows” attribute update with invalid value after the initialization.
In this article, I have shared 6 approaches to validate your class attributes. 4 of them are based on Python built-in functions and 2 of them are third-party libraries.
With built-in functions, developers could have full control on every single detail of the validation. But it could cost more time to develop and maintain.
With third-party libraries, developers could save themselves from developing generic rules and write less code. But at the same time, they should know if those pre-cooked functions really meet their expectations. For instance, there could be different opinions on how to validate an email format. In addition to that, for both Marshmallow and Pydantic, they would allow an invalid update after the initialization which could be dangerous in certain cases.
What is your preferred solution? I believe that there must be many other options, please share them in the comment section! I would love to hear them.
I hope you enjoy this article.