Zod & Pydantic in Action: A Cross-Ecosystem Guide to Validating Data

As developers, we’ve all been burned by runtime data that doesn’t match our assumptions. An API returns null
where you expected a string, user input breaks your carefully typed functions, or environment variables cause production crashes. Zod (TypeScript) and Pydantic (Python) solve this problem by bridging the gap between static types and runtime validation.
Zod and Pydantic are powerful libraries that bring runtime guarantees to statically typed code. They let you trust your data—even when it comes from unpredictable sources like user input, external APIs, or environment variables. By validating data at runtime while preserving strong types, they help you write safer, cleaner, and more maintainable applications.
The Core Problem They Solve
TypeScript and Python type hints only exist at development time. At runtime, your data comes from untrusted sources—APIs, user forms, configuration files—that don’t respect your type definitions.
The TypeScript Problem:
interface User {
name: string;
email: string;
age: number;
}
// This compiles fine but might explode at runtime
const user: User = await fetch('/api/user').then(r => r.json());
console.log(user.name.toUpperCase()); // TypeError if name is null
The Python Problem:
def process_user(name: str, email: str, age: int):
return f"User {name.upper()} is {age} years old"
# Type hints don't prevent this from breaking
user_data = {"name": None, "email": "test", "age": "twenty"}
process_user(**user_data) # AttributeError: 'NoneType' has no attribute 'upper'
Both Zod and Pydantic provide runtime validation that works seamlessly with your type system, catching these issues before they cause problems.
Getting Started
Installing Zod:
npm install zod
# or
yarn add zod
# or
pnpm add zod
Installing Pydantic:
pip install pydantic
# For email validation support
pip install pydantic[email]
# For settings management (used in config examples)
pip install pydantic-settings
That’s it! Both libraries have zero required dependencies and work out of the box.
Basic Data Validation
Let’s start with the most common scenario - validating user data from an API or form.
Zod (TypeScript):
import { z } from 'zod';
// Define schema - this becomes your source of truth
const UserSchema = z.object({
name: z.string().min(1, "Name is required"),
email: z.string().email("Invalid email format"),
age: z.number().int().min(18, "Must be 18 or older")
});
// Automatically infer TypeScript type
type User = z.infer<typeof UserSchema>;
// Parse unknown data - throws on invalid data
const user = UserSchema.parse(apiResponse);
// Safe parsing - returns result object
const result = UserSchema.safeParse(apiResponse);
if (result.success) {
console.log("Valid user:", result.data);
} else {
console.log("Validation errors:", result.error.errors);
}
Pydantic (Python):
from pydantic import BaseModel, EmailStr, Field, ValidationError
from typing import Optional
class User(BaseModel):
name: str = Field(..., min_length=1, description="User's full name")
email: EmailStr
age: int = Field(..., ge=18, le=120, description="Age in years")
is_active: bool = True
# Create and validate user
user = User(
name="Alice Johnson",
email="alice@example.com",
age=30
)
# Automatic validation on creation
try:
invalid_user = User(name="", email="invalid", age=15)
except ValidationError as e:
print("Validation errors:", e.errors())
What this shows: Zod automatically creates TypeScript types from your validation schema, so you define the structure once. Parsing either succeeds with fully typed data or fails with detailed error information.
Environment Configuration
A common real-world need is validating environment variables and configuration:
Zod (TypeScript):
const ConfigSchema = z.object({
NODE_ENV: z.enum(['development', 'production', 'test']).default('development'),
PORT: z.coerce.number().int().positive().default(3000),
DATABASE_URL: z.string().url(),
DEBUG: z.coerce.boolean().default(false)
});
type Config = z.infer<typeof ConfigSchema>;
// Parse and validate environment variables
export const config: Config = ConfigSchema.parse(process.env);
Pydantic (Python):
from pydantic_settings import BaseSettings
from typing import Literal
class Settings(BaseSettings):
app_name: str = "My Application"
environment: Literal['development', 'production', 'test'] = 'development'
port: int = 3000
database_url: str
debug: bool = False
model_config = {
"env_file": ".env" # Automatically load from .env file
}
# Automatically reads from environment variables
settings = Settings()
What this shows: Zod can coerce types (string “3000” becomes number 3000) and validate formats like URLs. Pydantic’s BaseSettings
automatically loads from environment variables and .env files.
Let’s see how both libraries handle a common scenario - validating API request data:
Zod (TypeScript):
const CreatePostSchema = z.object({
title: z.string().min(1).max(200),
content: z.string().min(10),
tags: z.array(z.string()).max(5),
publishAt: z.string().datetime().optional()
});
// In your API handler
app.post('/posts', (req, res) => {
try {
const post = CreatePostSchema.parse(req.body);
// post is now fully typed and validated
const created = await createPost(post);
res.json(created);
} catch (error) {
res.status(400).json({
error: error.errors
});
}
});
Pydantic (Python):
from pydantic import BaseModel, Field
from datetime import datetime
from typing import Optional, List
class CreatePost(BaseModel):
title: str = Field(..., min_length=1, max_length=200)
content: str = Field(..., min_length=10)
tags: List[str] = Field(..., max_items=5)
publish_at: Optional[datetime] = None
# In your FastAPI handler
@app.post("/posts")
async def create_post(post: CreatePost):
# post is automatically validated by FastAPI
created = await create_post_service(post)
return created
What this shows: Zod requires explicit parsing in your route handlers, giving you control over when validation happens. Pydantic with FastAPI handles validation automatically at the framework level.
Data Transformation: Cleaning User Input
Both libraries can transform data during validation:
Zod (TypeScript):
const UserInputSchema = z.object({
name: z.string()
.trim()
.transform(s => s.toLowerCase()),
email: z.string()
.email()
.transform(s => s.toLowerCase()),
age: z.coerce.number().int().positive()
});
// " JOHN DOE " becomes "john doe"
const cleaned = UserInputSchema.parse({
name: " JOHN DOE ",
email: "JOHN@EXAMPLE.COM",
age: "25"
});
Pydantic (Python):
from pydantic import BaseModel, field_validator, EmailStr
class UserInput(BaseModel):
name: str
email: EmailStr
age: int
@field_validator('name', mode='before')
@classmethod
def clean_name(cls, v):
return v.strip().lower()
@field_validator('email', mode='before')
@classmethod
def clean_email(cls, v):
return v.strip().lower()
@field_validator('age', mode='before')
@classmethod
def coerce_age(cls, v):
return int(v) if isinstance(v, str) else v
# Automatic cleaning and coercion
cleaned = UserInput(
name=" JOHN DOE ",
email="JOHN@EXAMPLE.COM",
age="25"
)
What this shows: Zod uses chained transformations applied in order, while Pydantic uses field validators that run before or after validation. Both can clean and normalize data during the validation process.
Advanced Feature: Conditional Validation
Sometimes you need validation rules that depend on other fields:
Zod (TypeScript):
const UserRegistrationSchema = z.object({
type: z.enum(['personal', 'business']),
name: z.string().min(1),
email: z.string().email(),
companyName: z.string().optional()
}).refine(data => {
// Business users must provide company name
if (data.type === 'business') {
return data.companyName && data.companyName.length > 0;
}
return true;
}, {
message: "Company name required for business accounts",
path: ["companyName"]
});
Pydantic (Python):
from pydantic import BaseModel, model_validator, EmailStr
from typing import Optional, Literal
class UserRegistration(BaseModel):
type: Literal['personal', 'business']
name: str
email: EmailStr
company_name: Optional[str] = None
@model_validator(mode='after')
def validate_business_fields(self):
if self.type == 'business' and not self.company_name:
raise ValueError('Company name required for business accounts')
return self
What this shows: Both libraries support validation rules that depend on multiple fields. Zod uses the refine()
method with custom logic, while Pydantic uses @model_validator
to validate the entire object.
Error Handling: User-Friendly Messages
Both libraries provide structured error information for better user experience:
Zod (TypeScript):
const result = UserSchema.safeParse(invalidData);
if (!result.success) {
result.error.errors.forEach(err => {
console.log(`${err.path.join('.')}: ${err.message}`);
});
// Example output:
// "email: Invalid email format"
// "age: Must be 18 or older"
}
Pydantic (Python):
from pydantic import ValidationError
try:
user = User(**invalid_data)
except ValidationError as e:
for error in e.errors():
field = '.'.join(str(x) for x in error['loc'])
message = error['msg']
print(f"{field}: {message}")
# Example output:
# "email: field required"
# "age: ensure this value is greater than or equal to 18"
What this shows: Both libraries provide detailed error information including field names, error types, and custom messages. This makes it easy to provide helpful feedback to users or debug validation issues.
Key Takeaways
Both Zod and Pydantic solve the same problem: ensuring runtime data matches your type expectations.
Zod focuses on TypeScript integration and developer experience, working well in React/Next.js apps and full-stack TypeScript projects where end-to-end type safety matters. Pydantic focuses on performance and flexibility, working well in FastAPI applications, data processing pipelines, and ML/AI systems where speed is important.
The choice comes down to your language ecosystem. Zod provides clean TypeScript integration, while Pydantic offers fast Python validation. Both libraries are proven at scale and will help catch bugs earlier in your applications.