FastModel – Declarative Models

Added in version 0.6.0.

FastModel is a declarative validation model built on validatedata’s compiled core. It combines the speed of validator() with the convenience of typed, reusable models – ideal for API schemas, configuration objects, and domain entities.


Basic usage

Define a model by subclassing FastModel and annotating fields with types. Use Rule to add validation constraints.

from validatedata import FastModel, Rule

class User(FastModel):
    id: int = Rule(type="int")
    username: str = Rule(min=3, max=32, pattern=r'^[a-z0-9_]+$')
    email: str = Rule("email")          # pipe syntax works too
    tags: list[str] = Rule(default=[], init_new=True, max=20)

# Instantiate – validates on creation
user = User(id=1, username="alice", email="alice@example.com")

# Invalid data raises ValidationError
try:
    bad = User(id=1, username="a", email="not-an-email")
except ValidationError as e:
    print(e.errors)   # {'username': [...], 'email': [...]}

Full example — most Rule options at once

The example below is deliberately dense: it exercises nearly every Rule option and shows the two syntaxes side by side. Keyword form with an explicit type= is preferred for non-str types and for anything past a bare type check; pipe strings remain fully supported and are shown here both as a fallback and for cases the keyword shortcuts don’t cover.

from validatedata import FastModel, Rule, ValidationError


class Address(FastModel):
    """Nested model — used as a field type on Order below."""

    # Keyword form with an explicit type=, since "str" needs no extra
    # constraints here beyond a length range.
    street: str = Rule(type="str", min=3, max=64)

    # Pipe-string form (fallback / shortform) — identical result to
    # Rule(type="str", pattern=r'^[A-Za-z ]+$'), just written as one string.
    city: str = Rule("str|re:^[A-Za-z ]+$")

    # pattern kwarg -> compiled to |re: internally. Also shows nullable=True
    # as an explicit keyword instead of appending "|nullable" by hand.
    postal_code: str = Rule(type="str", pattern=r'^[0-9]{4,10}$', nullable=True)


class Order(FastModel):
    """
    Exercises keyword constraints with explicit type=, pipe-string
    fallback, choices, regex patterns, transforms, custom messages,
    nullable fields, mutable defaults via init_new, a default_factory,
    a nested FastModel field, and cross-field validation via model_check.
    """

    # --- required scalar fields, keyword form with explicit type= ---
    order_id: str = Rule(type="str", pattern=r'^ORD-[0-9]{6}$')

    # int with min/max and a custom failure message
    priority: int = Rule(type="int", min=1, max=5, msg="priority must be 1-5")

    # choices -> compiled to "in:pending,paid,shipped,cancelled"
    status: str = Rule(type="str", choices=["pending", "paid", "shipped", "cancelled"])

    # transforms kwarg: strip + lower BEFORE the pattern check runs
    customer_email: str = Rule(type="email", transforms="strip|lower")

    # Pipe-string-only fallback showing the exact same transform pipeline
    # written by hand, for cases where the shortform keywords don't fit.
    coupon_code: str = Rule("str|strip|upper|min:4|max:12|nullable")

    # --- floats / numeric edge types ---
    discount_rate: float = Rule(type="float", min=0.0, max=1.0, msg="discount_rate must be between 0 and 1")

    # --- nullable without touching the pipe string directly ---
    gift_message: str = Rule(type="str", max=200, nullable=True)

    # --- mutable defaults ---
    # init_new=True gives every Order instance its own fresh list, instead
    # of sharing one mutable default across instances.
    tags: list[str] = Rule("list[str]", default=[], init_new=True, max=20)  # item type enforced
    # default_factory is the explicit-callable equivalent of init_new for
    # cases where the container needs custom construction logic.
    metadata: dict = Rule(type="dict", default_factory=dict)

    # --- nested FastModel field ---
    # A dict passed for this field is converted into an Address instance
    # (recursively validated) when built via from_dict — see below.
    shipping_address: Address

    # Plain list field (count constraints only — a single Rule can't
    # express a per-item schema; use validate_data()'s dict-rule 'items'
    # key for that instead).
    items: list = Rule(type="list", min=1)

    def model_check(self, data: dict):
        """Cross-field logic that can't live on a single Rule."""
        if data["status"] == "cancelled" and data["discount_rate"] > 0:
            raise ValidationError(
                {"discount_rate": ["cancelled orders cannot carry a discount"]}
            )
        # Returning a dict mutates fields after validation passes.
        item_count = len(data["items"])
        return {"metadata": {**data["metadata"], "item_count": item_count}}


# from_dict recursively builds nested FastModel fields (a dict passed for
# shipping_address is converted into an Address instance automatically).
# validate=True runs the full __init__ path and raises ValidationError on
# bad data. Direct Order(...) construction also works, but nested fields
# must then be passed as already-built model instances (Address(...)),
# not raw dicts.
order = Order.from_dict(
    {
        "order_id": "ORD-004821",
        "priority": 3,
        "status": "pending",
        "customer_email": "  Alice@Example.com  ",   # -> stripped + lowercased
        "coupon_code": "save10",                      # -> "SAVE10"
        "discount_rate": 0.1,
        "gift_message": None,
        "shipping_address": {"street": "1 Main St", "city": "Springfield", "postal_code": None},
        "items": [
            {"sku": "ABC-1234", "quantity": 2, "unit_price": 9.99},
            {"sku": "XYZ-0007", "quantity": 1, "unit_price": 24.50},
        ],
    },
    validate=True,
)

print(order.customer_email)      # alice@example.com
print(order.coupon_code)         # SAVE10
print(order.shipping_address)    # Address(street='1 Main St', city='Springfield', postal_code=None)
print(order.metadata)            # {'item_count': 2}

Note

Rule fields are field-scoped only — cross-field logic like the cancelled/discount check above always belongs in model_check, never on a Rule.

Tip

Every keyword shortcut compiles down to a pipe string internally. If a constraint has no dedicated keyword, drop to the pipe string directly — both forms can be mixed freely across fields in the same model, as shown with coupon_code above.


Cross‑field validation

Implement a model_check method to run cross‑field logic. It receives a dict of all field values and can mutate them by returning a dict.

class Order(FastModel):
    start: int
    end: int

    def model_check(self, data: dict):
        if data["end"] <= data["start"]:
            raise ValidationError({"end": ["end must be greater than start"]})
        # Optionally mutate
        return {"end": data["end"] + 1}   # add one day

Serialisation / Deserialisation

Models can be converted to dicts and reconstructed.

# to dict
data = user.to_dict()   # {'id': 1, 'username': 'alice', ...}

# recommeded if you want speed. its the same as from_dict(data, validate="check")
user2 = User.from_dict(data)

# from dict – full validation (raises on invalid)
user3 = User.from_dict(data, validate=True)

# from dict – check only, returns None on invalid
user4 = User.from_dict(data, validate="check")

# never use unless you validated the data earlier and you trust it
user5 = User.from_dict(data, validate=False)

Nested models are automatically handled:

class Address(FastModel):
    street: str
    city: str

class User(FastModel):
    name: str
    address: Address

data = {"name": "Alice", "address": {"street": "123 Main St", "city": "Springfield"}}
user = User.from_dict(data)   # address is automatically converted to Address instance

Partial validation (no instantiation)

Use check to validate a dict without creating an instance.

ok, errors = User.check({"username": "alice", "email": "alice@example.com"})
if not ok:
    print(errors)

Fast boolean checks

Use is_valid on an instance or is_valid_data on a class for a fast boolean check.

if user.is_valid():        # whole model
    ...

if user.is_valid(field="username"):   # single field
    ...

if User.is_valid_data({"username": "alice"}):   # class‑level, dict only
    ...

Schema introspection

schema() returns a lightweight description of the model fields – useful for documentation or API generation.

User.schema()
# {
#   "model": "User",
#   "fields": {
#     "username": {"rule": "str|min:3|max:32|re:...", "required": True},
#     "email":    {"rule": "email", "required": True},
#     "tags":     {"rule": None, "required": False, "default": []},
#   }
# }

Rule dict and compiled validator access

get_rules() returns the canonical rule dictionary used at class-creation time — each value is a pipe string, or a nested rule dict for fields whose type is a FastModel subclass.

Order.get_rules()["shipping_address"]
# {'street': 'str|min:3|max:64', 'city': 'str|re:^[A-Za-z ]+$', 'postal_code': 'str|re:^[0-9]{4,10}$|nullable'}

get_validator() returns the compiled boolean validator callable for the whole model — the same function used internally by is_valid_data and the “check” branch of from_dict.

validate = User.get_validator()
validate({"username": "alice", "email": "alice@example.com"})   # True / False