Quickstart

Installation

Modelity is available on PyPI and can be installed or added to your project’s dependencies using any available Python package manager.

For example, to install Modelity using pip, run following command:

$ pip install modelity

Defining domain models

For the purpose of this quickstart guide we’ll create a domain model of a simple ordering system composed of two models: OrderItem (representing single order item) and Order (representing collection of order items). Let’s create initial draft of these two models using Modelity:

import datetime

from modelity.api import Model

class OrderItem(Model):
    name: str
    quantity: int
    price: float

class Order(Model):
    items: list[OrderItem]
    modified: datetime.datetime
    created: datetime.datetime

All fields in models created above are currently required and have their types set, but with no constraints yet. Let’s now add some field-level constraints and to do so we need typing.Annotated and some constraints provided by Modelity. Here’s an updated previous example with comments:

import datetime
from typing import Annotated

from modelity.api import Model, Gt, Ge, MinLen

class OrderItem(Model):
    name: str
    quantity: Annotated[int, Gt(0)]  # quantity must be > 0
    price: Annotated[float, Ge(0)]  # price must be >= 0 (we can offer things for free)

class Order(Model):
    items: Annotated[list[OrderItem], MinLen(1)]  # minimum number of items in order is 1
    modified: datetime.datetime
    created: datetime.datetime

Note

Constraints to be used with typing.Annotated are used to ensure data integrity at the field level. Check modelity.constraints to get documentation on all supported constraints.

Now we have field-level constraint set, so each field will be prevented from being set to either wrong type, or right type but incorrect value (e.g. negative price). But none of these fields have any knowledge about the model itself and neighboring dependent fields. For example, how to ensure that modified >= created? To do so we need to define cross-field invariants and here Modelity hook system comes in:

import datetime
from typing import Annotated

from modelity.api import Model, UserError, Gt, Ge, MinLen, field_validator

class OrderItem(Model):
    name: str
    quantity: Annotated[int, Gt(0)]
    price: Annotated[float, Ge(0)]

class Order(Model):
    items: Annotated[list[OrderItem], MinLen(1)]
    modified: datetime.datetime
    created: datetime.datetime

    @field_validator("modified")
    def _validate_modified(self, value: datetime.datetime):
        if value < self.created:
            raise UserError(f"Incorrect value for `modified` field; cannot precede `created` datetime")

We’ve used modelity.api.field_validator() hook to create field-level validators for modified field. This hook will run during model validation and only if modified field is set.

Important

The modelity.api.UserError exception is in fact a helper. It will not be propagated, but handled by Modelity, converted into modelity.api.Error object and added to validation errors list. We’ll talk about error handling in more details later in this handbook.

Now the model is basically complete, but it still can be improved a bit. Currently, both modified and created have to be explicitly given during construction of the model. Let’s make created to be automatically assigned during construction to a current datetime, and modified to automatically be aligned with created. We can achieve that by specifying default value for created field and by using modelity.hooks.after_field_set() hook:

import datetime
from typing import Annotated

from modelity.api import (
    Model, field_info, is_unset, UserError, Deferred, Gt, Ge, MinLen,
    Unset, field_validator, after_field_set
)

class OrderItem(Model):
    name: str
    quantity: Annotated[int, Gt(0)]
    price: Annotated[float, Ge(0)]

class Order(Model):
    items: Annotated[list[OrderItem], MinLen(1)]  # This is the only field that is required
    modified: Deferred[datetime.datetime] = Unset  # Deferred[T] -> can be omitted when constructing, but must be set before validation
    created: datetime.datetime = field_info(default_factory=datetime.datetime.now)  # current datetime will be used as default

    @after_field_set("created")
    def _update_modified(self, value: datetime.datetime):
        if is_unset(self.modified):
            self.modified = value  # Set `modified` to `created` if not set yet

    @field_validator("modified")
    def _validate_modified(self, value: datetime.datetime):
        if value < self.created:
            raise UserError(f"Incorrect value for `modified` field; cannot precede `created` datetime")

At this point our domain model is complete and we’re ready to move forward.

Creating model instances

Creating model instances is done by calling model’s constructor and passing field values using keyword args:

>>> order = Order(items=[OrderItem(name="apple", quantity=2, price=2.5)])
>>> order.created is not None
True
>>> order.created == order.modified
True

As you can see, also created and modified were implicitly set. Of course it is possible to give all the arguments by hand:

>>> import datetime
>>> apple = OrderItem(name="apple", quantity=2, price=2.5)
>>> order = Order(
...     items=[apple],
...     modified=datetime.datetime(2026, 1, 2),
...     created=datetime.datetime(2026, 1, 1),
... )
>>> order.items
[OrderItem(name='apple', quantity=2, price=2.5)]
>>> order.created
datetime.datetime(2026, 1, 1, 0, 0)
>>> order.modified
datetime.datetime(2026, 1, 2, 0, 0)

If constructor is called without arguments and model has required fields defined then you’ll get a modelity.api.ParsingError exception with detailed list of errors, in this case signalling which required fields were found missing:

>>> Order()
Traceback (most recent call last):
  ...
modelity.exc.ParsingError: Found 1 parsing error for type 'Order':
  items:
    This field is required [code=modelity.REQUIRED_MISSING, value_type=UnsetType]

Similar error will also be reported when you try to use an incompatible value for a field:

>>> Order(items=[apple], created=123)
Traceback (most recent call last):
  ...
modelity.exc.ParsingError: Found 1 parsing error for type 'Order':
  created:
    Not a valid value; expected: datetime [code=modelity.INVALID_TYPE, value_type=int, expected_types=[datetime], allowed_types=[str]]

But even string can be accepted (or any other compatible type) for as long as Modelity parsing engine can automatically convert it to a valid type, like in example below:

>>> banana = OrderItem(name="banana", quantity="3", price="1.5")
>>> type(banana.quantity)
<class 'int'>
>>> type(banana.price)
<class 'float'>
>>> banana
OrderItem(name='banana', quantity=3, price=1.5)

Modifying existing model instances

Modelity allows to alter models after creation and when doing so same mechanics come into play as used during construction.

Let’s once again create order model:

>>> apple = OrderItem(name="apple", quantity=2, price=2.5)
>>> banana = OrderItem(name="banana", quantity=3, price=1.5)
>>> order = Order(items=[apple, banana], created=datetime.datetime(2000, 1, 1, 0, 0))

And now let’s assume that our application needs to add an orange to that order. This is actually pretty straightforward; just append new item to the items list:

>>> orange = OrderItem(name="orange", quantity=1, price=3.0)
>>> order.items.append(orange)

What is important, proper handling of mutable typed containers is one of core features of Modelity, so standard type checking and coercion attempts take place while modifying a typed container. For example, you’ll not be able to append integer number as another order item:

>>> order.items.append(123)
Traceback (most recent call last):
  ...
modelity.exc.ParsingError: Found 1 parsing error for type 'list[OrderItem]':
  3:
    Not a valid value; expected: OrderItem [code=modelity.INVALID_TYPE, value_type=int, expected_types=[OrderItem], allowed_types=[Mapping]]

Same thing will happen with model fields; you can modify those, but modification will fail if incorrect value is provided, or provided value does not meet field-level constraints:

>>> order.items = []
Traceback (most recent call last):
  ...
modelity.exc.ParsingError: Found 1 parsing error for type 'Order':
  items:
    Expected length >= 1 [code=modelity.INVALID_LENGTH, value_type=MutableSequenceProxy, min_length=1]

Validating model instances

To validate models you need a modelity.api.validate() helper. It will call all built-in validators and user-defined ones. The model is valid if running validation succeeds without raising exceptions. For example, our previous model is already valid:

>>> from modelity.api import validate
>>> validate(order)

And now let’s break it by setting modified datetime to be earlier than created and see what will happen if we validate again:

>>> order.modified = order.created - datetime.timedelta(days=1)
>>> validate(order)
Traceback (most recent call last):
  ...
modelity.exc.ValidationError: Found 1 validation error for model 'Order':
  modified:
    Incorrect value for `modified` field; cannot precede `created` datetime [code=modelity.USER_ERROR]

Validation errors are signalled using modelity.exc.ValidationError exception.

Serializing models

To serialize models, modelity.helpers.dump() function is needed. It serializes models to a closest JSON-compatible Python dict. This is how this works:

>>> from modelity.helpers import dump
>>> order.modified = order.created  # Let's fix what we broke in previous example
>>> validate(order)  # It is recommended, yet not required
>>> order_dict = dump(order)
>>> order_dict
{'items': [{'name': 'apple', 'quantity': 2, 'price': 2.5}, {'name': 'banana', 'quantity': 3, 'price': 1.5}, {'name': 'orange', 'quantity': 1, 'price': 3.0}], 'modified': '2000-01-01T00:00:00.000000', 'created': '2000-01-01T00:00:00.000000'}

Now you can use any library you like to further dump order_dict into JSON or any other format, as this is out of Modelity scope.

Deserializing models

To build model object from serialized data, you have to use modelity.helpers.load() function:

>>> from modelity.helpers import load
>>> loaded_order = load(Order, order_dict)
>>> isinstance(loaded_order, Order)
True
>>> loaded_order == order
True

This helper is automatically validating given data as it is meant to be used with data coming from untrusted source. Therefore if you run on empty dict that does not have required items key, following error will be reported:

>>> load(Order, {})
Traceback (most recent call last):
  ...
modelity.exc.ParsingError: Found 1 parsing error for type 'Order':
  items:
    This field is required [code=modelity.REQUIRED_MISSING, value_type=UnsetType]

And similar thing will happen if items exists, but is incorrect:

>>> incorrect_data = {
...     "items": [{"name": "banana"}]
... }
>>> load(Order, incorrect_data)
Traceback (most recent call last):
  ...
modelity.exc.ParsingError: Found 2 parsing errors for type 'Order':
  items.0.price:
    This field is required [code=modelity.REQUIRED_MISSING, value_type=UnsetType]
  items.0.quantity:
    This field is required [code=modelity.REQUIRED_MISSING, value_type=UnsetType]

As seen in the examples above, each found error points to the exact location in the input data where the error was found.

Next steps

Now you’ve learned the basics of how Modelity can be used. You can now proceed to the full guide to learn all features in details.