Skip to content

Custom Validators — Complete Guide

DodaTech Updated 2026-06-28 5 min read

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

Can I use database queries in validators?

Yes, but keep them in async validators and batch them to avoid N+1 queries.

Should I validate on every save?

Yes. Always validate before saving. Database constraints are a last resort.

How do I test custom validators?

Unit test each validator independently with valid and invalid inputs.

Can validators modify the input?

No. Validators should be pure functions. Sanitizers modify input, validators only check.

What is a validator chain?

A sequence of validators. All validators run (collecting errors) or stop on first failure.

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