An easier way to validate data in python.
Validatedata is for when you want expressive, inline validation rules without defining model classes. It is not a Pydantic alternative — it is a different tool for a different workflow: scripts, lightweight APIs, CLI tools, and anywhere defining a full model class feels like overkill.
pip install validatedata
For extended phone number validation (national, international, and region-specific formats):
pip install phonenumbers
from validatedata import validate_data
# with shorthand
rule={'keys': {
'username': 'str|min:3|max:32',
'email': 'email',
'age': 'int|min:18',
}}
result = validate_data(
data={'username': 'alice', 'email': 'alice@example.com', 'age': 25},
rule=rule
)
if result.ok:
print('valid!')
else:
print(result.errors)Python 3.7+: For simple cases you can omit the
keyswrapper and pass a bare field map directly:rule = { 'username': 'str|min:3|max:32', 'email': 'email', 'age': 'int|min:18', }The
keysform is recommended when you need to pair field rules with top-level options (such asstrict_keysin a future release).
Validates function arguments against their Python type annotations. Works with or without brackets.
from validatedata import validate_types
@validate_types
def create_user(username: str, age: int):
return f'{username} ({age})'
create_user('alice', 30) # works
create_user('alice', 'thirty') # raises ValidationError
# with options — brackets required
@validate_types(raise_exceptions=False)
def create_user(username: str, age: int):
return f'{username} ({age})'
result = create_user('alice', 'thirty')
# returns {'errors': [...]} instead of raisingfrom validatedata import validate
signup_rules = [
{
'type': 'str',
'expression': r'^[^\d\W_]+[\w\d_-]{2,31}$',
'expression-message': 'invalid username'
},
'email:msg:invalid email address',
{
'type': 'str',
'expression': r'(?=\S*[a-z])(?=\S*[A-Z])(?=\S*\d)(?=\S*[^\w\s])\S{8,}$',
'message': 'password must contain uppercase, lowercase, number and symbol'
}
]
class User:
@validate(signup_rules, raise_exceptions=True)
def signup(self, username, email, password):
return 'Account Created'
user = User()
user.signup('alice_99', 'alice@example.com', 'Secure@123') # works
user.signup('alice_99', 'not-an-email', 'weak') # raises ValidationErrorAsync functions are supported. The decorator behaves identically:
from validatedata import validate
@validate(signup_rules, raise_exceptions=True)
async def signup(self, username, email, password):
await db.save(username, email, password)
return 'Account Created'validate_types works the same way with async functions.
Class methods:
class User:
@classmethod
@validate(rule=['str', 'str'], is_class=True)
def format_name(cls, firstname, lastname):
return f'{firstname} {lastname}'from validatedata import validate_data
rules = [
{'type': 'int', 'range': (1, 'any'), 'range-message': 'must be greater than zero'},
{'type': 'int', 'range': (1, 'any')}
]
result = validate_data(data=[a, b], rule=rules)
if result.ok:
total = a + b
else:
print(result.errors)Dict input:
rules = {'keys': {
'username': {'type': 'str', 'range': (3, 32)},
'age': {'type': 'int', 'range': (18, 'any'), 'range-message': 'must be 18 or older'}
}}
result = validate_data(data={'username': 'alice', 'age': 25}, rule=rules)validate and validate_data:
| Parameter | Type | Default | Description |
|---|---|---|---|
rule |
str, list, tuple, dict | required | validation rules matching the data by index |
raise_exceptions |
bool | False |
raise ValidationError on failure instead of returning errors |
is_class |
bool | False |
set to True for classmethods without self |
mutate |
bool | False |
apply transforms to the original values and return them |
kwds |
dict | — | extra config: log_errors, group_errors |
validate_types:
Same as above except raise_exceptions defaults to True.
Set log_errors=True to log background errors: @validate(rules, kwds={'log_errors': True})
Set group_errors=False to return a flat error list instead of grouped by field.
A SimpleNamespace with:
result.ok—Trueif all validation passedresult.errors— list of errors (grouped by field by default)result.data— transformed data, only present whenmutate=True
result = validate_data(...)
if result.ok:
pass
else:
for error_group in result.errors:
print(error_group)| Type | Description |
|---|---|
bool |
Boolean |
color |
Color in any format. Use format key to specify: hex, rgb, hsl, named |
date |
Date or datetime string |
email |
Email address |
even |
Even integer |
float |
Float |
int |
Integer |
ip |
IPv4 or IPv6 address |
odd |
Odd integer |
phone |
Phone number. E.164 built-in. Extended formats require pip install phonenumbers |
prime |
Prime number |
semver |
Semantic version e.g. 1.0.0, 2.1.0-alpha.1 |
slug |
URL-friendly string e.g. my-blog-post |
str |
String |
url |
URL with protocol e.g. https://example.com |
uuid |
UUID string |
dict, list, object, regex, set, tuple
| Rule | Type | Description |
|---|---|---|
contains |
str or tuple | values expected to be present |
depends_on |
dict | validate only when a sibling field meets a condition |
endswith |
object | value the data must end with |
excludes |
str or tuple | values not permitted |
expression |
str | regular expression the data must match |
fields |
dict | rules for nested dict fields |
items |
dict | rule applied to each item in a list or tuple |
length |
int | exact expected length |
nullable |
bool | allow None as a valid value. Default False |
options |
tuple | permitted values |
range |
tuple | permitted range. Use 'any' for an open bound |
startswith |
object | value the data must start with |
strict |
bool | skip type casting. Default False |
transform |
callable or dict | function applied to the value before validation |
type |
str | type expected. Always required |
unique |
bool | list or tuple must contain no duplicates |
Add a {rule}-message key to override any default error:
rules = [{
'type': 'int',
'range': (18, 'any'),
'range-message': 'you must be at least 18 years old'
}, {
'type': 'str',
'range': (3, 32),
'range-message': 'username must be between 3 and 32 characters'
}, {
'type': 'email',
'message': 'please enter a valid email address'
}]Rules can be expressed as compact strings instead of dicts. There are two syntaxes: the original colon syntax for simple cases, and the newer pipe syntax for anything more expressive. Both work side by side in the same rule list.
'str' # string
'str:20' # string of exactly 20 characters
'int:10' # int of exactly 10 digits
'email' # email address
'email:msg:invalid email address' # with custom error message
'int:1:to:100' # int in range 1 to 100
'regex:[A-Z]{3}' # must match regexThe pipe syntax uses | to chain modifiers onto a type. It supports the full set of validation rules, optional transforms, and custom messages — all in one readable string.
General shape:
type [| transform ...] [| modifier ...] [| msg:message]
Transforms must come before validators. msg: must always be last.
Any supported type name is valid as the first token:
'str|...'
'int|...'
'email|...'
'url|...'
'uuid|...'
# ...any type from the Types section'int|strict' # no type coercion — value must already be the right type
'email|nullable' # None is accepted as a valid value
'int|strict|nullable' # both'int|min:18' # >= 18, no upper limit
'int|max:100' # no lower limit, <= 100
'int|min:0|max:100' # between 0 and 100 inclusive
'int|between:0,100' # shorthand for the above
'str|min:3|max:32' # string length between 3 and 32
'float|min:0.5|max:9.9' # float range
'list|min:1|max:10' # list must have between 1 and 10 itemsmin and max can be used independently for open bounds. between is a convenience alias for min + max together — they cannot be combined.
Note:
validatedatadoes not impose a maximum size on lists or tuples. If you are validating untrusted input in a web API or other public-facing context, always set an explicit upper bound to prevent memory exhaustion from unexpectedly large payloads.
'str|in:admin,user,guest' # value must be one of these
'str|not_in:root,superuser' # value must not be any of these'str|starts_with:https' # must start with this prefix
'str|ends_with:.pdf' # must end with this suffix
'str|contains:@' # must contain this substring
'list|unique' # no duplicate valuesValues can safely contain | — the parser only splits on | when followed by a recognised keyword:
'str|starts_with:image/png|min:3' # 'image/png' is treated as one valueFor types that support format variants:
'color|format:hex' # #fff or #ffffff
'color|format:rgb' # rgb(255, 0, 0)
'color|format:hsl' # hsl(0, 100%, 50%)
'color|format:named' # red, cornflowerblue, etc.
'phone|format:national' # (415) 555-2671 — requires: pip install phonenumbers
'phone|format:e164' # +14155552671 — built-inNamed transforms are applied to the value before validation runs. They are the only modifiers that must come before validators.
'str|strip|min:3|max:32' # strip whitespace, then check length
'str|lower|in:admin,user,guest' # lowercase, then check options
'str|strip|lower|min:3|max:32' # chain as many as neededAvailable named transforms: strip, lstrip, rstrip, lower, upper, title.
To get the transformed value back, pass mutate=True to validate_data or @validate:
result = validate_data([' Alice '], ['str|strip|lower'], mutate=True)
result.data # ['alice']'str|re:[A-Z]{3}' # must match pattern
'str|min:8|re:(?=.*[A-Z])(?=.*\d).+' # combined with other modifiersThe pattern is everything after re: up to the next recognised modifier or end of string. Patterns can safely contain : and |:
'str|re:https?://\S+' # colons in pattern are safe
'str|re:(?=.*[A-Z]|.*\d).+' # pipes in pattern are safemsg: must be the last modifier. The message text can contain any characters including |:
'str|min:3|max:32|msg:must be 3 to 32 characters'
'int|min:18|msg:you must be 18 or older'
'str|re:[A-Z]+|msg:uppercase letters only'
'int|min:0|msg:must be positive | or zero' # | inside message is fineColon shorthand, pipe shorthand, and dict rules can all coexist in the same rule list:
rules = [
{'type': 'str', 'expression': r'^[\w-]{3,32}$', 'expression-message': 'invalid username'},
'email|nullable|msg:invalid email',
'str|min:8|re:(?=.*[A-Z])(?=.*\d).+|msg:password too weak',
]| Modifier | Example | Description |
|---|---|---|
strict |
int|strict |
No type coercion |
nullable |
email|nullable |
Allow None |
unique |
list|unique |
No duplicate values |
min:N |
int|min:18 |
Minimum value or length |
max:N |
int|max:100 |
Maximum value or length |
between:N,M |
int|between:0,100 |
Range shorthand |
in:a,b,c |
str|in:admin,user |
Allowed values |
not_in:a,b |
str|not_in:root |
Excluded values |
starts_with:x |
str|starts_with:https |
Required prefix |
ends_with:x |
str|ends_with:.pdf |
Required suffix |
contains:x |
str|contains:@ |
Required substring |
format:x |
color|format:hex |
Format variant |
strip |
str|strip|min:3 |
Remove surrounding whitespace |
lstrip |
str|lstrip|min:3 |
Remove leading whitespace |
rstrip |
str|rstrip|min:3 |
Remove trailing whitespace |
lower |
str|lower|in:yes,no |
Lowercase before validating |
upper |
str|upper|starts_with:ADM |
Uppercase before validating |
title |
str|title|min:3 |
Title case before validating |
re:pattern |
str|re:[A-Z]{3} |
Regex pattern |
msg:text |
str|min:3|msg:too short |
Custom error message — must be last |
# user signup fields
rules = [
'str|strip|min:3|max:32|msg:username must be 3 to 32 characters',
'email|nullable|msg:invalid email address',
'str|min:8|re:(?=.*[A-Z])(?=.*\d).+|msg:password must contain uppercase and a number',
]
# role with enum
'str|in:admin,editor,viewer|msg:invalid role'
# optional hex colour
'color|format:hex|nullable'
# URL that must use HTTPS
'url|starts_with:https|msg:must be a secure URL'
# slugified identifier
'slug|min:3|max:64|msg:invalid slug'
# age gate
'int|strict|min:18|max:120|msg:invalid age'
# phone — any format, optional
'phone|nullable'
# deduplicated tag list
'list|unique|min:1|max:10'
# transform then validate
'str|strip|lower|in:yes,no,maybe|msg:invalid response'The 'any' keyword is used as an open bound:
Note:
validatedatadoes not impose a maximum size on lists or tuples. If you are validating untrusted input in a web API or other public-facing context, always set an explicit upper bound to prevent memory exhaustion from unexpectedly large payloads.
{'type': 'int', 'range': (1, 'any')} # >= 1, no upper limit
{'type': 'int', 'range': ('any', 100)} # no lower limit, <= 100
{'type': 'int', 'range': (1, 100)} # >= 1 and <= 100
{'type': 'date', 'range': ('01-Jan-2021', 'any')} # from Jan 2021 onwards
{'type': 'date', 'range': ('any', '31-Dec-2025')} # up to Dec 2025
# on str — checks string length
{'type': 'str', 'range': (3, 32)} # len(s) >= 3 and len(s) <= 32
# on list/tuple — checks number of elements
{'type': 'list', 'range': (1, 10)} # between 1 and 10 items# accept any color format
{'type': 'color'}
# specific formats
{'type': 'color', 'format': 'hex'} # #ff0000 or #fff
{'type': 'color', 'format': 'rgb'} # rgb(255, 0, 0)
{'type': 'color', 'format': 'hsl'} # hsl(0, 100%, 50%)
{'type': 'color', 'format': 'named'} # red, cornflowerblue, etc.
result = validate_data(
data={'primary': '#ff0000', 'background': 'white'},
rule={'keys': {
'primary': {'type': 'color', 'format': 'hex'},
'background': {'type': 'color', 'format': 'named'}
}}
)# E.164 format — built-in, no extra install
{'type': 'phone'} # +14155552671
{'type': 'phone', 'format': 'e164'} # same
# extended formats — requires: pip install phonenumbers
{'type': 'phone', 'format': 'national'} # (415) 555-2671
{'type': 'phone', 'format': 'international'} # +1 415-555-2671
{'type': 'phone', 'region': 'GB'} # region-specific validation# url
validate_data(['https://example.com'], [{'type': 'url'}])
# ip — accepts both IPv4 and IPv6
validate_data(['192.168.1.1'], [{'type': 'ip'}])
validate_data(['2001:db8::1'], [{'type': 'ip'}])
# uuid
validate_data(['550e8400-e29b-41d4-a716-446655440000'], [{'type': 'uuid'}])
# slug
validate_data(['my-blog-post'], [{'type': 'slug'}])
# semver
validate_data(['1.2.3'], [{'type': 'semver'}])
validate_data(['2.0.0-alpha.1'], [{'type': 'semver'}])
# prime
validate_data([7], [{'type': 'prime'}])
# even and odd
validate_data([4], [{'type': 'even'}])
validate_data([3], [{'type': 'odd'}])rules = {'keys': {
'name': {'type': 'str'},
'middle_name': {'type': 'str', 'nullable': True}, # optional
'age': {'type': 'int'}
}}
validate_data({'name': 'Alice', 'middle_name': None, 'age': 30}, rules).ok # True
validate_data({'name': 'Alice', 'middle_name': 'Jane', 'age': 30}, rules).ok # Truerules = [{'type': 'list', 'unique': True}]
validate_data([[1, 2, 3]], rules).ok # True
validate_data([[1, 2, 2]], rules).ok # False — duplicatesSimple — pass a callable:
rules = [{'type': 'str', 'transform': str.strip, 'length': 5}]
validate_data([' hello '], rules).ok # True — stripped before length checkComplex — access sibling fields:
rules = {'keys': {
'role': {'type': 'str'},
'username': {
'type': 'str',
'transform': {
'func': lambda value, data: value.upper() if data.get('role') == 'admin' else value,
'pass_data': True
}
}
}}With mutate=True — get back the transformed values:
result = validate_data(
data=[' alice ', ' bob '],
rule=[
{'type': 'str', 'transform': str.strip},
{'type': 'str', 'transform': str.strip}
],
mutate=True
)
result.ok # True
result.data # ['alice', 'bob']Using mutate=True with the decorator passes transformed values into the function:
@validate(rules, mutate=True)
def save_user(username):
# username arrives already stripped
db.save(username)Validate a field only when a sibling field meets a condition:
# simple equality check
rules = {'keys': {
'role': {'type': 'str'},
'permissions': {
'type': 'str',
'depends_on': {'field': 'role', 'value': 'admin'},
'options': ('full', 'read', 'none')
}
}}
# permissions only validated when role is 'admin'
validate_data({'role': 'user', 'permissions': 'anything'}, rules).ok # True
validate_data({'role': 'admin', 'permissions': 'full'}, rules).ok # True
validate_data({'role': 'admin', 'permissions': 'anything'}, rules).ok # FalseCallable condition for complex logic:
rules = {'keys': {
'age': {'type': 'int'},
'guardian_name': {
'type': 'str',
'depends_on': {
'field': 'age',
'condition': lambda age: age < 18
},
'message': 'guardian name required for users under 18'
}
}}class Address:
pass
rules = [{'type': 'object', 'object': Address, 'message': 'Address object expected'}]
address = Address()
validate_data([address], rules).ok # True
validate_data(['not an address'], rules).ok # FalseWhen rules contain fields or items, errors are automatically returned as path-prefixed flat strings instead of the default grouped format.
Nested dict:
rules = {'keys': {
'user': {
'type': 'dict',
'fields': {
'username': {'type': 'str', 'range': (3, 32)},
'email': {'type': 'email'},
'age': {'type': 'int', 'range': (18, 'any')}
}
}
}}
result = validate_data(
data={'user': {'username': 'al', 'email': 'not-an-email', 'age': 25}},
rule=rules
)
result.ok # False
result.errors # ['user.username: invalid string length', 'user.email: invalid email']Deeply nested:
rules = {'keys': {
'company': {
'type': 'dict',
'fields': {
'name': {'type': 'str'},
'address': {
'type': 'dict',
'fields': {
'street': {'type': 'str'},
'city': {'type': 'str'},
'postcode': {'type': 'str', 'length': 6}
}
}
}
}
}}
result = validate_data(
data={'company': {'name': 'Acme', 'address': {'street': '1 Main St', 'city': 'Lagos', 'postcode': '123'}}},
rule=rules
)
result.errors # ['company.address.postcode: value is not of required length']List of typed items:
rules = [{'type': 'list', 'items': {'type': 'int', 'range': (1, 100)}}]
result = validate_data([[10, 50, 200, 5]], rules)
result.errors # ['[0][2]: number out of range']List of dicts:
rules = [{'type': 'list', 'items': {
'type': 'dict',
'fields': {
'name': {'type': 'str'},
'score': {'type': 'int', 'range': (0, 100)}
}
}}]
result = validate_data(
data=[[
{'name': 'Alice', 'score': 95},
{'name': 'Bob', 'score': 150}, # invalid
]],
rule=rules
)
result.errors # ['[0][1].score: number out of range']from validatedata import validate, ValidationError
rules = [{'type': 'email', 'message': 'invalid email'}]
@validate(rules, raise_exceptions=True)
def send_email(address):
...
try:
send_email('not-an-email')
except ValidationError as e:
print(e) # invalid email# contains — value must include these
{'type': 'str', 'contains': '@'}
{'type': 'list', 'contains': ('admin', 'user')}
# excludes — value must not include these
{'type': 'str', 'excludes': ('forbidden', 'banned')}
# options — value must be one of these (equal to)
{'type': 'str', 'options': ('active', 'inactive', 'pending')}
# not equal to — achieved with excludes
{'type': 'str', 'excludes': ('deleted',)}# strings
{'type': 'str', 'startswith': 'https'}
{'type': 'str', 'endswith': '.pdf'}
# lists
{'type': 'list', 'startswith': 'header'}
{'type': 'list', 'endswith': 'footer'}By default validatedata casts values before checking type (strict=False), so "42" passes as an int. Set strict=True to require exact types:
{'type': 'int', 'strict': True} # "42" will fail, only 42 passes
{'type': 'str', 'strict': True} # 42 will fail, only "42" passesfrom validatedata import validate, validate_data
# validate a product creation request
product_rules = {'keys': {
'name': {'type': 'str', 'range': (2, 100)},
'slug': {'type': 'slug', 'message': 'slug must be lowercase with hyphens only'},
'price': {'type': 'float', 'range': (0, 'any'), 'range-message': 'price must be positive'},
'version': {'type': 'semver'},
'homepage': {'type': 'url', 'nullable': True},
'tags': {'type': 'list', 'unique': True, 'nullable': True},
'variants': {
'type': 'list',
'items': {
'type': 'dict',
'fields': {
'sku': {'type': 'uuid'},
'color': {'type': 'color'},
'stock': {'type': 'int', 'range': (0, 'any')}
}
}
}
}}
result = validate_data(data=request_body, rule=product_rules)
if not result.ok:
return {'status': 400, 'errors': result.errors}depends_ononly works whendatais a dict since it needs access to sibling fields- Nested data (
fields,items) automatically switches error format to path-prefixed strings - The current version does not support
depends_onacross nested levels transformruns before type checking, so the transformed value is what gets validated
Contributions are welcome!
Before starting work on a new feature or non-trivial change, please open an issue first. This helps avoid duplicate effort and lets us align on scope and approach before any code is written.
- Open an issue describing what you'd like to add or change
- You'll be informed if there's someone working on it and given the green light if it's the right call
- Fork the repository and create a branch off
main
git checkout -b feature/your-feature-name
- Make your changes and add tests where appropriate
- Open a pull request referencing the issue
For bug fixes and small improvements, feel free to skip the issue and go straight to a PR.
MIT