Zod vs Yup in 2026: Which Validation Library Should You Use?
You've got a signup form. Email, password, maybe a checkbox for terms and conditions. Simple, right? Then someone submits email: 123 and your API route crashes with a cryptic error three files deep, because nothing ever checked that the data coming in actually matched what your code expected.
That's the exact gap schema validation libraries fill — and if you've searched for one, you've almost certainly landed on two names: Zod and Yup. Both let you describe the shape of your data once and validate real values against it. Both work great with React Hook Form. Both have been around long enough to be genuinely production-tested.
But they're not interchangeable, and picking the wrong one for your setup means either fighting TypeScript for months or hand-writing types you shouldn't have to. By the end of this guide, you'll know exactly how Zod and Yup differ, see real code for both, and know which one actually fits your project — not just which one has more GitHub stars.
Quick Answer
- Choose Zod if you're using TypeScript and want your schema to be your type — no writing an interface separately and hoping it stays in sync.
- Choose Yup if you're maintaining an existing Formik-based codebase, or your team is more comfortable with its
.shape()API and mature async validation methods.
For any brand-new TypeScript project in 2026, Zod is the safer long-term default — it's what most modern tools (React Hook Form, tRPC, Next.js Server Action patterns) assume you're using. Now let's get into why.
What Is Schema Validation, Exactly?
Before comparing the two, a quick beginner-friendly definition: a schema is just a description of what your data should look like — "this field is a string," "this field is a number between 1 and 100," "this field is a valid email." A validation library takes that schema and checks real, incoming data (a form submission, an API request body) against it, telling you exactly what's wrong if it doesn't match.
Without one, you end up writing manual checks like this everywhere:
if (typeof data.email !== "string" || !data.email.includes("@")) {
throw new Error("Invalid email");
}
Multiply that across every field, every form, every API route, and it gets messy fast — inconsistent error messages, duplicated logic, and no single source of truth for what "valid" even means. Zod and Yup both solve this by letting you define the rules once and reuse them everywhere.
Zod: TypeScript-First Validation
Zod was built specifically for TypeScript projects. Its core idea: you write one schema, and Zod derives both the runtime validator and the TypeScript type from it — there's no second place to keep in sync.
Installing Zod
npm install zod
A Basic Zod Schema
// lib/validators/user-schema.ts
import { z } from "zod";
export const userSchema = z.object({
name: z.string().min(2, "Name must be at least 2 characters"),
email: z.email("Please enter a valid email address"),
age: z.number().int().positive().optional(),
website: z.url().optional(),
});
// The type is derived directly from the schema — never written twice
export type User = z.infer<typeof userSchema>;
What's happening here: z.object({...}) describes the shape. z.email() and z.url() are Zod's built-in format validators (as of Zod v4, these live as top-level helpers rather than chained .email() string methods, though the chained form still works). z.infer<typeof userSchema> pulls a real TypeScript type straight out of the schema — if you add a field to the schema, the User type updates automatically, with zero manual work.
💡 Tip: This is Zod's single biggest selling point. You will never end up with a TypeScript interface that's quietly out of sync with your actual validation rules, because there's only one definition.
Validating Data with Zod
// Somewhere handling a form submission or API request
const result = userSchema.safeParse({
name: "Ali",
email: "not-an-email",
});
if (!result.success) {
console.log(result.error.flatten().fieldErrors);
// { email: ["Please enter a valid email address"] }
} else {
// result.data is fully typed as `User`
console.log(result.data.name);
}
What's happening here: safeParse() never throws — it returns a { success: true, data } or { success: false, error } object, so you don't need a try/catch block for normal validation flow. error.flatten().fieldErrors gives you a clean, per-field map of error messages, perfect for showing next to form inputs.
⚠️ Common Mistake: Using
.parse()instead of.safeParse()in a code path where invalid data is expected (like a form submission)..parse()throws on failure, which means you need atry/catcheverywhere —.safeParse()is almost always the better default for user input.
Zod in a Next.js Server Action
// actions/create-user.ts
"use server";
import { userSchema } from "@/lib/validators/user-schema";
export async function createUser(prevState: unknown, formData: FormData) {
const result = userSchema.safeParse({
name: formData.get("name"),
email: formData.get("email"),
});
if (!result.success) {
return { errors: result.error.flatten().fieldErrors };
}
// result.data is typed and safe to use here
// await db.user.create({ data: result.data });
return { success: true };
}
This is the exact pattern you'll see across most modern Next.js codebases — validate raw FormData on the server, never trust the client, and let the schema do the type narrowing for you.
Yup: The Established Forms Favorite
Yup predates the current TypeScript-first wave of tooling. It grew up alongside Formik and is still deeply embedded in a lot of React form codebases, particularly ones started before 2022.
Installing Yup
npm install yup
A Basic Yup Schema
// lib/validators/user-schema.ts
import * as yup from "yup";
export const userSchema = yup.object({
name: yup.string().min(2, "Name must be at least 2 characters").required(),
email: yup.string().email("Please enter a valid email address").required(),
age: yup.number().integer().positive(),
website: yup.string().url(),
});
// Yup's type inference — functional, but less precise than Zod's
export type User = yup.InferType<typeof userSchema>;
What's happening here: the shape looks similar to Zod at a glance, but notice .required() — in Yup, fields are optional by default unless you explicitly mark them required, which is the opposite of Zod's default (Zod fields are required unless you call .optional()). This trips up a lot of developers switching between the two.
⚠️ Common Mistake: Forgetting
.required()on a Yup field and assuming it behaves like Zod. Yup schemas silently allowundefinedon any field you don't explicitly mark required — this is one of the most common Yup-related bugs in production forms.
Validating Data with Yup
try {
const validData = await userSchema.validate(
{ name: "Ali", email: "not-an-email" },
{ abortEarly: false }
);
console.log(validData);
} catch (err) {
if (err instanceof yup.ValidationError) {
console.log(err.errors); // ["Please enter a valid email address"]
}
}
What's happening here: Yup's .validate() is async by default and returns a Promise — this is a real difference from Zod, where safeParse is synchronous unless you specifically opt into safeParseAsync. abortEarly: false tells Yup to collect every validation error instead of stopping at the first one. For a version that never throws, Yup also offers validateSync() with a try/catch, or you can check .isValid().
Yup in a Next.js Server Action
// actions/create-user.ts
"use server";
import { userSchema } from "@/lib/validators/user-schema";
import { ValidationError } from "yup";
export async function createUser(prevState: unknown, formData: FormData) {
try {
const data = await userSchema.validate(
{
name: formData.get("name"),
email: formData.get("email"),
},
{ abortEarly: false }
);
// await db.user.create({ data });
return { success: true };
} catch (err) {
if (err instanceof ValidationError) {
const fieldErrors: Record<string, string[]> = {};
for (const e of err.inner) {
if (e.path) fieldErrors[e.path] = [...(fieldErrors[e.path] ?? []), e.message];
}
return { errors: fieldErrors };
}
throw err;
}
}
Notice this requires a try/catch and manually reshaping err.inner into a field-keyed object — Zod's flatten().fieldErrors gives you that shape for free.
Zod vs Yup: Side-by-Side
| Zod | Yup | |
|---|---|---|
| TypeScript inference | Automatic, precise (z.infer) | Functional but looser (InferType) |
| Default field behavior | Required by default | Optional by default |
| Validation style | Sync by default (safeParse), async available | Async by default (.validate()) |
| Error shape | Structured ZodError with .flatten() | ValidationError with .inner array |
| Bundle size (gzipped) | Zod is slightly larger, but the difference is usually too small to matter for real applications. | Slightly smaller |
| React Hook Form support | ✅ via @hookform/resolvers/zod | ✅ via @hookform/resolvers/yup |
| Ecosystem fit | tRPC, modern Next.js patterns, Server Actions | Formik, legacy React form codebases |
| Learning curve | Slightly more API surface | Simpler, more forgiving for JS-first teams |
| Best for | New TypeScript projects | Existing Formik/Yup codebases |
💡 Tip: Bundle size differences here are small enough that they shouldn't be your deciding factor for most apps — a few KB gzipped rarely matters next to your actual app code and images.
Using Either One with React Hook Form
This is where a lot of real-world decisions actually get made, so it's worth showing both side by side. Both integrate through the same @hookform/resolvers package.
npm install react-hook-form @hookform/resolvers
With Zod:
"use client";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
const formSchema = z.object({
email: z.email(),
password: z.string().min(8, "Password must be at least 8 characters"),
});
type FormValues = z.infer<typeof formSchema>;
export function LoginForm() {
const { register, handleSubmit, formState: { errors } } = useForm<FormValues>({
resolver: zodResolver(formSchema),
});
const onSubmit = (data: FormValues) => console.log(data);
return (
<form onSubmit={handleSubmit(onSubmit)}>
<input {...register("email")} />
{errors.email && <p>{errors.email.message}</p>}
<input type="password" {...register("password")} />
{errors.password && <p>{errors.password.message}</p>}
<button type="submit">Log in</button>
</form>
);
}
With Yup:
"use client";
import { useForm } from "react-hook-form";
import { yupResolver } from "@hookform/resolvers/yup";
import * as yup from "yup";
const formSchema = yup.object({
email: yup.string().email().required(),
password: yup.string().min(8, "Password must be at least 8 characters").required(),
});
type FormValues = yup.InferType<typeof formSchema>;
export function LoginForm() {
const { register, handleSubmit, formState: { errors } } = useForm<FormValues>({
resolver: yupResolver(formSchema),
});
const onSubmit = (data: FormValues) => console.log(data);
return (
<form onSubmit={handleSubmit(onSubmit)}>
<input {...register("email")} />
{errors.email && <p>{errors.email.message}</p>}
<input type="password" {...register("password")} />
{errors.password && <p>{errors.password.message}</p>}
<button type="submit">Log in</button>
</form>
);
}
What's different: almost nothing at the React Hook Form layer — swap the resolver, swap the schema syntax, and everything else stays identical. This is exactly why the "which one" decision usually comes down to TypeScript ergonomics and team familiarity rather than form-library compatibility, since both are equally well supported.
Async Validation: Where Yup Still Has an Edge
If you need to check something against a server mid-validation — "is this username already taken?" — Yup's .test() method has had mature async support for years:
const signupSchema = yup.object({
username: yup.string().test(
"unique-username",
"This username is already taken",
async (value) => {
if (!value) return true;
const res = await fetch(`/api/check-username?u=${value}`);
const { available } = await res.json();
return available;
}
),
});
Zod supports async refinements too, using .refine() with an async function and safeParseAsync():
const signupSchema = z.object({
username: z.string(),
}).refine(
async (data) => {
const res = await fetch(`/api/check-username?u=${data.username}`);
const { available } = await res.json();
return available;
},
{ message: "This username is already taken", path: ["username"] }
);
// must use safeParseAsync, not safeParse, when the schema has async refinements
const result = await signupSchema.safeParseAsync({ username: "ali" });
What's happening here: both libraries can do this — Yup's API was simply designed around async validation from the start, so it reads slightly more naturally. Zod requires you to remember to swap safeParse for safeParseAsync whenever an async .refine() is in play, which is an easy thing to forget.
⚠️ Common Mistake: Calling
safeParse()(notsafeParseAsync()) on a Zod schema with an async.refine(). It won't throw — it'll just resolve the refinement incorrectly, since Zod can't await inside a synchronous parse. Always matchsafeParseAsyncto async refinements.
Migrating from Yup to Zod (If You're Considering It)
If you're weighing a migration, here's the honest advice: don't rewrite a working Yup codebase just to switch libraries. The type-safety gains are real, but they're not worth the regression risk on schemas that already work in production. Reach for Zod on new features or new projects instead, and let old Yup schemas retire naturally as you touch that code for other reasons.
If you are migrating a specific schema, the field-by-field mapping is usually mechanical:
| Yup | Zod |
|---|---|
yup.string().required() | z.string() |
yup.string().optional() | z.string().optional() |
yup.string().email() | z.email() |
yup.number().integer() | z.number().int() |
yup.object().shape({...}) | z.object({...}) |
yup.InferType<typeof schema> | z.infer<typeof schema> |
Frequently Asked Questions
Can I use Zod without TypeScript?
Yes, Zod works fine in plain JavaScript — but you lose its single biggest advantage, since type inference only matters if you have TypeScript to infer into. If your project is JavaScript-only, Zod and Yup are much closer in practical value, and team familiarity should decide it.
Is Zod always faster than Yup?
Not necessarily, and it depends heavily on the shape of your schema. For most typical form-sized payloads, both validate in well under a millisecond, so raw performance differences almost never matter in practice — TypeScript ergonomics and API fit are the more meaningful factors.
Do I need to rewrite my Formik forms to use Zod?
No. Formik and Yup are still a perfectly fine, actively maintained combination for existing codebases. Only reach for a migration if you're already refactoring that form layer for other reasons.
Which one works better with tRPC?
Zod, by a wide margin — tRPC's input/output validation is built with Zod's type inference in mind, and most tRPC examples and starter templates assume Zod. Yup can technically be adapted, but it's fighting the grain.
Can I use both Zod and Yup in the same project?
Technically yes, but it's not something to do on purpose. If you're migrating incrementally, it's fine to have both installed temporarily — just avoid mixing them within the same feature or form, since their default behaviors (required vs. optional fields, sync vs. async) differ enough to cause confusing bugs.
Does Zod support transforming data, not just validating it?
Yes — .transform() lets you reshape data as part of validation, like converting a string date into a Date object. Yup supports this too, through .transform() on its schemas. Both are equally capable here.
Wrapping Up
Zod and Yup solve the exact same problem — turning "hope the data is right" into "know the data is right" — but they were built for different eras of the JavaScript ecosystem. Zod's schema-is-the-type approach is what modern TypeScript tooling expects in 2026, and it's the right default for anything new you're building. Yup remains a completely legitimate choice if you're maintaining an existing Formik-based app, and there's no urgency to rip it out just because a newer library exists.
If you're starting a project today: reach for Zod, pair it with @hookform/resolvers/zod for your forms, and use .safeParse() on the server inside every Server Action or API route that touches user input — never trust that client-side validation alone is enough.
Try This Free Developer Tool
- Zod Schema Generator — paste a JSON sample or a TypeScript interface and get a production-ready Zod schema instantly, complete with smart field-name inference, a live validator, and ready-to-use React Hook Form, API Route, and Server Action output.
Continue Learning
More in Backend
View AllHow to Display Images from S3 in Next.js 16 (2026 Guide)
Learn 3 ways to display S3-uploaded images in Next.js 16: public URLs, presigned GET links for private files, and CloudFront CDN delivery.
Next.js 16 AWS S3 File Upload Guide: Presigned URLs, Proxy & Multipart
Learn AWS S3 file uploads in Next.js 16 using server proxy, presigned URLs, and multipart uploads with TypeScript, AWS SDK v3, and production best practices.
How to Integrate Sanity CMS with Next.js 16 App Router (2026 Guide)
Learn how to integrate Sanity CMS with Next.js 16 App Router. Set up an embedded Sanity Studio, write GROQ queries, render Portable Text, add TypeScript types, and build a fully content-driven blog.
Build a Todo App with Next.js 16 and Supabase (2026 Guide)
Learn how to build a production-ready Todo App using Next.js 16, Supabase, TypeScript, Tailwind CSS, Server Actions, Authentication, and Row Level Security (RLS).