Custom Validators — Complete Guide
In this tutorial, you'll learn about Custom Validators. We cover key concepts, practical examples, and best practices to help you understand and apply this topic effectively.
Custom validators are user-defined validation functions that implement business-specific rules beyond type and schema validation, such as password strength, unique email checks, and domain logic.
What You'll Learn
By the end of this lesson, you will build parameterized custom validators, compose multiple validators, and integrate them into a validation pipeline.
Why It Matters
Built-in validators handle types and formats. Business rules are unique to each application. Custom validators encode domain logic that schema validators cannot express.
Real-World Use
A registration form needs custom validators: password strength (min 8 chars, uppercase, digit), username uniqueness (async check against database), and age verification (must be 13+).
Custom Validator Flow
flowchart LR
Input --> BuiltIn[Type/Format Check]
BuiltIn --> Custom[Business Rule Check]
Custom --> DB[(Database Check)]
DB --> Result[Valid/Invalid]
Custom Validator Functions
# custom_validators.py
import re
from typing import Any, Callable, Dict, List, Optional
class Validator:
def __init__(self, name: str, fn: Callable, message: str):
self.name = name
self.fn = fn
self.message = message
def validate(self, value: Any, context: Dict = None) -> Optional[str]:
if not self.fn(value, context or {}):
return self.message
return None
class ValidatorRegistry:
def __init__(self):
self.validators: Dict[str, Validator] = {}
def register(self, name: str, fn: Callable, message: str):
self.validators[name] = Validator(name, fn, message)
def validate(self, field: str, value: Any, rules: List[str],
context: Dict = None) -> List[str]:
errors = []
for rule in rules:
validator = self.validators.get(rule)
if validator:
error = validator.validate(value, context)
if error:
errors.append(f"{field}: {error}")
return errors
registry = ValidatorRegistry()
registry.register("required", lambda v, ctx: v is not None and v != "", "{field} is required")
registry.register("email", lambda v, ctx: re.match(r'[^@]+@[^@]+\.[^@]+', str(v)) is not None, "invalid email format")
registry.register("min_age", lambda v, ctx: v is None or (isinstance(v, (int, float)) and v >= 13), "must be 13 or older")
registry.register("unique_username", lambda v, ctx: v not in ctx.get("existing_users", []), "username already taken")
errors = registry.validate("email", "not-an-email", ["required", "email"])
print(f"Email errors: {errors}")
errors2 = registry.validate("username", "alice", ["required", "unique_username"],
{"existing_users": ["alice", "bob"]})
print(f"Username errors: {errors2}")
errors3 = registry.validate("age", 10, ["min_age"])
print(f"Age errors: {errors3}")
Expected output:
Email errors: ['email: invalid email format']
Username errors: ['username: username already taken']
Age errors: ['age: must be 13 or older']
Parameterized Validators
# parameterized_validators.py
from typing import Any, Callable, Dict, List, Optional
class ParamValidator:
def __init__(self):
self.rules: List[Dict] = []
def min_length(self, min_len: int):
def validate(value, ctx):
return value is None or len(str(value)) >= min_len
self.rules.append({
"name": f"min_length({min_len})",
"fn": validate,
"message": f"minimum {min_len} characters",
})
return self
def max_length(self, max_len: int):
def validate(value, ctx):
return value is None or len(str(value)) <= max_len
self.rules.append({
"name": f"max_length({max_len})",
"fn": validate,
"message": f"maximum {max_len} characters",
})
return self
def pattern(self, regex: str, message: str = None):
import re
compiled = re.compile(regex)
def validate(value, ctx):
return value is None or bool(compiled.match(str(value)))
self.rules.append({
"name": f"pattern({regex})",
"fn": validate,
"message": message or f"must match pattern {regex}",
})
return self
def validate(self, value: Any, context: Dict = None) -> List[str]:
errors = []
for rule in self.rules:
if not rule["fn"](value, context or {}):
errors.append(rule["message"])
return errors
password_validator = ParamValidator()
password_validator.min_length(8).max_length(64).pattern(r"[A-Z]", "must contain uppercase")
password_validator.pattern(r"[0-9]", "must contain digit")
print(f"Password 'short': {password_validator.validate('short')}")
print(f"Password 'lowercase1': {password_validator.validate('lowercase1')}")
print(f"Password 'Valid1Pass': {password_validator.validate('Valid1Pass')}")
Expected output:
Password 'short': ['minimum 8 characters', 'must contain uppercase', 'must contain digit']
Password 'lowercase1': ['must contain uppercase']
Password 'Valid1Pass': []
Async Custom Validators
# async_validators.py
from typing import Any, Callable, Dict, List, Optional
class AsyncValidator:
def __init__(self):
self.checks: List[Dict] = []
def add_async(self, name: str, check_fn: Callable, message: str):
self.checks.append({
"name": name,
"fn": check_fn,
"message": message,
})
async def validate(self, value: Any) -> List[str]:
errors = []
for check in self.checks:
try:
result = await check["fn"](value)
if not result:
errors.append(check["message"])
except Exception as e:
errors.append(f"{check['name']}: error - {str(e)}")
return errors
import asyncio
async def check_email_exists(email: str) -> bool:
await asyncio.sleep(0.01)
return "taken@example.com" not in email
async def check_username_available(username: str) -> bool:
await asyncio.sleep(0.01)
return username not in ["admin", "root", "system"]
async def main():
validator = AsyncValidator()
validator.add_async("email", check_email_exists, "email already registered")
validator.add_async("username", check_username_available, "username not available")
errors = await validator.validate("new@example.com")
print(f"New email: {errors}")
errors2 = await validator.validate("taken@example.com")
print(f"Taken email: {errors2}")
errors3 = await validator.validate("admin")
print(f"Admin username: {errors3}")
asyncio.run(main())
Expected output:
New email: []
Taken email: ['email already registered']
Admin username: ['username not available']
Common Mistakes
1. Too Many Validators
Every validation rule adds complexity. Only validate what matters for security and data integrity.
2. Side Effects in Validators
Validators should not modify data, send emails, or trigger side effects. They only check.
3. Not Returning All Errors
Stop-on-first-error forces clients to fix one error at a time. Collect and return all errors.
4. Hardcoding Domain Rules
Coupling validation to specific values makes reuse impossible. Parameterize validators.
5. No Context Passing
Validators often need context (existing users, current user ID). Pass context explicitly.
Practice Questions
1. What is a custom validator?
A function that implements a business-specific validation rule beyond type/format checking.
2. Why pass context to validators?
Validators may need access to external data like existing records, current user, or request state.
3. What is a parameterized validator?
A validator Factory that returns a configured validation function based on parameters.
4. How do async validators work?
They return a Promise or Coroutine for database lookups or external API checks.
Challenge
Build a registration validator with custom rules: username (unique, 3-20 chars, alphanumeric), email (unique, valid format), password (strength check, not in common-passwords list), age (13+).
FAQ
Mini Project: Validator Builder
# validator_builder.py
from typing import Any, Callable, Dict, List
class ValidatorBuilder:
def __init__(self):
self.checks: List[Callable] = []
def add(self, fn: Callable, message: str):
def check(value, ctx):
return None if fn(value, ctx) else message
self.checks.append(check)
return self
def run(self, value: Any, ctx: Dict = None) -> List[str]:
return [chk(value, ctx or {}) for chk in self.checks if not chk(value, ctx or {}) is None]
vb = ValidatorBuilder()
vb.add(lambda v, c: len(v) >= 3, "too short")
vb.add(lambda v, c: v not in c.get("blocked", []), "blocked")
print(vb.run("ab"))
print(vb.run("hello", {"blocked": ["hello"]}))
print(vb.run("alice"))
Expected output:
['too short']
['blocked']
[]
What's Next
You understand custom validators. Next, learn validation error handling, then async validation.
Built by the developers of DodaTech
Doda Browser, DodaZIP & Durga Antivirus Pro