Zod vs Valibot in 2026: Which TypeScript Validation Library Should You Use?

javascript dev.to

Zod vs Valibot in 2026: Which TypeScript Validation Library Should You Use?

For the last three years, Zod has been the default answer to runtime TypeScript validation. It ships with nearly every T3 stack, every tRPC tutorial, and most production Next.js apps. Then Valibot showed up and cut the bundle size by 10x. Now the ecosystem is split and developers on new projects have a real choice to make.

This is a practical breakdown — same schema, both libraries, real tradeoffs, and a clear recommendation for each use case. No benchmarks-that-don't-match-your-workload, no "it depends" cop-outs.

Why Bundle Size Actually Matters Here

Zod 3.x ships ~57 KB minified, ~14 KB gzipped. Valibot 1.x ships ~7 KB minified, ~3 KB gzipped. That gap matters in two places:

Edge runtimes — Cloudflare Workers has a 1 MB compressed script limit. When your validation layer eats 14 KB, that's budget you don't get back. Combined with a framework, ORM adapter, and business logic, you can hit the limit faster than expected.

Cold starts — Lambda and Vercel Edge Functions parse JS on every cold start. Smaller modules initialize faster. At scale, this compounds: a 50ms cold start difference at 10,000 invocations/day is meaningful in both latency percentiles and user experience.

For a standard Node.js server or a full Next.js app deployed to a traditional runtime, neither number is meaningful. The tradeoff shifts entirely to DX and ecosystem fit.

The Same Schema in Both Libraries

Let's validate a user signup payload — the kind of thing you'd parse in an API route before inserting to the database.

Zod

import { z } from 'zod';

const SignupSchema = z.object({
  email: z.string().email('Invalid email address'),
  password: z
    .string()
    .min(8, 'Password must be at least 8 characters')
    .regex(/[A-Z]/, 'Must contain at least one uppercase letter')
    .regex(/[0-9]/, 'Must contain at least one number'),
  username: z
    .string()
    .min(3)
    .max(20)
    .regex(/^[a-z0-9_]+$/, 'Only lowercase letters, numbers, and underscores'),
  role: z.enum(['user', 'admin', 'moderator']).default('user'),
  metadata: z
    .object({
      referralCode: z.string().optional(),
      acceptedTerms: z.literal(true),
    })
    .strict(),
});

type SignupInput = z.infer<typeof SignupSchema>;

// Usage in an API route
const result = SignupSchema.safeParse(req.body);
if (!result.success) {
  return res.status(400).json({
    errors: result.error.flatten().fieldErrors,
  });
}
const data: SignupInput = result.data;
Enter fullscreen mode Exit fullscreen mode

Valibot

import * as v from 'valibot';

const SignupSchema = v.object({
  email: v.pipe(v.string(), v.email('Invalid email address')),
  password: v.pipe(
    v.string(),
    v.minLength(8, 'Password must be at least 8 characters'),
    v.regex(/[A-Z]/, 'Must contain at least one uppercase letter'),
    v.regex(/[0-9]/, 'Must contain at least one number')
  ),
  username: v.pipe(
    v.string(),
    v.minLength(3),
    v.maxLength(20),
    v.regex(/^[a-z0-9_]+$/, 'Only lowercase letters, numbers, and underscores')
  ),
  role: v.optional(v.picklist(['user', 'admin', 'moderator']), 'user'),
  metadata: v.strictObject({
    referralCode: v.optional(v.string()),
    acceptedTerms: v.literal(true),
  }),
});

type SignupInput = v.InferOutput<typeof SignupSchema>;

// Usage in an API route
const result = v.safeParse(SignupSchema, req.body);
if (!result.success) {
  return res.status(400).json({
    errors: v.flatten(result.issues).nested,
  });
}
const data: SignupInput = result.output;
Enter fullscreen mode Exit fullscreen mode

The schemas look nearly identical at a glance, but there are structural differences worth noting. Valibot uses a pipe() model for chaining validators — base type first, then transforms/validations in order. Zod chains methods directly on the schema object. Both produce equivalent TypeScript types. Both support safeParse that returns a discriminated union instead of throwing.

The surface-level similarity hides a meaningful implementation difference: Valibot's pipe() builds a flat action list at schema definition time. Zod's method chaining creates a linked object graph. At parse time, Valibot iterates an array; Zod traverses a tree. This is why Valibot is faster at runtime.

Where They Diverge: Transformations

Transformations are where the DX gap becomes real. Zod's .transform() is ergonomic and naturally composable with .pipe() for the output type:

// Zod — parse date strings from an API and convert them, then cross-validate
const EventSchema = z.object({
  id: z.string().uuid(),
  startsAt: z.string().datetime().transform((val) => new Date(val)),
  durationMinutes: z.number().int().positive(),
  endsAt: z.string().datetime().transform((val) => new Date(val)),
}).refine(
  (data) => data.endsAt > data.startsAt,
  { message: 'End time must be after start time', path: ['endsAt'] }
);

type Event = z.infer<typeof EventSchema>;
// startsAt and endsAt infer as Date objects, not strings
// The .refine() error points specifically at the endsAt field
Enter fullscreen mode Exit fullscreen mode

Valibot handles the same, but cross-field refinement requires wrapping the whole object in another pipe(), and attaching a specific path to the error is more verbose:

import * as v from 'valibot';

const EventSchema = v.pipe(
  v.object({
    id: v.pipe(v.string(), v.uuid()),
    startsAt: v.pipe(
      v.string(),
      v.isoDateTime(),
      v.transform((val) => new Date(val))
    ),
    durationMinutes: v.pipe(v.number(), v.integer(), v.minValue(1)),
    endsAt: v.pipe(
      v.string(),
      v.isoDateTime(),
      v.transform((val) => new Date(val))
    ),
  }),
  v.check(
    (data) => data.endsAt > data.startsAt,
    'End time must be after start time'
  )
);
Enter fullscreen mode Exit fullscreen mode

Valibot's v.check() doesn't accept a path — the error surfaces at the root of the object, not on endsAt. For form validation where you want to highlight a specific field, this matters. There's a workaround using v.forward() with v.partialCheck(), but it's considerably more ceremony than Zod's one-liner { path: ['endsAt'] }.

This is the biggest practical DX difference between the two libraries. If you're doing a lot of cross-field validation with specific error paths, Zod wins on ergonomics.

Real Performance Benchmarks

These numbers are from a realistic SaaS workload benchmark using mitata, parsing a 10-field mixed-type object 100,000 times on Node 22:

Library Parse (valid input) Parse (invalid input) Bundle (gzip)
Zod 3.23 ~1.8M ops/sec ~1.2M ops/sec 14.1 KB
Valibot 1.0 ~2.4M ops/sec ~2.1M ops/sec 2.9 KB
Typia (AOT compile) ~8.1M ops/sec ~7.8M ops/sec 0.8 KB

Valibot is roughly 30% faster on valid parses and about 75% faster on invalid parses. The invalid-parse gap matters for APIs where you expect a lot of malformed input — validation on invalid data exercises more of the error path, and Valibot's flat action list makes early-exit faster.

Typia wins on raw speed by compiling validators at build time via TypeScript compiler plugins, but that's a different category of tool — it doesn't support schemas defined at runtime, which rules it out for anything that builds schemas dynamically.

For context: at 500 req/s with average parse time of 0.5ms per request, you're spending 250ms/sec on validation. The 30% Valibot advantage saves you 75ms/sec — invisible at that scale. At 10,000 req/s, it's 1,500ms/sec saved per server. At that point, profile first and optimize validation only if it shows up in traces.

Ecosystem and Framework Integrations

Zod's ecosystem advantage is real and sticky. First-party or officially recommended integrations exist for:

  • tRPC — Zod is the default and most documented option. initTRPC.create() accepts Zod schemas natively.
  • React Hook Form@hookform/resolvers/zod is the canonical resolver
  • Drizzle ORMdrizzle-zod generates Zod schemas from table definitions
  • Prismazod-prisma-types generates schemas from your Prisma schema
  • Next.js Server Actions — virtually all tutorials and starter kits use Zod
  • Conform — the progressive enhancement form library uses Zod as its primary schema

Valibot has adapter packages for tRPC and React Hook Form (@hookform/resolvers/valibot works). Drizzle added drizzle-valibot in 2024. The gap has narrowed, but if you're pulling in tRPC, you're already loading Zod as a transitive dependency. Switching your own schemas to Valibot doesn't eliminate Zod from your bundle — it adds Valibot on top.

This is the most overlooked point in the Zod vs Valibot debate. Check your lockfile first:

cat package-lock.json | grep '"zod"' | head -5
# If Zod is already in your dependency tree, bundle savings from switching are zero
Enter fullscreen mode Exit fullscreen mode

Standard Schema: Reducing the Lock-In

Both libraries now implement the Standard Schema spec — a shared interface (~standard) that lets frameworks accept either library without dedicated adapters. tRPC v11, TanStack Form, and several other libraries check for this interface at runtime.

import { z } from 'zod';
import * as v from 'valibot';

const ZodUser = z.object({ id: z.string(), name: z.string() });
const ValibotUser = v.object({ id: v.string(), name: v.string() });

// Both satisfy StandardSchemaV1 — any Standard Schema-compatible framework
// can accept either without adapter packages
type StandardSchema = { '~standard': { version: 1; vendor: string; validate: Function } };

function validateWithFramework<T extends StandardSchema>(schema: T, input: unknown) {
  return schema['~standard'].validate(input);
}

validateWithFramework(ZodUser, { id: '1', name: 'Alice' });    // works
validateWithFramework(ValibotUser, { id: '1', name: 'Alice' }); // works
Enter fullscreen mode Exit fullscreen mode

As the Standard Schema spec gets wider adoption, the library lock-in from this decision decreases. You'll be able to migrate from Zod to Valibot (or vice versa) without touching framework integration code. That's not true today for every framework, but it's the direction the ecosystem is moving.

Error Format Differences

Zod and Valibot both produce flat error structures, but the APIs to access them differ:

// Zod error handling — flatten() is convenient for form libraries
const zodResult = SignupSchema.safeParse(badInput);
if (!zodResult.success) {
  const { fieldErrors, formErrors } = zodResult.error.flatten();
  // fieldErrors: { email?: string[], password?: string[], username?: string[] }
  // formErrors: string[]  (errors not attached to a specific field)

  // Individual field access
  const emailErrors = zodResult.error.formErrors.fieldErrors.email; // string[] | undefined

  // Full structured access
  const issues = zodResult.error.issues;
  // issues[0].path = ['metadata', 'acceptedTerms']
  // issues[0].message = 'Expected literal true'
}

// Valibot error handling — flatten() exists but API differs
const valibotResult = v.safeParse(SignupSchema, badInput);
if (!valibotResult.success) {
  const flattened = v.flatten(valibotResult.issues);
  // flattened.nested: { email?: [string, ...string[]], password?: [string, ...string[]] }
  // flattened.root: [string, ...string[]]  (instead of formErrors)

  // Direct issues access
  const issues = valibotResult.issues;
  // issues[0].path = [{ key: 'metadata', ... }, { key: 'acceptedTerms', ... }]
  // issues[0].message = 'Invalid type'
}
Enter fullscreen mode Exit fullscreen mode

Two concrete differences: Zod's fieldErrors values are string[] | undefined — the array may be absent. Valibot's nested values are [string, ...string[]] | undefined — if present, always at least one item. Minor but it affects your null checks downstream.

Valibot's path items are objects { key: string | number, ... } rather than Zod's plain (string | number)[]. Valibot carries more metadata per path segment (schema, input, origin), which is useful for building form libraries but verbose for basic error display.

When to Use Zod

Use Zod when:

  • You're on a team where DX consistency matters more than bundle optimization
  • Your stack already includes Zod transitively (tRPC, Prisma generators, etc.) — check before switching
  • You rely on .refine() with specific error paths across multiple fields
  • You're generating schemas from Drizzle or Prisma table definitions
  • You're using Conform or other Zod-first form libraries
  • You want the largest StackOverflow/LLM-generated example coverage

When to Use Valibot

Use Valibot when:

  • You're building for Cloudflare Workers, edge runtimes, or Deno Deploy where every KB counts
  • Your schema files are imported in a client bundle (browser-side form validation)
  • You're starting a greenfield project with no existing Zod transitive dependencies
  • Parse throughput is measurably on your hot path (verify with profiling, not assumption)
  • You want to adopt the pipeline validation model and prefer its explicit composition

The Honest Take

For most developers building a standard Next.js SaaS with tRPC: use Zod. The ecosystem match is too good to pass up and the bundle difference is meaningless when Zod is already in your dependency tree.

For developers targeting edge runtimes, writing libraries others will import, or building browser-heavy validation with bundle budgets: use Valibot. The 10x bundle reduction is real and the API has matured enough for serious production use.

The Standard Schema spec is gradually making this decision less permanent. Neither library is going anywhere. Pick based on your concrete current constraints and revisit when the tradeoff changes.


Skip the Boilerplate

The AI SaaS Starter Kit includes a pre-wired Zod validation layer for all API routes, tRPC procedures, and server actions — error formatting that works with React Hook Form out of the box, plus Drizzle schema generation included. Ship production-ready in hours.

$99 → whoffagents.com


Source: dev.to

arrow_back Back to Tutorials