Using Pydantic with custom classes

Posted on 2025-04-19 in Programmation

Pydantic is an awesome data validation library for Python. It’s getting very popular these days because it’s fast, has lots of features and is pleasant to use. I’m using it at work and it personal projects. It’s one of the strong points of FastAPI, the new popular Python framework. It can even integrate with Django thanks to django-ninja.

While it integrates with standard Python types out of the box, using it with custom classes requires a bit more work. It’s documented here for generalities about custom classes and here for specificities about container types, but I don’t think this part of the documentation is very clear. That’s why, in this article, I’ll share my experience with how I integrated a custom class with Pydantic. I hope you can use it to understand better how it works and thus facilitate using your custom classes with Pydantic. In a second article, I’ll give another example of custom classes with Pydantic by using enums whose members are Pydantic models.

You can access the code of this article as a notebook and as a Python script. I’ll strip imports from inner examples for brevity. Feel free to download these files and experiment! The code is tested on Python 3.11 but should work with any recent enough version of Python.

For the purpose of this article, I’ll suppose you know Python and have a basic understanding of Pydantic, generic types in Python and know the common dunder methods.

Why I needed to integrate a custom class in the first place

My goal was to create a read only mapping compatible with Pydantic. Here’s how it must work:

  • Usage with Pydantic must be transparent:
    • When the model is serialized, the mapping must be serialized as a dict.
    • It must be created from a dict containing values. Both the keys and the values from this input dict must be parsed and validated. To increase reusability, the model must be made generic to allow validation of different types: for instance a ReadonlyMapping[str, int] and ReadonlyMapping[int, float].
    • Just like any other Pydantic models, if I pass an instance to the model, the model must use the class directly.
  • Once set in the model, the field must not be editable any more. The goal being that the model must be fully immutable so that neither its fields nor the content of the mapping can be edited by mistake.

So my goal is to arrive at something like this:

class ImmutableModel(BaseModel):
    # Make the model immutable: cannot add nor change attributes.
    model_config = ConfigDict(frozen=True)

    my_field: int
    my_dict: dict[str, str]


m = ImmutableModel.model_validate({"my_field": 1, "my_dict": {}})

try:
    m.my_field = 2
    assert False, "Must fail"
except ValidationError as e:
    print("Fails as expected with:", e)

# The underlying structure is still mutable and can be changed.
# despite the model being frozen. That’s what we want to prevent.
m.my_dict["key"] = "value"

The class without Pydantic

Let’s start by building a class that will be our generic readonly mapping. At its core, this class will just be a wrapper around a good old dict. It will rely on typing.Generic to be generic and will implement __getitem__ to provide item access. It’ll also implement __str__ and __repr__ to ease debugging. __setitem__ won’t be implemented to prevent setting an item and get a TypeError if we try to.

Note

I didn’t use MappingProxyType because I wanted a class that’s easier to extend to fit my needs here. In another context, that’s the solution I’d have chosen to have a readonly mapping.

# Generic type for the key.
Key = TypeVar("Key")
# Generic type for the value.
Value = TypeVar("Value")


class ReadonlyMapping(Generic[Key, Value]):
    def __init__(self, mapping: dict[Key, Value] | None = None):
        self._mapping: dict[Key, Value] = mapping or {}

    def __getitem__(self, key: Key):
        return self._mapping[key]

    def __str__(self):
        return str(self._mapping)

    def __repr__(self):
        return repr(self._mapping)

This class can then be used this way:

readonly_mapping = ReadonlyMapping[int, str]({1: "Test"})
print(readonly_mapping[1])
Test

And it fails as expected on key assignment:

try:
    readonly_mapping[2] = "Testing"
    assert False, "Must fail"
except TypeError as e:
    print("Fails as expected with:", e)
Fails as expected with: 'ReadonlyMapping' object does not support item assignment

Adding Pydantic

Now that the base is built, let’s iterate on that. To hook Pydantic with this custom class, a class method named __get_pydantic_core_schema__ must be added. It’ll use low level Pydantic functions to generate a proper Pydantic schema based on your generic arguments and let Pydantic handle the parsing and the validation. These methods come from pydantic_core and are used internally by Pydantic. They are documented here. Here’s how it looks like (with inline comments to explain its tricky part):

 1 class ReadonlyMapping(Generic[Key, Value]):
 2     ...
 3 
 4     @classmethod
 5     def __get_pydantic_core_schema__(
 6         cls, source: Any, handler: GetCoreSchemaHandler
 7     ) -> core_schema.CoreSchema:
 8         # Retrieve the generic arguments. args is a tuple of the type used for
 9         # Key and the type used for Value.
10         args = get_typing_args(source)
11         # If no generics are passed, args will be an empty tuple.
12         # Let’s fallback to a raw dict in this case.
13         if args:
14             # Make Pydantic generate a schema for dict[Key, Value] that it can
15             # directly use for validation.
16             # This allows for Pydantic to parse the keys and the values and to
17             # validate them without further work.
18             validate_dict_data_schema = handler.generate_schema(
19                 dict[args[0], args[1]]
20             )
21         else:
22             validate_dict_data_schema = handler.generate_schema(dict)
23 
24         # Create a schema that will build our instance from cls
25         # (ie ReadonlyMapping) from the validated dict.
26         from_dict_schema = core_schema.no_info_after_validator_function(
27             cls, validate_dict_data_schema
28         )
29 
30         # Validator to hook an already built instance of ReadonlyMapping
31         # into the model.
32         from_instance_schema = core_schema.is_instance_schema(cls)
33 
34         # Combine from_dict_schema with from_instance_schema to be able
35         # to use both.
36         # They will be tried in order: 1st from_instance_schema and if
37         # it fails from_dict_schema.
38         # If the last one fails, validation will fail.
39         schema = core_schema.union_schema(
40             [from_instance_schema, from_dict_schema]
41         )
42 
43         return core_schema.json_or_python_schema(
44             # Use our schema when raw JSON data is used directly.
45             json_schema=schema,
46             # With Python data.
47             python_schema=schema,
48             # When the model is serialized to Python or JSON, return the
49             # underlying dict that Pydantic can handle directly.
50             serialization=core_schema.plain_serializer_function_ser_schema(
51                 lambda instance: instance._mapping
52             ),
53         )

Testing

Let’s add a test model for testing:

class MyModel(BaseModel):
    my_mapping: ReadonlyMapping[str, int]

And on to the tests we go!

Loading from a dict

m = MyModel.model_validate({"my_int": 1, "my_mapping": {"key": "77"}})
print("The model:", type(m), m)
m_python = m.model_dump(mode="python")
print("As Python:", type(m_python), m_python)
m_json = m.model_dump_json()
print("As JSON:", type(m_json), m_json)
The model: <class '__main__.MyModel'> my_mapping={'key': 77}
As Python: <class 'dict'> {'my_mapping': {'key': 77}}
As JSON: <class 'str'> {"my_mapping":{"key":77}}

Loading from json

m = MyModel.model_validate_json("""{"my_mapping": {"key": "77"}}""")
print("The model from JSON:", m)
The model from JSON: my_mapping={'key': 77}

Key access

print(m.my_mapping, m.my_mapping["key"])
{'key': 77} 77

Access invalid key

try:
    print(m.my_mapping["nope"])
    assert False, "Must fail"
except KeyError as e:
    print("Accessing an invalid key fails as expected with error:", e)
Accessing an invalid key fails as expected with error: 'nope'

Direct creation

m = MyModel(my_mapping=ReadonlyMapping({"key": 77}))
print("Direct creation:", m)
Direct creation: my_mapping={'key': 77}

Validating with a ReadonlyMapping instance

m = MyModel.model_validate({"my_mapping": ReadonlyMapping({"key": 77})})
print("Validating with a ReadonlyMapping instance:", m)
Validating with a ReadonlyMapping instance: my_mapping={'key': 77}

Setting a key

try:
    m.my_mapping["key"] = 0
    assert False, "Must fail"
except TypeError as e:
    print("Setting a key fails as expected with error:", e)
Setting a key fails as expected with error: 'ReadonlyMapping' object does not support item assignment

Key parsing

class MyOtherModel(BaseModel):
    mapping: ReadonlyMapping[int, int | bool]


m = MyOtherModel.model_validate({"mapping": {"1": "1", "2": False}})
print("Keys and values are correctly parsed and validated:", m)
Keys and values are correctly parsed and validated: mapping={1: 1, 2: False}

Validation failure

try:
    MyOtherModel.model_validate({"mapping": {"key": "1", "2": "Some value"}})
    assert False, "Must fail"
except ValidationError as e:
    print("This fails as expected with error e:", e)
This fails as expected with error e: 4 validation errors for MyOtherModel
mapping.is-instance[ReadonlyMapping]
  Input should be an instance of ReadonlyMapping [type=is_instance_of, input_value={'key': '1', '2': 'Some value'}, input_type=dict]
    For further information visit https://errors.pydantic.dev/2.11/v/is_instance_of
mapping.function-after[ReadonlyMapping(), dict[int,union[int,bool]]].key.[key]
  Input should be a valid integer, unable to parse string as an integer [type=int_parsing, input_value='key', input_type=str]
    For further information visit https://errors.pydantic.dev/2.11/v/int_parsing
mapping.function-after[ReadonlyMapping(), dict[int,union[int,bool]]].2.int
  Input should be a valid integer, unable to parse string as an integer [type=int_parsing, input_value='Some value', input_type=str]
    For further information visit https://errors.pydantic.dev/2.11/v/int_parsing
mapping.function-after[ReadonlyMapping(), dict[int,union[int,bool]]].2.bool
  Input should be a valid boolean, unable to interpret input [type=bool_parsing, input_value='Some value', input_type=str]
    For further information visit https://errors.pydantic.dev/2.11/v/bool_parsing

Without any generic annotation:

class NoTypeAnnotationModel(BaseModel):
    mapping: ReadonlyMapping

m = NoTypeAnnotationModel.model_validate({"mapping": {"key": "value", 1: 1}})
print("Read and parsed, no validation:", m)
Read and parsed, no validation: mapping={'key': 'value', 1: 1}

Conclusion

I hope I demystified how to use a custom class with Pydantic. Once you know a bit how __get_pydantic_core_schema__ can be used and how to use the core validation schemas, it’s not that hard. Just like me, you’ll probably have to experiment a bit to fully grasp what is going on. And if something is unclear, don’t hesitate to ask a question below!