Validation Libraries Compared — Joi vs Yup vs Zod vs Pydantic
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: <script>alert("xss")</script>
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
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