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
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
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"]})
A few things worth noticing:
-
choices=[...]replaces the customfield_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_checkis the cross-field hook, playing the same role as Pydantic'smodel_validator(mode="after"), but it's just a plain method you override — no decorator required. - Nesting works exactly like Pydantic: reference another
FastModelclass 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"),
)
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
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)
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)
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 ]+$'}}
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
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,
)
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.*"],
)
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'sBaseModel. -
Hot loops and high-throughput endpoints where you've already got a
FastModelbut 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, orautovalidate_packageif you want it applied wholesale.
pip install validatedata
📖 Full docs: https://validatedata.readthedocs.io
Repo: https://github.com/Edward-K1/validatedata

Top comments (0)