Both Zod and Valibot solve the same problem: you have TypeScript types that only exist at compile time, but you need to validate unknown data at runtime — API responses, form inputs, environment variables, JSON from a database. Both generate TypeScript types from your schema definitions. Both support transforms, refinements, and custom validators. Both work with the same tools: tRPC, Hono, React Hook Form, Conform.
The meaningful differences are bundle size, API design, and ecosystem maturity.
The Bundle Gap
This is where the comparison starts:
| Library | Minified + Gzipped | Tree-shaking |
|---|---|---|
| Zod v3 | ~55kb | Limited — imports pull in the full library |
| Valibot v1 | ~10-12kb total, ~1-2kb per schema | Full — pay only for what you use |
For a Node.js API, 55kb vs 12kb is irrelevant — it's startup memory, not network transfer. For a Cloudflare Worker or a browser bundle, it's material. Valibot was designed specifically for environments where bundle size is a constraint.
// Zod — the full library loads when you import anything
import { z } from 'zod'
// Valibot — only the functions you import are bundled
import { object, string, email, minLength, parse } from 'valibot'Valibot's functional import model means a worker that only validates email addresses pays ~1.5kb, not 55kb. That's the design decision at the core of the fork.
API Design: Methods vs Functions
The practical difference you encounter every day isn't bundle size — it's the API style.
Zod: Method Chaining
import { z } from 'zod'
const CreateUserSchema = z.object({
name: z.string().min(1, 'Name is required').max(100),
email: z.string().email('Invalid email address'),
age: z.number().int().min(18).max(120).optional(),
role: z.enum(['USER', 'ADMIN']).default('USER'),
tags: z.array(z.string()).max(10).default([]),
metadata: z.record(z.string(), z.unknown()).optional()
})
// Type inference
type CreateUser = z.infer<typeof CreateUserSchema>
// { name: string; email: string; age?: number; role: 'USER' | 'ADMIN'; tags: string[]; metadata?: Record<string, unknown> }
// Parsing (throws on failure)
const user = CreateUserSchema.parse(req.body)
// Safe parsing (returns result object)
const result = CreateUserSchema.safeParse(req.body)
if (!result.success) {
const errors = result.error.flatten()
// errors.fieldErrors: { name?: string[], email?: string[] }
// errors.formErrors: string[]
}Method chaining feels natural and reads well. The downside: Zod methods are on prototype objects — tree-shaking tools can't prune methods you don't call on a given schema.
Valibot: Function Pipeline
import {
object, string, number, array, boolean,
minLength, maxLength, email, integer, minValue, maxValue,
optional, picklist, record, unknown as vUnknown,
pipe, transform, parse, safeParse
} from 'valibot'
const CreateUserSchema = object({
name: pipe(string(), minLength(1, 'Name is required'), maxLength(100)),
email: pipe(string(), email('Invalid email address')),
age: optional(pipe(number(), integer(), minValue(18), maxValue(120))),
role: optional(picklist(['USER', 'ADMIN']), 'USER'),
tags: optional(pipe(array(string()), maxLength(10)), []),
metadata: optional(record(string(), vUnknown()))
})
// Type inference — same result
type CreateUser = InferOutput<typeof CreateUserSchema>
// Parsing — same API surface
const user = parse(CreateUserSchema, req.body)
const result = safeParse(CreateUserSchema, req.body)
if (!result.success) {
const errors = result.issues
// issues: Array<{ message: string; path: Array<{ key: string }> }>
}Valibot's pipe() pattern is more explicit and more verbose. It's also fully tree-shakeable — bundlers can statically analyze which functions you import and drop the rest.
Transforms and Coercion
Both handle data transformation as part of validation. The patterns differ slightly.
Coercing strings to numbers (common for query params)
// Zod
const PaginationSchema = z.object({
page: z.coerce.number().int().min(1).default(1),
limit: z.coerce.number().int().min(1).max(100).default(20)
})
// z.coerce.number() calls Number() on the input before validating
// Valibot
import { coerce } from 'valibot'
const PaginationSchema = object({
page: optional(pipe(coerce(number(), Number), integer(), minValue(1)), 1),
limit: optional(pipe(coerce(number(), Number), integer(), minValue(1), maxValue(100)), 20)
})
// More explicit but equivalentTransforming validated data
// Zod — .transform() on any schema
const UserIdSchema = z.string().cuid().transform(id => ({ id }))
// Input: "clm1234..." → Output: { id: "clm1234..." }
const DateStringSchema = z.string().datetime().transform(s => new Date(s))
// Input: "2026-08-11T10:00:00Z" → Output: Date object
// Valibot — transform() as a pipe step
import { transform, isoTimestamp } from 'valibot'
const DateStringSchema = pipe(
string(),
isoTimestamp(),
transform(s => new Date(s))
)Zod's .transform() is more compact. Valibot's is more consistent with the pipeline model — transforms are just another step in pipe().
Error Messages and i18n
Zod: Per-Validator Error Messages
const Schema = z.object({
username: z
.string({ required_error: 'Username is required' })
.min(3, 'Username must be at least 3 characters')
.max(20, 'Username cannot exceed 20 characters')
.regex(/^[a-z0-9_]+$/, 'Only lowercase letters, numbers, and underscores'),
password: z
.string()
.min(8, 'Password must be at least 8 characters')
.refine(
val => /[A-Z]/.test(val) && /[0-9]/.test(val),
'Password must contain at least one uppercase letter and one number'
)
})
// Error format for API responses
const result = Schema.safeParse(input)
if (!result.success) {
return res.status(400).json({
errors: result.error.flatten().fieldErrors
// { username: ['Only lowercase letters...'], password: ['...'] }
})
}Valibot: Global i18n via setGlobalConfig
import { setGlobalConfig } from 'valibot'
// Set locale-specific messages globally
setGlobalConfig({
lang: 'es',
message: (issue) => {
// Custom message resolver — full control over every error string
if (issue.type === 'min_length') return `Mínimo ${issue.requirement} caracteres`
if (issue.type === 'email') return 'Email inválido'
return 'Campo inválido'
}
})
// Or per-validator (same as Zod)
const Schema = object({
username: pipe(
string('El nombre de usuario es obligatorio'),
minLength(3, 'Mínimo 3 caracteres'),
maxLength(20, 'Máximo 20 caracteres')
)
})Zod's error messages are simpler to configure per field. Valibot's global config is better for full i18n — you write the translation layer once, every schema picks it up automatically.
Framework Integration
Both work with the same ecosystem. The adapters exist for both.
React Hook Form
// Zod
import { zodResolver } from '@hookform/resolvers/zod'
const { register, handleSubmit, formState: { errors } } = useForm({
resolver: zodResolver(CreateUserSchema)
})
// Valibot
import { valibotResolver } from '@hookform/resolvers/valibot'
const { register, handleSubmit, formState: { errors } } = useForm({
resolver: valibotResolver(CreateUserSchema)
})Same API, different resolver import. Valibot's resolver is in @hookform/resolvers as of v3.3+.
tRPC
// tRPC with Zod (default, most common)
import { z } from 'zod'
export const usersRouter = router({
create: publicProcedure
.input(z.object({ name: z.string(), email: z.string().email() }))
.mutation(async ({ input }) => { /* input is typed */ })
})
// tRPC with Valibot (supported since tRPC v11)
import { object, string, email } from 'valibot'
export const usersRouter = router({
create: publicProcedure
.input(object({ name: string(), email: pipe(string(), email()) }))
.mutation(async ({ input }) => { /* input is typed */ })
})Hono zValidator
// Zod
import { zValidator } from '@hono/zod-validator'
app.post('/users', zValidator('json', CreateUserSchema), async (c) => {
const body = c.req.valid('json') // typed
})
// Valibot
import { vValidator } from '@hono/valibot-validator'
app.post('/users', vValidator('json', CreateUserSchema), async (c) => {
const body = c.req.valid('json') // typed
})Both are officially supported. Valibot adapters exist for every major integration, but they're newer and occasionally lag one version behind Zod adapters when libraries update.
Environment Variable Validation
A common use case where both shine:
// Zod — the dominant pattern for env validation in Next.js projects
import { z } from 'zod'
const envSchema = z.object({
DATABASE_URL: z.string().url(),
NEXTAUTH_SECRET: z.string().min(32),
STRIPE_SECRET_KEY: z.string().startsWith('sk_'),
NODE_ENV: z.enum(['development', 'production', 'test']).default('development'),
PORT: z.coerce.number().int().min(1024).max(65535).default(3000)
})
export const env = envSchema.parse(process.env)
// Crashes at startup with clear errors if any variable is missing or malformed
// Valibot equivalent — same result, slightly more verbose
import { parse, object, string, url, minLength, startsWith, picklist, optional, coerce, number } from 'valibot'
const envSchema = object({
DATABASE_URL: pipe(string(), url()),
NEXTAUTH_SECRET: pipe(string(), minLength(32)),
STRIPE_SECRET_KEY: pipe(string(), startsWith('sk_')),
NODE_ENV: optional(picklist(['development', 'production', 'test']), 'development'),
PORT: optional(pipe(coerce(number(), Number), integer(), minValue(1024), maxValue(65535)), 3000)
})
export const env = parse(envSchema, process.env)For server-side env validation in a Node.js app, Zod is shorter and more readable. The bundle size difference doesn't matter here since it's server-only.
TypeScript Inference: Both Are Excellent
One concern when evaluating alternatives to Zod is type inference quality. Valibot matches Zod in this regard.
// Zod
const PostSchema = z.object({
id: z.string().cuid(),
title: z.string().min(1).max(200),
body: z.string(),
published: z.boolean().default(false),
tags: z.array(z.string()).default([]),
author: z.object({
id: z.string(),
name: z.string()
})
})
type Post = z.infer<typeof PostSchema>
// {
// id: string;
// title: string;
// body: string;
// published: boolean;
// tags: string[];
// author: { id: string; name: string };
// }
// Valibot — InferOutput for the output type (after transforms/defaults)
import { InferOutput } from 'valibot'
type Post = InferOutput<typeof PostSchema>
// Same inferred typeValibot has both InferInput (before transforms) and InferOutput (after) — useful when your schema transforms data and you want the type of the result, not the input.
Decision Framework
| Situation | Choose |
|---|---|
| Existing Zod codebase | Zod |
| Node.js API, bundle size irrelevant | Zod |
| Already using React Hook Form + Zod | Zod |
| Cloudflare Workers, edge functions | Valibot |
| Browser bundle, size-sensitive SPA | Valibot |
| Need full i18n error messages | Valibot |
Want InferInput / InferOutput distinction | Valibot |
| Ecosystem maturity matters (tRPC, adapters) | Zod |
Practical summary: If you're starting a new project and bundle size isn't a constraint, Zod is the lower-friction choice — the ecosystem integration is more mature, the documentation is richer, and the API is slightly more readable. If you're building for edge runtimes or shipping validation in a browser bundle, Valibot's tree-shaking and smaller footprint make a meaningful difference.
They're interchangeable for most use cases. Migrating from one to the other is a find-and-replace-plus-refactor, not a rearchitecture.
For a complete reference on Zod's API — including discriminated unions, branded types, and advanced transforms — see the Zod complete guide. For the form validation layer, React Hook Form + Zod covers the full integration. Both libraries work equally well with tRPC and Hono RPC for end-to-end type safety.