Skip to content

Validation Libraries Compared — Joi vs Yup vs Zod vs Pydantic

DodaTech Updated 2026-06-28 10 min read

In this tutorial, you'll learn about Validation Libraries. We cover key concepts, practical examples, and best practices to help you understand and apply this topic effectively.

Validation libraries provide declarative schemas for defining and enforcing data rules, saving developers from writing repetitive validation logic for every endpoint.

What You'll Learn

By the end of this lesson, you will compare seven validation libraries across features, syntax, performance, and ecosystem fit, and choose the right one for your stack.

Why It Matters

Choosing the wrong validation library leads to verbose code, poor TypeScript integration, slow validation, or missing features. Each library has strengths that match specific project needs.

Real-World Use

Doda Browser's API gateway uses Zod for TypeScript type inference, while Durga Antivirus Pro's backend uses Pydantic for its Python-based microservices — each chosen for ecosystem fit.

Library Selection Flow

flowchart TD
    Start[Choose Validation Library] --> Lang{Language?}
    Lang -->|Python| Pydantic[Pydantic]
    Lang -->|JS/TS| JSType{TypeScript?}
    JSType -->|Yes| Zod[Zod]
    JSType -->|No| Joi[Joi]
    JSType -->|Lightweight| Yup[Yup]
    JSType -->|Express| ExpressVal[express-validator]
    Start -->|Python Simple| Cerberus[Cerberus]
    Start -->|Node String| ValidatorJS[validator.js]
    Pydantic --> Done
    Zod --> Done
    Joi --> Done

Pydantic (Python)

# pydantic_example.py
from pydantic import BaseModel, Field, field_validator, EmailStr, ValidationError
from typing import List, Optional
from datetime import datetime

class AddressModel(BaseModel):
    street: str = Field(min_length=1, max_length=200)
    city: str = Field(min_length=1, max_length=100)
    zip_code: str = Field(pattern=r'^\d{5}(-\d{4})?$')
    country: str = Field(default="US", min_length=2, max_length=2)

class UserModel(BaseModel):
    username: str = Field(min_length=3, max_length=30, pattern=r'^[a-zA-Z0-9_]+$')
    email: EmailStr
    age: int = Field(ge=13, le=150)
    addresses: List[AddressModel] = Field(min_length=1)
    created_at: datetime = Field(default_factory=datetime.now)
    score: float = Field(ge=0.0, le=100.0)

    @field_validator('username')
    @classmethod
    def username_no_sql(cls, v: str) -> str:
        forbidden = ["'", '"', ';', '--', '/*', '*/']
        for char in forbidden:
            if char in v:
                raise ValueError(f"Username contains forbidden character: {char}")
        return v

data = {
    "username": "alice_dev",
    "email": "alice@example.com",
    "age": 28,
    "addresses": [{"street": "123 Main St", "city": "Portland", "zip_code": "97201"}],
    "score": 87.5
}

try:
    user = UserModel(**data)
    print(f"Valid: {user.username}, {user.email}, addresses={len(user.addresses)}")
except ValidationError as e:
    print(f"Error: {e}")

bad_data = {**data, "email": "not-an-email", "age": 12}
try:
    UserModel(**bad_data)
except ValidationError as e:
    print(f"Errors: {len(e.errors())}")
    for err in e.errors():
        print(f"  {'.'.join(str(l) for l in err['loc'])}: {err['msg']}")

Expected output:

Valid: alice_dev, alice@example.com, addresses=1
Errors: 2
  email: value is not a valid email address
  age: Input should be greater than or equal to 13

Zod (TypeScript)

// zod-example.ts
import { z } from 'zod';

const ProductSchema = z.object({
  id: z.string().uuid(),
  name: z.string().min(1).max(200),
  price: z.number().positive().multipleOf(0.01),
  category: z.enum(['electronics', 'clothing', 'food', 'books']),
  tags: z.array(z.string().max(20)).max(10).optional(),
  metadata: z.record(z.string(), z.unknown()).optional(),
  in_stock: z.boolean(),
  variants: z.array(z.object({
    sku: z.string().regex(/^[A-Z]{2,4}-\d{4,8}$/),
    quantity: z.number().int().nonnegative()
  })).optional(),
}).strict();

const validProduct = {
  id: '550e8400-e29b-41d4-a716-446655440000',
  name: 'Wireless Headphones',
  price: 79.99,
  category: 'electronics',
  tags: ['audio', 'wireless'],
  in_stock: true,
  variants: [{ sku: 'WH-1000', quantity: 50 }]
};

const validResult = ProductSchema.safeParse(validProduct);
console.log('Valid product:', validResult.success);

const invalidProduct = { ...validProduct, price: -5, category: 'unknown' };
const invalidResult = ProductSchema.safeParse(invalidProduct);
console.log('Invalid product:', invalidResult.success);
invalidResult.error?.issues.forEach(i => console.log(`  ${i.path.join('.')}: ${i.message}`));

// Type inference
type Product = z.infer<typeof ProductSchema>;
const inferredType: Product = validProduct;
console.log('Type inference works:', inferredType.name);

// Partial for updates
const PartialProduct = ProductSchema.partial();
const update = PartialProduct.safeParse({ price: 49.99 });
console.log('Partial update:', update.success);

Expected output:

Valid product: true
Invalid product: false
  price: Number must be positive
  category: Invalid enum value. Expected 'electronics' | 'clothing' | 'food' | 'books', received 'unknown'
Type inference works: Wireless Headphones
Partial update: true

Joi (JavaScript)

// joi-example.js
const Joi = require('joi');

const configSchema = Joi.object({
  NODE_ENV: Joi.string().valid('development', 'production', 'test').required(),
  PORT: Joi.number().port().default(3000),
  DATABASE_URL: Joi.string().uri().required(),
  REDIS_URL: Joi.string().uri().optional(),
  JWT_SECRET: Joi.string().min(32).required(),
  JWT_EXPIRES_IN: Joi.string().pattern(/^\d+[smhd]$/).default('1h'),
  CORS_ORIGINS: Joi.array().items(Joi.string().uri()).min(1).required(),
  RATE_LIMIT_WINDOW: Joi.number().integer().min(1000).default(60000),
  RATE_LIMIT_MAX: Joi.number().integer().min(1).max(1000).default(100),
  LOG_LEVEL: Joi.string().valid('error', 'warn', 'info', 'debug').default('info'),
  FEATURE_FLAGS: Joi.object().pattern(Joi.string(), Joi.boolean()).default({}),
}).with('JWT_SECRET', 'JWT_EXPIRES_IN');

const validConfig = {
  NODE_ENV: 'production',
  PORT: 8080,
  DATABASE_URL: 'postgres://user:pass@localhost:5432/db',
  JWT_SECRET: 'a'.repeat(32),
  CORS_ORIGINS: ['https://app.example.com'],
};

const { error, value } = configSchema.validate(validConfig, { abortEarly: false });
console.log('Valid config:', !error);

const badConfig = { NODE_ENV: 'invalid' };
const badResult = configSchema.validate(badConfig, { abortEarly: false });
console.log('Invalid config:', badResult.error?.details.length, 'errors');
badResult.error?.details.forEach(d => console.log(`  ${d.path.join('.')}: ${d.message}`));

Expected output:

Valid config: true
Invalid config: 4 errors
  NODE_ENV: "NODE_ENV" must be one of [development, production, test]
  DATABASE_URL: "DATABASE_URL" is required
  JWT_SECRET: "JWT_SECRET" is required
  CORS_ORIGINS: "CORS_ORIGINS" is required

Validator.js and express-validator

// validatorjs-express.js
const validator = require('validator');
const { body, validationResult, query } = require('express-validator');

// Standalone validator.js usage
const testValues = [
  validator.isEmail('user@example.com'),
  validator.isEmail('not-an-email'),
  validator.isMobilePhone('+14155550100', 'en-US'),
  validator.isURL('https://example.com'),
  validator.isUUID('550e8400-e29b-41d4-a716-446655440000'),
  validator.isStrongPassword('Weak1'),
  validator.isStrongPassword('Str0ng!Pass#'),
  validator.escape('<script>alert("xss")</script>'),
  validator.trim('  hello world  '),
  validator.normalizeEmail('USER@EXAMPLE.COM'),
];

testValues.forEach((result, i) => {
  const labels = [
    'email valid', 'email invalid', 'phone US', 'url',
    'uuid', 'password weak', 'password strong',
    'escape HTML', 'trim', 'normalize email'
  ];
  console.log(`  ${labels[i]}: ${result}`);
});

// express-validator middleware pattern (conceptual)
const createUserValidation = [
  body('email').isEmail().normalizeEmail(),
  body('password').isLength({ min: 8 }).isStrongPassword(),
  body('age').optional().isInt({ min: 13, max: 150 }),
  body('referral_code').optional().isAlphanumeric().trim(),
  query('source').optional().isIn(['web', 'mobile', 'api']),
];

console.log('\n  validation middleware: 5 rules registered');

// Simulating validation
const mockErrors = [
  { path: 'email', msg: 'Invalid email' },
  { path: 'password', msg: 'Must be at least 8 characters' },
];
console.log(`  Error count: ${mockErrors.length}`);
console.log(`  Fields: ${mockErrors.map(e => e.path).join(', ')}`);

Expected output:

  email valid: true
  email invalid: false
  phone US: true
  url: true
  uuid: true
  password weak: false
  password strong: true
  escape HTML: &lt;script&gt;alert(&quot;xss&quot;)&lt;/script&gt;
  trim: hello world
  normalize email: user@example.com

  validation middleware: 5 rules registered
  Error count: 2
  Fields: email, password

Cerberus (Python)

# cerberus_example.py
from cerberus import Validator

schema = {
    'name': {
        'type': 'string',
        'minlength': 1,
        'maxlength': 100,
        'required': True,
        'empty': False
    },
    'email': {
        'type': 'string',
        'required': True,
        'regex': r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$',
        'empty': False
    },
    'age': {
        'type': 'integer',
        'min': 13,
        'max': 150,
        'nullable': True
    },
    'role': {
        'type': 'string',
        'allowed': ['admin', 'user', 'moderator'],
        'required': True
    },
    'tags': {
        'type': 'list',
        'schema': {'type': 'string', 'maxlength': 20},
        'maxlength': 10
    },
    'metadata': {
        'type': 'dict',
        'allow_unknown': False,
        'schema': {
            'source': {'type': 'string', 'allowed': ['web', 'api']},
            'version': {'type': 'integer', 'min': 1}
        }
    },
    'password': {
        'type': 'string',
        'minlength': 8,
        'maxlength': 128,
        'required': True,
        'check_with': 'strong_password'
    }
}

def check_strong_password(field, value, error):
    if not any(c.isupper() for c in value):
        error(field, "Must contain uppercase letter")
    if not any(c.islower() for c in value):
        error(field, "Must contain lowercase letter")
    if not any(c.isdigit() for c in value):
        error(field, "Must contain digit")

v = Validator(schema)
v.check_with['strong_password'] = check_strong_password

doc = {
    'name': 'Alice',
    'email': 'alice@example.com',
    'age': 28,
    'role': 'user',
    'tags': ['python', 'api'],
    'password': 'Str0ng!Pass'
}

valid = v.validate(doc)
print(f"Valid: {valid}")
if not valid:
    print(f"Errors: {v.errors}")

bad_doc = {
    'name': '',
    'email': 'invalid',
    'role': 'superadmin',
    'password': 'weak',
    'metadata': {'extra': True}
}

v.validate(bad_doc)
print(f"\nInvalid doc errors:")
for field, errors in v.errors.items():
    print(f"  {field}: {errors}")

Expected output:

Valid: True

Invalid doc errors:
  name: min length is 1, empty values not allowed
  email: value does not match regex
  role: unallowed value superadmin
  password: min length is 8
  metadata: unknown field extra

Library Feature Comparison

# lib_comparison.py
from typing import Dict, List, Any

class LibraryComparison:
    libraries = {
        "Pydantic": {
            "language": "Python",
            "type_hints": "Native Pydantic v2",
            "async": "Yes",
            "custom_validators": "@field_validator",
            "json_schema": "FastAPI integration",
            "performance": "Fast (Rust core)",
            "ecosystem": "FastAPI, SQLModel",
        },
        "Zod": {
            "language": "TypeScript",
            "type_hints": "z.infer<> type inference",
            "async": "refine with async",
            "custom_validators": ".refine(), .superRefine()",
            "json_schema": "zod-to-json-schema",
            "performance": "Very fast",
            "ecosystem": "tRPC, Next.js, React",
        },
        "Joi": {
            "language": "JavaScript",
            "type_hints": "Third-party (@types/joi)",
            "async": "No native async",
            "custom_validators": ".custom()",
            "json_schema": "joi-to-json-schema",
            "performance": "Fast",
            "ecosystem": "Hapi.js, Express",
        },
        "Yup": {
            "language": "JavaScript",
            "type_hints": "InferType",
            "async": "Yes (.test async)",
            "custom_validators": ".test()",
            "json_schema": "yup-to-json-schema",
            "performance": "Moderate",
            "ecosystem": "Formik, React",
        },
        "express-validator": {
            "language": "JavaScript",
            "type_hints": "No built-in",
            "async": "Yes (custom)",
            "custom_validators": ".custom()",
            "json_schema": "No",
            "performance": "Fast",
            "ecosystem": "Express.js",
        },
        "Cerberus": {
            "language": "Python",
            "type_hints": "No",
            "async": "No",
            "custom_validators": "check_with",
            "json_schema": "Partial",
            "performance": "Moderate",
            "ecosystem": "Standalone",
        },
    }

    @staticmethod
    def display():
        features = ["language", "type_hints", "async", "custom_validators", "json_schema", "performance", "ecosystem"]
        for name, props in LibraryComparison.libraries.items():
            print(f"\n{name}:")
            for feat in features:
                print(f"  {feat}: {props[feat]}")

LibraryComparison.display()

print("\n--- Decision Guide ---")
print("Use Pydantic: Python + FastAPI")
print("Use Zod: TypeScript + tRPC/Next.js")
print("Use Joi: JavaScript + Hapi/Express")
print("Use Yup: React Formik forms")
print("Use express-validator: Existing Express app")
print("Use Cerberus: Simple Python validation without Pydantic")

Expected output:

Pydantic:
  language: Python
  type_hints: Native Pydantic v2
  async: Yes
  custom_validators: @field_validator
  json_schema: FastAPI integration
  performance: Fast (Rust core)
  ecosystem: FastAPI, SQLModel

Zod:
  language: TypeScript
  type_hints: z.infer<> type inference
  async: refine with async
  custom_validators: .refine(), .superRefine()
  json_schema: zod-to-json-schema
  performance: Very fast
  ecosystem: tRPC, Next.js, React

Joi:
  language: JavaScript
  type_hints: Third-party (@types/joi)
  async: No native async
  custom_validators: .custom()
  json_schema: joi-to-json-schema
  performance: Fast
  ecosystem: Hapi.js, Express

Yup:
  language: JavaScript
  type_hints: InferType
  async: Yes (.test async)
  custom_validators: .test()
  json_schema: yup-to-json-schema
  performance: Moderate
  ecosystem: Formik, React

express-validator:
  language: JavaScript
  type_hints: No built-in
  async: Yes (custom)
  custom_validators: .custom()
  json_schema: No
  performance: Fast
  ecosystem: Express.js

Cerberus:
  language: Python
  type_hints: No
  async: No
  custom_validators: check_with
  json_schema: Partial
  performance: Moderate
  ecosystem: Standalone

--- Decision Guide ---
Use Pydantic: Python + FastAPI
Use Zod: TypeScript + tRPC/Next.js
Use Joi: JavaScript + Hapi/Express
Use Yup: React Formik forms
Use express-validator: Existing Express app
Use Cerberus: Simple Python validation without Pydantic

Common Mistakes

1. Not Using Type Inference with Zod

Writing separate TypeScript interfaces alongside Zod schemas duplicates work. Use z.infer<> to derive types.

2. Mixing Multiple Libraries

Using Joi in one service and Zod in another within the same project creates cognitive overhead. Standardize on one.

3. Ignoring Async Validation

Sync-only validators block the event loop during DB lookups. Use libraries with async support for database-dependent rules.

4. Not Setting abortEarly: False in Joi

Joi stops on the first error by default. Always set abortEarly: false to collect all errors.

5. Overusing Cerberus for Complex Schemas

Cerberus lacks async, type hints, and JSON schema export. Choose Pydantic for Python projects that need these features.

Practice Questions

1. Which library provides type inference for TypeScript?

Zod with z.infer<>. Yup also supports InferType but Zod's integration is deeper.

2. What is the best Python validation library?

Pydantic v2. It has Rust-based performance, FastAPI integration, and native type hints.

3. When should you use express-validator?

When you already have an Express.js app and want middleware-based validation without adding a schema library.

4. What does abortEarly: false do in Joi?

It tells Joi to continue validation after the first error and return all errors instead of stopping early.

Challenge

Build a validation layer that uses Zod for TypeScript inference, integrates with express-validator for HTTP middleware, and exports JSON schemas for OpenAPI documentation. Support async custom validators for database uniqueness checks.

FAQ

Which validation library should I use for TypeScript?

Zod. It provides deep TypeScript type inference with z.infer<>, async support, and a rich ecosystem including tRPC and Next.js.

Is Joi still relevant in 2026?

Yes, for JavaScript (non-TypeScript) projects and legacy Express/Hapi apps. For new TypeScript projects, Zod is preferred.

What is the fastest validation library?

Zod and Pydantic v2 are the fastest in their ecosystems. Zod's performance comes from its minimalist design, Pydantic from its Rust core.

Can I use multiple validation libraries together?

Avoid it. Choose one library per project. Mixing Yup for forms and Joi for APIs creates inconsistencies.

Does express-validator generate JSON schema?

No. For OpenAPI documentation, pair express-validator with a separate JSON schema tool or switch to Zod.

Mini Project: Multi-Library Validation Adapter

# validation_adapter.py
from typing import Any, Dict, List, Optional, Callable

class ValidationAdapter:
    def __init__(self):
        self.rules: Dict[str, Callable] = {}

    def add_rule(self, name: str, validator: Callable[[Any], Optional[str]]):
        self.rules[name] = validator

    def validate(self, data: Dict, schema: Dict[str, List[str]]) -> Dict[str, List[str]]:
        errors = {}
        for field, rule_names in schema.items():
            value = data.get(field)
            for rule_name in rule_names:
                validator = self.rules.get(rule_name)
                if validator:
                    error = validator(value)
                    if error:
                        errors.setdefault(field, []).append(error)
                else:
                    errors.setdefault(field, []).append(f"Unknown rule: {rule_name}")
        return errors

adapter = ValidationAdapter()
adapter.add_rule("required", lambda v: None if v is not None and v != "" else "Required")
adapter.add_rule("email", lambda v: None if "@" in str(v) else "Invalid email")
adapter.add_rule("min_8", lambda v: None if len(str(v)) >= 8 else "Minimum 8 characters")

schema = {
    "email": ["required", "email"],
    "password": ["required", "min_8"],
}

print(adapter.validate({"email": "", "password": "short"}, schema))
print(adapter.validate({"email": "user@example.com", "password": "longenough"}, schema))

Expected output:

{'email': ['Required', 'Invalid email'], 'password': ['Required', 'Minimum 8 characters']}
{}

What's Next

You understand validation libraries. Next, learn error messages and localization, then validation security.

Built by the developers of DodaTech

Doda Browser, DodaZIP & Durga Antivirus Pro