DEV Community

Coddy
Coddy

Posted on

Stop reaching for Pydantic by default: meet `validatedata`

If you write Python, you already know the drill. Someone needs to validate a dict, a request body, or a config file, and the reflex is pip install pydantic. That's usually the right call — but not always. Pydantic's BaseModel brings in a Rust core, a metaclass, and a fairly large surface area, even when all you actually need is "does this dict look right, yes or no."

validatedata is a pure-Python library built for that gap — and as of v0.6, it's no longer just the lightweight inline-rules tool. It now ships FastModel, a declarative, class-based model with compiled validation and serialization, sitting right next to the original one-line validators. You get to pick the right tool for the job without switching libraries.

pip install validatedata
Enter fullscreen mode Exit fullscreen mode

The headline number

Before getting into the API, here's why people are trying it. On 1 million repetitions, validatedata's validator() beats Pydantic v2, msgspec, and fastjsonschema on both valid and invalid dict inputs:

Test validatedata (validator) msgspec pydantic fastjsonschema
Dict (valid) 1.1373s 1.2550s 4.2318s 4.6816s
Dict (invalid) 0.2359s 1.1655s 4.6230s 3.0652s

FastModel vs. Pydantic's BaseModel

Let's look at the same model in both libraries. Here's a User with a nested Address, in Pydantic:

from pydantic import BaseModel, Field, field_validator

class Address(BaseModel):
    street: str = Field(min_length=3, max_length=64)
    city: str = Field(pattern=r'^[A-Za-z ]+$')

class User(BaseModel):
    id: int
    username: str = Field(min_length=3, max_length=32, pattern=r'^[a-z0-9_]+$')
    role: str
    email: str
    tags: list[str] = Field(default_factory=list, max_length=20)
    address: Address

    @field_validator('role')
    @classmethod
    def check_role(cls, v):
        if v not in ('admin', 'member', 'guest'):
            raise ValueError('invalid role')
        return v

    @field_validator('id')
    @classmethod
    def check_id(cls, v):
        if v < 0:
            raise ValueError('ID must be positive')
        return v
Enter fullscreen mode Exit fullscreen mode

And the equivalent in validatedata:

from validatedata import FastModel, Rule, ValidationError

class Address(FastModel):
    street: str = Rule(type="str", min=3, max=64)
    city: str = Rule("str|re:^[A-Za-z ]+$")   # pipe syntax works too

class User(FastModel):
    id: int = Rule(type="int")
    username: str = Rule(type="str", min=3, max=32, pattern=r'^[a-z0-9_]+$')
    role: str = Rule(type="str", choices=["admin", "member", "guest"])
    email: str = Rule("email")
    tags: list[str] = Rule(type="list[str]", default=[], init_new=True, max=20)
    address: Address   # nested FastModel field

    def model_check(self, data: dict):
        # cross-field validation, same idea as a Pydantic model_validator
        if data["id"] < 0:
            raise ValidationError({"id": ["ID must be positive"]})
Enter fullscreen mode Exit fullscreen mode

A few things worth noticing:

  • choices=[...] replaces the custom field_validator — no need to write a function for a plain enum check.
  • Regex either as a keyword (pattern=) or inline in the pipe string (re:^...$) — pick whichever reads better for the field.
  • model_check is the cross-field hook, playing the same role as Pydantic's model_validator(mode="after"), but it's just a plain method you override — no decorator required.
  • Nesting works exactly like Pydantic: reference another FastModel class as a field type, and validation recurses automatically.

Instantiating and using it:

user = User(
    id=1, username="alice", role="admin", email="alice@example.com",
    address=Address(street="1 Main St", city="Springfield"),
)
Enter fullscreen mode Exit fullscreen mode

Validation runs at construction time, same as Pydantic — invalid data raises immediately, so you're never holding a half-valid object.


Serialization and deserialization

Pydantic gives you .model_dump() / .model_validate(). FastModel gives you .to_dict() / .from_dict(), and nested models round-trip automatically:

# serialise
data = user.to_dict()
# {'id': 1, 'username': 'alice', 'role': 'admin', 'email': 'alice@example.com',
#  'tags': [], 'address': {'street': '1 Main St', 'city': 'Springfield'}}

# deserialise — nested dicts are recursively converted back into FastModel instances
user2 = User.from_dict(data)

# from_dict returns None on invalid data by default;
# pass validate=True if you'd rather it raise
maybe_user = User.from_dict(bad_data, validate=True)  # raises ValidationError
Enter fullscreen mode Exit fullscreen mode

That recursive nested conversion — turning data['address'] back into an Address instance — is handled for you. You don't write a custom from_dict per model.


The fast path: validator() vs. FastModel.get_validator()

This is where validatedata's design gets interesting. Sometimes you don't want an object — you just want True/False as fast as possible, for example validating a million webhook payloads in a queue consumer.

Standalone, no model class at all:

from validatedata import validator

validate_user = validator({
    'username': 'str|min:3|max:32',
    'email':    'email',
    'age':      'int|min:18'
})

if validate_user({'username': 'bob', 'email': 'bob@example.com', 'age': 25}):
    process(payload)
Enter fullscreen mode Exit fullscreen mode

Same compiled speed, but derived from a FastModel you already defined, via get_validator():

# Compiled once, cached on the class — every call after the first
# returns the same function object
validate_user = User.get_validator()

for payload in incoming_requests:
    if not validate_user(payload):
        reject(payload)
        continue
    process(payload)
Enter fullscreen mode Exit fullscreen mode

The point of get_validator() is that you keep FastModel as your single source of truth for the schema — used for normal instantiation, serialization, documentation, whatever — but in a hot loop you skip building a User(**kwargs) instance every call and just get the boolean check. No duplicated rule definitions, no drift between "the model" and "the fast check."

There's also get_rules(), which returns the exact compiled rule dict that get_validator() was built from — handy for logging what a model actually enforces, or feeding those rules into a separate validator() call elsewhere:

User.get_rules()
# {'id': 'int', 'username': 'str|min:3|max:32|re:^[a-z0-9_]+$',
#  'role': 'str|in:admin,member,guest', 'email': 'email',
#  'tags': 'list[str]|max:20|nullable',
#  'address': {'street': 'str|min:3|max:64', 'city': 'str|re:^[A-Za-z ]+$'}}
Enter fullscreen mode Exit fullscreen mode

Decorators: validating function boundaries, not just data

This is the part Pydantic doesn't really do — validatedata treats function signatures as a first-class validation target.

@validate_types checks arguments against your existing type hints, no rules to write:

from validatedata import validate_types

@validate_types
def create_user(username: str, age: int):
    return f'{username} ({age})'

create_user('alice', 30)        # fine
create_user('alice', 'thirty')  # raises ValidationError
Enter fullscreen mode Exit fullscreen mode

It supports async def functions and classmethods (is_class=True) without any special-casing on your part.

And then there's autovalidate / autovalidate_package, which is the feature that turns this from "a validation library" into "a validation policy for your whole codebase":

# bottom of a module you want fully validated
from validatedata import autovalidate

autovalidate(
    module="my_project.my_module",
    raise_exceptions=True,
    enforce_hints=False,
)
Enter fullscreen mode Exit fullscreen mode

Or for an entire package, dropped into the root __init__.py:

from validatedata import autovalidate_package

report = autovalidate_package(
    package="my_project",
    include=["my_project.*"],
    exclude=["my_project.tests.*"],
)
Enter fullscreen mode Exit fullscreen mode

Every type-hinted function under my_project gets @validate_types applied automatically. There's a dry_run=True mode if you want to see what would be decorated before committing to it. You can also swap the default library decorator for one of another library or one rolled manually by you.


So when do you actually reach for this?

  • Quick scripts, CLI tools, one-off API validation where defining a full model class is more ceremony than the task deserves → validate_data / validator().
  • Structured data you reuse across the app — API request/response shapes, config objects — where you want the model as documentation and the source of truth → FastModel, same niche as Pydantic's BaseModel.
  • Hot loops and high-throughput endpoints where you've already got a FastModel but don't want the overhead of building instances → User.get_validator().
  • A codebase where you want type hints to actually mean something at runtime, without hand-rolling checks everywhere → @validate_types, or autovalidate_package if you want it applied wholesale.
pip install validatedata
Enter fullscreen mode Exit fullscreen mode

📖 Full docs: https://validatedata.readthedocs.io
Repo: https://github.com/Edward-K1/validatedata

Top comments (0)