Zod vs Joi vs Yup: Which Validation Library Should You Use?
On this page
- The same schema in each library
- Zod
- Joi
- Yup
- Side-by-side comparison
- Which one should you pick?
- Validating Express requests with Zod
- Common validation tasks in all three
- Confirm password must match
- A field required only in some cases
- Cleaning input while validating
- Error formats compared
- Using them with React Hook Form
- Bundle size and performance
- Sharing one schema between React and Express
- Migrating an existing project
- Frequently asked questions
- Oh hi there 👋It’s nice to meet you.
- Sign up to receive awesome content in your inbox.
Every API needs to check incoming data before it reaches the database. Zod, Joi and Yup all do this, and they look similar at first glance. The differences show up in TypeScript support, where each one runs best, and how errors come back. Here is the same schema in all three, then a clear recommendation.
The same schema in each library
We validate a sign-up body: an email, a password of at least 8 characters, an optional age of 18 or more, and a list of skills.
Zod
import { z } from 'zod';
const SignupSchema = z.object({
email: z.string().email(),
password: z.string().min(8),
age: z.number().int().min(18).optional(),
skills: z.array(z.string()).default([]),
});
type Signup = z.infer<typeof SignupSchema>; // TypeScript type for free
const result = SignupSchema.safeParse(req.body);
if (!result.success) console.log(result.error.issues);
Joi
const Joi = require('joi');
const signupSchema = Joi.object({
email: Joi.string().email().required(),
password: Joi.string().min(8).required(),
age: Joi.number().integer().min(18),
skills: Joi.array().items(Joi.string()).default([]),
});
const { error, value } = signupSchema.validate(req.body, { abortEarly: false });
if (error) console.log(error.details);
Yup
import { object, string, number, array } from 'yup';
const signupSchema = object({
email: string().email().required(),
password: string().min(8).required(),
age: number().integer().min(18),
skills: array().of(string()).default([]),
});
try {
const value = await signupSchema.validate(req.body, { abortEarly: false });
} catch (err) {
console.log(err.errors);
}
Notice one difference already: Zod fields are required unless you add .optional(), while Joi and Yup fields are optional unless you add .required().
Side-by-side comparison
| Zod | Joi | Yup | |
|---|---|---|---|
| TypeScript types from the schema | Yes, with z.infer |
No built-in inference | Yes, with InferType |
| Best place to run | Browser and server | Server | Browser, especially forms |
| Required by default | Yes | No | No |
| Sync validation | Yes (parse) |
Yes (validate) |
Yes (validateSync), async is the default |
| React form libraries | React Hook Form resolver | React Hook Form resolver | Formik and React Hook Form |
| Main strength | One schema for types and validation | Very rich rules and messages | Simple form validation |
Which one should you pick?
Choose Zod if you use TypeScript or share code between React and Express. One schema validates the request body on the server, validates the form in the browser, and gives you the type. You stop writing interfaces by hand and they never fall out of sync. Generate a starting schema from any JSON with the JSON to Zod tool.
Choose Joi for a JavaScript-only Node.js API, especially an existing one. It is mature, its rules cover nearly every case (conditional fields with when, references between fields, custom messages), and many Express codebases already use it.
Choose Yup if your React forms are built with Formik, or your team already knows it. For new projects, Zod covers the same ground and adds better type inference.
Validating Express requests with Zod
A small middleware keeps your routes clean:
const validate = (schema) => (req, res, next) => {
const result = schema.safeParse(req.body);
if (!result.success) {
return res.status(400).json({
message: 'Invalid request',
errors: result.error.issues.map((i) => ({ field: i.path.join('.'), message: i.message })),
});
}
req.body = result.data; // cleaned data, with defaults applied
next();
};
app.post('/api/signup', validate(SignupSchema), async (req, res) => {
const user = await User.create(req.body);
res.status(201).json({ id: user._id });
});
Validation in the browser is for user experience. Validation on the server is for safety, because anyone can call your API directly. Always do both, or at least the server side.
Common validation tasks in all three
Basic types look similar everywhere. The differences show in everyday tasks such as comparing two fields, transforming input and writing custom error messages.
Confirm password must match
// Zod: refine runs after the fields are valid; path decides which field shows the error
const Signup = z.object({
password: z.string().min(8),
confirm: z.string(),
}).refine((d) => d.password === d.confirm, { message: 'Passwords do not match', path: ['confirm'] });
// Joi: reference another field
const signup = Joi.object({
password: Joi.string().min(8).required(),
confirm: Joi.any().valid(Joi.ref('password')).required().messages({ 'any.only': 'Passwords do not match' }),
});
// Yup: oneOf with a reference
const signup = object({
password: string().min(8).required(),
confirm: string().oneOf([ref('password')], 'Passwords do not match').required(),
});
A field required only in some cases
Business accounts need a GST number; personal accounts don’t:
// Zod: a discriminated union describes each shape exactly
const Account = z.discriminatedUnion('type', [
z.object({ type: z.literal('personal'), name: z.string() }),
z.object({ type: z.literal('business'), name: z.string(), gstin: z.string().length(15) }),
]);
// Joi: when()
const account = Joi.object({
type: Joi.string().valid('personal', 'business').required(),
name: Joi.string().required(),
gstin: Joi.string().length(15).when('type', { is: 'business', then: Joi.required(), otherwise: Joi.forbidden() }),
});
// Yup: when()
const account = object({
type: string().oneOf(['personal', 'business']).required(),
name: string().required(),
gstin: string().when('type', { is: 'business', then: (s) => s.length(15).required() }),
});
The Zod version gives TypeScript a precise union type: inside an if (data.type === 'business') block, TypeScript knows gstin exists. That kind of type safety is the main reason TypeScript teams prefer Zod. You can check real GSTIN values with the Indian ID validator.
Cleaning input while validating
All three can transform values, such as trimming whitespace or converting strings to numbers from form fields:
// Zod
const Query = z.object({ page: z.coerce.number().int().min(1).default(1), q: z.string().trim().max(100).optional() });
// Joi converts by default when you call validate()
const query = Joi.object({ page: Joi.number().integer().min(1).default(1), q: Joi.string().trim().max(100) });
// Yup also casts by default
const query = object({ page: number().integer().min(1).default(1), q: string().trim().max(100) });
Query strings always arrive as text, so coercion is essential for values like ?page=2. With Zod you ask for it explicitly with z.coerce; Joi and Yup convert automatically, which is convenient but can hide surprising conversions.
Error formats compared
How errors come back affects how much code your frontend needs to show them next to the right field:
| Library | Where errors live | Field path |
|---|---|---|
| Zod | result.error.issues |
issue.path (an array, such as ['address', 'city']) |
| Joi | error.details (use abortEarly: false to get all) |
detail.path (an array) |
| Yup | err.inner (with abortEarly: false) |
e.path (a string, such as address.city) |
Whichever you choose, convert errors into one shape in a single helper, such as { field, message }, so every form in your React app can display them the same way.
Using them with React Hook Form
React Hook Form is the most popular form library for React, and it works with all three through @hookform/resolvers:
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
function SignupForm() {
const { register, handleSubmit, formState: { errors } } = useForm({ resolver: zodResolver(SignupSchema) });
return (
<form onSubmit={handleSubmit((data) => api.post('/signup', data))}>
<input {...register('email')} />
{errors.email && <p role="alert">{errors.email.message}</p>}
<input type="password" {...register('password')} />
{errors.password && <p role="alert">{errors.password.message}</p>}
<button>Sign up</button>
</form>
);
}
Swap zodResolver for joiResolver or yupResolver and the rest of the form stays the same. With Zod, you can import the very same SignupSchema that your Express route uses, from a shared folder, so the browser and the server always agree on the rules.
Bundle size and performance
For a backend, library size hardly matters. For a frontend, every kilobyte counts on slow mobile connections. Joi was designed for servers and is the heaviest to ship to a browser. Yup is smaller. Zod sits in between, and newer Zod versions offer a lighter “mini” build for size-sensitive apps. If bundle size is critical, measure your own build with a tool such as vite-bundle-visualizer rather than trusting general numbers, because results depend on which features you import.
Sharing one schema between React and Express
The strongest argument for Zod in a MERN project is that one schema can serve both sides. Put shared schemas in their own folder or workspace package, for example shared/schemas/signup.js, and import it from the React form and the Express route. When a rule changes, such as a longer minimum password, you change it in one place, and the browser and the server can never disagree again.
This setup needs a little structure. With npm workspaces, create a shared package that both client and server depend on. Keep shared schemas free of browser-only or server-only code, so they import cleanly in both places. And remember that the server must still validate everything: browser validation exists to give users quick feedback, while server validation keeps your data safe, because anyone can call your API without using your form.
Migrating an existing project
You don’t have to switch libraries in one big change. Add the new library alongside the old one, use it for every new route and form, and convert old schemas when you next touch that code. The libraries don’t conflict with each other. Prioritize the routes that accept the most user input, such as sign-up, checkout and profile updates, because they benefit most from stricter, typed validation.
Frequently asked questions
Do I still need Mongoose validation if I use Zod?
Keep both. Zod checks the request before your code runs; Mongoose rules such as required and unique indexes protect the database from every code path, including scripts and admin tools.
Is Zod slower than Joi?
For typical API requests, all three are fast enough that validation is not your bottleneck. Database calls take far longer.
Can I convert a Joi schema to Zod automatically?
Not reliably. Rewrite the schemas one route at a time; the syntax is close enough that it goes quickly.
Can Zod validate environment variables?
Yes, and it is a great use for it. Parse process.env with a Zod schema at startup, and the app stops immediately with a clear message if a variable is missing or has the wrong format.




