Key Takeaway
Zod 4 ships inside the main zod package and rewrites the validation core for speed: roughly 14x faster string parsing, 7x faster arrays, and a 100x cut in TypeScript instantiations on chained schemas. It adds a tree-shakable zod/mini build at about 1.9 KB gzipped, unifies error customization under one error parameter, and moves string formats to top-level functions like z.email().
I lean on Zod in almost every TypeScript service I run: it validates request bodies in NestJS, parses environment variables at boot, and guards the JSON I pull from third-party APIs. So when a major version lands and the headline is performance, I pay attention. Zod 4 is not a cosmetic bump. The core was rewritten, the type-level cost dropped hard, and a few APIs I used every day changed shape.
This is what actually changed, what it costs you to migrate, and where the new zod/mini build earns its place. Everything below is from hands-on upgrading, cross-checked against the official Zod 4 release notes and migration guide.
The performance story is real, and most of it is at compile time
The runtime numbers Zod publishes are genuinely large: about 14x faster string parsing, 7x faster array parsing, and 6.5x faster object parsing versus Zod 3. Those come from flattening the internal check system and killing the old ZodEffects wrapper that used to sit around refinements and transforms. In a hot request path that validates every payload, that matters.
But the change I felt most was the type-level one. Zod reports up to a 100x reduction in tsc type instantiations when you chain methods like .extend() and .omit(). On a large schema file, that is the difference between the editor keeping up and the editor stalling. If you have ever watched TypeScript grind on a deeply composed Zod schema, this alone can justify the upgrade.
Before you migrate, run your existing test suite once on Zod 3 and note the wall-clock time. Repeat after upgrading. The runtime win is easy to see in parse-heavy tests, and it gives you a concrete number to put in the pull request description instead of quoting a benchmark you did not run.
Error customization: one error parameter instead of three
This is the breaking change you will hit most. Zod 3 had three overlapping ways to customize messages: required_error, invalid_type_error, and errorMap, plus a plain message on refinements. Zod 4 collapses all of them into a single error parameter. It accepts a string for the simple case or a function that receives the issue for conditional logic. The old message field still works but is deprecated.
// Zod 3 — three different knobs
const schema3 = z.string({
required_error: "Name is required",
invalid_type_error: "Name must be a string",
});
// Zod 4 — one unified `error`
const schema4 = z.string({
error: (issue) =>
issue.input === undefined
? "Name is required"
: "Name must be a string",
});
// Simple string form still works everywhere:
z.string().min(5, { error: "Too short" });There is also a global error map so you can localize messages in one place, which is genuinely useful when you serve both English and Indonesian users. The mental model is cleaner: there is exactly one place to set an error, and it is the same in every position.
String formats moved to top-level functions
In Zod 3 you wrote z.string().email(). In Zod 4 the format validators are top-level functions: z.email(), z.uuid(), z.url(), z.ipv4(), z.ipv6(). The chained method forms still exist but are deprecated, and they carry a bundle-size cost because they force the whole string-format machinery into your build. The top-level functions are tree-shakable, so you only ship the validators you import.
// Deprecated (still works)
z.string().email();
z.string().uuid();
// Preferred in Zod 4
z.email();
z.uuid(); // stricter, RFC 9562
z.guid(); // looser GUID matching if you need it
z.url();z.uuid() is stricter in Zod 4 — it validates against RFC 9562 and will reject some strings that Zod 3 accepted, including certain non-standard UUID-like values. If you validate IDs coming from an older system, test with real data before you ship, or use z.guid() for the looser match.
Other breaking changes worth a grep
- z.record() now requires two arguments and infers exhaustive keys for enums. A record over an enum gives you a complete type, not a partial one — use z.partialRecord() if you want the old optional-key behavior.
- .default() now applies to the output type and short-circuits parsing when the input is undefined. The old input-side behavior lives on as .prefault().
- z.number() rejects Infinity, and .int() only accepts safe integers. Numeric edge cases that silently passed in Zod 3 now fail loudly.
- z.function() is no longer a schema; it is a function factory with .implement() and .implementAsync(). Any code that treated it as a parseable schema needs rework.
- A failed intersection merge throws a plain Error now, not a ZodError with a special issue code.
There is an official codemod, zod-v3-to-v4, that handles the mechanical renames like the error-param migration and the string-format moves. I still read every diff it produces — codemods are great at the boring 90 percent and dangerous on the interesting 10 percent — but it saved me real time on a large codebase.
zod/mini: when the 1.9 KB build is worth it
Zod 4 introduces a second entry point, zod/mini, with a functional and fully tree-shakable API. It lands at about 1.88 KB gzipped, roughly 85 percent smaller than the core build. Instead of chaining methods you compose functions, which is what makes it shake so well: unused validators never reach your bundle.
// Core Zod — method chaining
import * as z from "zod";
const User = z.object({
email: z.email(),
age: z.number().min(18),
});
// zod/mini — function composition, tree-shakable
import * as z from "zod/mini";
const UserMini = z.object({
email: z.email(),
age: z.number().check(z.minimum(18)),
});I would not reach for zod/mini on the backend. On a Node service the bundle size is irrelevant and the chaining ergonomics of core Zod are nicer to read and maintain. Where mini earns its place is the edge: serverless functions with cold-start budgets, browser bundles where every kilobyte is a real metric, and edge runtimes. The APIs share the same core, so you can use core on the server and mini on the client without learning two validation libraries.
| Concern | Core zod | zod/mini |
|---|---|---|
| Import | import * as z from "zod" | import * as z from "zod/mini" |
| Bundle (gzipped) | Larger, chaining API | ~1.9 KB, tree-shakable |
| Ergonomics | Method chaining, most readable | Function composition |
| Best fit | Node backends, servers | Edge, serverless, browser |
How I would sequence the upgrade
- Bump to zod version 4 and let TypeScript surface the type errors — the compiler is your migration checklist.
- Run the zod-v3-to-v4 codemod, then read every diff it produced before committing.
- Grep for required_error, invalid_type_error, and errorMap and migrate them to the single error parameter.
- Search for .uuid() usages and test them against real production IDs, since RFC 9562 is stricter.
- Run the full test suite, then decide per-target whether any browser or edge bundle should switch to zod/mini.
For most projects the migration is an afternoon, not a week. The error-param change touches many files but is mechanical, the codemod does the bulk of it, and TypeScript refuses to let you finish while anything is still broken. The payoff — faster parsing, dramatically cheaper type checking, and a real edge-sized build — is worth the diff.

