Skip to content

Valibot vs Zod in 2026: I Half-Migrated and Stopped

Valibot vs Zod in 2026: I Half-Migrated and Stopped

Confession: I’ve been typing z.object({...}) so reflexively for the past five years that when a teammate suggested swapping to Valibot, my first reaction was a tired “why would I do that to myself.” Then I actually looked at the bundle.

Our app’s client-side validation pulled in 13.4 KB of gzipped Zod just to parse three forms. That’s not a crisis, but it’s not nothing on a low-end Android phone. Valibot’s pitch is simple: same idea, tree-shakable, often a fraction of the size. The marketing pages will tell you it’s “57x smaller.” The marketing pages are lying in a normal marketing way, but the real number for a typical schema is still good enough that I took it seriously.

So I migrated. Sort of. Then I stopped. This post is what I learned about both libraries, what made me switch on the client, and what kept Zod on the server. If you’re staring at the same choice, here’s the honest middle answer.

Why I even looked at Valibot

The pitch comes down to bundle size. The “functional pipeable API” framing is real but it’s a taste preference, roughly as religious as tabs vs spaces. Tree-shaking is table stakes on any modern bundler.

For a real comparison, I built the same schema in both libraries: a sign-up form with email, password rules, optional referral code, and a nested address. With Zod, the import cost about 13.4 KB gzipped. With Valibot’s parse, object, string, email, minLength, optional, and the rest of what I needed, it came out around 1.1 KB. That’s not 57x. It’s about 12x. And 12x is still very good.

The catch: Valibot’s bundle wins because it’s modular by design. You import every validator individually. That feels weird at first if you’re coming from z.string().email().min(8).

// Zod
const Signup = z.object({
  email: z.string().email(),
  password: z.string().min(8),
});

// Valibot
import { object, string, email, minLength, parse, pipe } from "valibot";
const Signup = object({
  email: pipe(string(), email()),
  password: pipe(string(), minLength(8)),
});

The Valibot version is a few more lines. In return, your bundler can drop anything you don’t actually use. Worth it on the client. Mostly noise on the server. See Valibot’s docs for the full API surface.

The API differences that actually matter

Bundle size is the headline. Day-to-day, the API differences matter more.

Zod uses method chaining. Every transform, refine, or default lives on the schema object. Valibot uses standalone functions composed via pipe. After about a week, both feel fine. The honest difference is that pipe plays better with TypeScript inference in some weird edge cases, particularly when you’re chaining custom validators. Zod’s chain is more discoverable in editor autocomplete because everything hangs off z..

A real example. Validating a string that should be a UUID, lowercased:

// Zod 3.x
const Id = z.string().uuid().transform((s) => s.toLowerCase());

// Valibot
const Id = pipe(string(), uuid(), transform((s) => s.toLowerCase()));

Same length. Slightly different mental model. The difference isn’t ergonomic. It’s where the type errors point. Zod errors cluster on .uuid() if you typo something. Valibot errors point at pipe. After two months of writing both, I had a clear preference: Zod for code I’m reading on a screen share, Valibot for code I’m tree-shaking.

For error formatting, both libraries return a structured error tree. Zod’s error.format() is more polished out of the box. Valibot’s flatten() is leaner but you’ll write your own formatter if your UI cares about per-field nested error paths. I wrote one in about 30 lines. It wasn’t a tax I expected.

Where Zod still wins

I went in expecting “Zod wins on ecosystem” to be the headline. It is, but not for the reason I thought.

The real win isn’t the number of integrations. It’s the second-order stuff. drizzle-zod gives you schemas auto-generated from your DB tables. tRPC defaults to Zod. Hono’s validator middleware ships with Zod adapters by default. Most OpenAPI generators assume Zod. Every one of these has a Valibot equivalent in some state of completeness, and “some state of completeness” is doing a lot of work in that sentence.

If you’re picking between them on a brand new project, this matters less than it sounds. If you’re inside an existing app, it matters a lot. I’ve been migrating an app from Prisma to Drizzle, and drizzle-zod lets me skip writing schemas by hand. There’s a drizzle-valibot package, and it works, but it lags behind on edge cases. I hit one bug with nullable JSONB columns in March that was still open the last time I checked.

Server-side, this is the whole game. I have schemas being shared between database, API, and client validation. Switching one to Valibot meant either translating at the boundary or migrating everything. Neither was a good Tuesday.

So the rule I landed on: client-bundled schemas go to Valibot. Server-side schemas, especially anything shared with the database, stay on Zod. Boring, but it works.

I covered a related case in my post on TypeScript branded types, where the same “runtime validation meets the type system” tension shows up.

My partial migration: what I moved, what I didn’t

Concrete numbers from a small SaaS app. Before:

  • Sign-up form on Zod, client-bundled.
  • Profile edit form on Zod, client-bundled.
  • API route validators on Zod, server only.
  • Drizzle inferred schemas, server only.

What I moved:

  • Sign-up form to Valibot. Saved about 11 KB gzipped from the auth bundle.
  • Profile edit form to Valibot. Saved another 8 KB.

What I left:

  • API route validators stayed on Zod. Not in the client bundle, so size doesn’t matter.
  • Drizzle schemas stayed on Zod because drizzle-valibot isn’t there yet.

Net result: the auth-and-onboarding bundle dropped about 18 KB gzipped. Real LCP improvement on a slow phone in our RUM data: about 140 ms. Not life-changing. Worth a Tuesday.

The migration itself took about four hours including tests, mostly because I was relearning the API. The second form took 30 minutes.

What about ArkType?

ArkType is the obvious next question. It tries something genuinely clever: parse TypeScript-flavored type strings at runtime.

import { type } from "arktype";
const User = type({
  email: "string",
  age: "number > 0",
});

Looks great in slides. In practice, the editor support is meaningfully worse than either Zod or Valibot. You’re writing your schema as a string, so your editor’s TypeScript service doesn’t help you. The plugin work is real but uneven. Bundle size is in the same ballpark as Valibot. Error messages are arguably better than both.

I’d watch ArkType. I wouldn’t bet a 2026 codebase on it yet. If you’re picking today, it’s still a Zod-or-Valibot choice for most teams.

Always check the bundle yourself with pnpm dlx source-map-explorer before you decide. The README benchmarks across all three projects are not lying, but they’re also not your app.

What to actually try this week

Pick one client-bundled schema in your app. A form validator is the obvious one. Migrate just that schema to Valibot. Run the bundle analyzer before and after. If the savings are over 5 KB gzipped, you have your answer for that part of the codebase. If they’re under 2 KB, stay on Zod and stop reading internet posts about validation libraries (including this one).

The annoying truth is that this isn’t a religious choice. It’s a per-bundle calculation. I run both libraries in the same repo and the only person who’s noticed is me, on the rare days I have to remember which file uses which API.

If you’re more interested in the bundle-size side of life, I write about practical performance and tooling work in my recent project work. If you do migrate a form this week, the only thing I’d ask is that you actually measure before and after. My migration was right. The Valibot README is right. Both could still be wrong for your app. Measure. Then decide.