Zod logo

Introducing Zod 4

Zod 4 is now in beta after over a year of active development. It's faster, slimmer, more tsc-efficient, and implements some long-requested features, including an entirely new @zod/mini library.

To try the beta:

pnpm upgrade zod@v4 

Development will continue on the v4 branch over a 4-6 week beta period as I work with libraries to ensure day-one compatibility with the first stable release.

Refer to the Changelog for a complete list of breaking changes.

Why a new major version?

Zod 3 was released in May 2021. Back then Zod had 2700 stars on GitHub and 600k weekly downloads. Today it has 36.5k stars and 23M weekly downloads (!). After 24 minor versions, the Zod 3 codebase is essentially "maxxed out"; the most commonly requested features and usability improvements require some sort of breaking change.

Zod 4 implements all of these in one fell swoop. It uses an entirely new internal architecture that solves some long-standing design limitations, lays the groundwork for some long-requested features, and closes 9 of Zod's 10 most upvoted open issues. With luck, it will serve a the new foundation for many more years to come.

What's new?

Here's a quick breakdown of what's new. Click on any item to jump to that section.

  • Benchmarks
    • 2.8x faster string parsing
    • 3x faster array parsing
    • 7x faster object parsing
    • 20x reduction in tsc instantiations
    • 30% reduction in core bundle size
  • Introducing @zod/mini
  • Exact(er) optional properties with z.interface()
  • True cyclical types
  • Metadata
  • JSON Schema conversion
  • Internationalization via locales
  • Template literals: z.templateLiteral()
  • Better boolean coercion: z.stringbool()
  • File support: z.file()
  • New string formats
  • New numeric formats
  • Simplified error customization
  • An extensible foundation: @zod/core

Development will continue on the v4 branch over a 4-6 week beta period as I work with libraries to ensure day-one compatibility with the first stable release.

Benchmarks

You can run these benchmarks yourself in the Zod repo:

$ git clone git@github.com:colinhacks/zod.git
$ cd zod
$ pnpm install

Then to run a particular benchmark:

$ pnpm bench <name>

2.6x faster string parsing

$ pnpm bench string
runtime: node v22.13.0 (arm64-darwin)
 
benchmark      time (avg)             (min max)       p75       p99      p999
------------------------------------------------- -----------------------------
 z.string().parse
------------------------------------------------- -----------------------------
zod3          348 µs/iter       (299 µs 743 µs)    362 µs    494 µs    634 µs
zod4          132 µs/iter       (108 µs 348 µs)    162 µs    269 µs    322 µs
 
summary for z.string().parse
  zod4
   2.63x faster than zod3

3x faster array parsing

$ pnpm bench array
runtime: node v22.13.0 (arm64-darwin)
 
benchmark      time (avg)             (min max)       p75       p99      p999
------------------------------------------------- -----------------------------
 z.array() parsing
------------------------------------------------- -----------------------------
zod3          162 µs/iter       (141 µs 753 µs)    152 µs    291 µs    513 µs
zod4       54'282 ns/iter    (47'084 ns 669 µs) 50'833 ns    185 µs    233 µs
 
summary for z.array() parsing
  zod4
   2.98x faster than zod3

7x faster object parsing

This runs the Moltar validation library benchmark.

$ pnpm bench object-moltar
benchmark      time (avg)             (min max)       p75       p99      p999
------------------------------------------------- -----------------------------
 z.object() safeParse
------------------------------------------------- -----------------------------
zod3          767 µs/iter     (735 µs 3'136 µs)    775 µs    898 µs  3'136 µs
zod4          110 µs/iter     (102 µs 1'291 µs)    105 µs    217 µs    566 µs
 
summary for z.object() safeParse
  zod4
   6.98x faster than zod3

20x reduction in tsc instantiations

Consider the following simple file:

import * as z from "zod";
 
export const A = z.object({
  a: z.string(),
  b: z.string(),
  c: z.string(),
  d: z.string(),
  e: z.string(),
});
 
export const B = A.extend({
  f: z.string(),
  g: z.string(),
  h: z.string(),
});

Compiling this file with tsc --extendedDiagnostics using zod@3 results in >25000 type instantiations. With zod@4 it only results in ~1100.

The Zod repo contains a tsc benchmarking playground. Try this for yourself using the compiler benchmarks in packages/tsc. The exact numbers may change as the implementation evolves.

$ cd packages/tsc
$ pnpm bench object-with-extend

More importantly, Zod 4 has redesigned and simplified the generics of ZodObject and other schema classes to avoid some pernicioius "instantiation explosions". The following script contains a number of chained called to .extend() and .omit().

import * as z from "zod";
 
export const a = z.object({
  a: z.string(),
  b: z.string(),
  c: z.string(),
});
 
export const b = a.omit({
  a: true,
  b: true,
  c: true,
});
 
export const c = b.extend({
  a: z.string(),
  b: z.string(),
  c: z.string(),
});
 
export const d = c.omit({
  a: true,
  b: true,
  c: true,
});
 
export const e = d.extend({
  a: z.string(),
  b: z.string(),
  c: z.string(),
});
 
export const f = e.omit({
  a: true,
  b: true,
  c: true,
});
 
export const g = f.extend({
  a: z.string(),
  b: z.string(),
  c: z.string(),
});
 
export const h = g.omit({
  a: true,
  b: true,
  c: true,
});
 
export const i = h.extend({
  a: z.string(),
  b: z.string(),
  c: z.string(),
});
 
export const j = i.omit({
  a: true,
  b: true,
  c: true,
});
 
export const k = j.extend({
  a: z.string(),
  b: z.string(),
  c: z.string(),
});
 
export const l = k.omit({
  a: true,
  b: true,
  c: true,
});
 
export const m = l.extend({
  a: z.string(),
  b: z.string(),
  c: z.string(),
});
 
export const n = m.omit({
  a: true,
  b: true,
  c: true,
});
 
export const o = n.extend({
  a: z.string(),
  b: z.string(),
  c: z.string(),
});
 
export const p = o.omit({
  a: true,
  b: true,
  c: true,
});
 
export const q = p.extend({
  a: z.string(),
  b: z.string(),
  c: z.string(),
});

In Zod 3, this took 4000ms to compile; and adding additional called to .extend() would trigger a "Possibly infinite" error. In Zod 4, this compiles in 400ms, and compile time increases linearly with the number of calls to .extend().

Coupled with the upcoming tsgo compiler, Zod 4's editor performance will scale to vastly larger schemas and codebases.

30% reduction in core bundle size

Consider the following simple script.

import * as z from "zod";
 
const schema = z.string().min(5);
 
schema.parse("hello world");

Let's bundle this with rollup using both Zod 3 and Zod 4 and compare the final bundles.

PackageBundle (gzip)
zod@312.47kb
zod@48.77kb

This indicates that the "core bundle" (the code that will get bundled in the most minimal cases) is ~30% smaller in Zod 4. That's good!

But ultimately the method-driven API of zod is fundamentally difficult to tree-shake. Writing slimmer implementations can only get you so far. Which brings us to the biggest announcement of Zod 4: the introduction of @zod/mini.

Introducing @zod/mini

The @zod/mini package provides a functional, tree-shakable API that maps one-to-one to the existing zod API. While zod is still recommended for the majority of use cases, any projects will uncommonly strict bundle size constraints should consider @zod/mini.

Everything in zod can be represented in @zod/mini; their APIs map one-to-one. But instead of Zod's method-first chainable API, @zod/mini is more function-forward. Use the tabs to compare the two.

import * as z from "@zod/mini";
 
z.optional(z.string());
z.union([z.string(), z.number()]);
z.string().check(z.refine(val => val.includes("@")));
z.array(z.number()).check(z.minLength(5), z.maxLength(10));
z.extend(z.object({ name: z.string() }), { age: z.number() });

By eliminating most methods from the base class, bundlers have a much easier time tree-shaking the APIs you don't use. Here's the script from above, updated to use "@zod/mini" instead of "zod" (nothing changed but the import).

import * as z from "@zod/mini";
 
const schema = z.string();
schema.parse("hello world");

When we build this will rollup, the gzipped bundle size is 2.23kb. That's a 5.7x reduction in bundle size compared to zod@3.

PackageBundle (gzip)
zod@312.47kb
zod@48.77kb
@zod/mini2.23kb

The @zod/mini package doesn't have its own documentation. Instead, all code blocks in the Defining schemas now have separate code tabs for zod and @zod/mini.

Exact(er) optional properties

Zod 4 introduces a new API for defining object types: z.interface(). This may seem surprising or confusing, so I'll briefly explain the reasoning here. (A full blog post on this topic is coming soon.)

In TypeScript a property can be "optional" in two disctint ways:

type ValueIsOptional = { value: string | undefined };
type KeyIsOptional = { value?: string };

Zod v3 cannot represent ValueOptional. Instead, z.object() automatically adds question marks to any key that accepts a value of undefined:

z.object({ name: z.string().optional() }); // { name?: string | undefined }
z.object({ name: z.union([z.string(), z.undefined()]) }); // { name?: string | undefined }

The question mark also gets added for things lik z.unknown():

z.object({ name: z.unknown() }); // { name?: unknown } 
z.object({ name: z.any() }); // { name?: any } 

To properly represent "key optionality", Zod needed an object-level API for marking keys as optional, instead of trying to guess based on the value schema.

z.interface()

This is why Zod 4 introduces a new API for defining object types: z.interface().

const ValueOptional = z.interface({ name: z.string().optional()}); 
// { name: string | undefined }
 
const KeyOptional = z.interface({ "name?": z.string() }); 
// { name?: string }

Key optionality is now defined with a ? suffix in the key itself. This way, you have the power to differentiate between key- and value-optionality.

Besides this syntactic change, z.object() and z.interface() are functionality identical. They even use the same parser internally.

The z.object() API is not deprecated; feel free to continue using it if you prefer it! For the sake of backwards compatibility z.interface() was added as an opt-in API.

True recursive types

But wait there's more! After implementing z.interface(), I had a huge realization. The ?-suffix API in z.interface() lets Zod sidestep a TypeScript limitation that long prevented Zod from cleanly representing recursive (cyclical) types. Take this example from the old Zod 3 docs:

import * as z from "zod"; // zod@3
 
interface Category  {
  name: string;
  subcategories: Category[];
};
 
const Category: z.ZodType<Category> = z.object({
  name: z.string(),
  subcategories: z.lazy(() => Category.array()),
});

This has been a thorn in my side for years. To define a cyclical object type, you must a) define a redundant interface, b) use z.lazy() to avoid reference errors, and c) cast your schema to z.ZodType. This is terrible.

Here's the same example in Zod 4:

import * as z from "zod"; // zod@4
 
const Category = z.interface({
  name: z.string(),
  get subcategories() {
    return z.array(Category)
  }
});

No casting, no z.lazy(), no redundant type signatures. Just use getters to define any cyclical properties. The resulting instance has all the object methods you expect:

Category.pick({ subcategories: true });

This means Zod can finally represent commonly cyclical data structure like ORM schemas, GraphQL types, etc.

Given it's ability to represent both cyclical types and more exact optionality, I recommend always using z.interface() over z.object() without reservation. That said, z.object() will never be deprecated or removed, so feel free to keep using it if you prefer.

Metadata

Zod 4 introduces a new system for adding strongly-typed metadata to your schemas. Metadata isn't stored inside the schema itself; instead it's stored in a "schema registry" that associated a schema with some typed metadata. To create a registry with z.registry():

import * as z from "zod";
 
const myRegistry = z.registry<{ title: string; description: string }>();

To add schemas to your registry:

const emailSchema = z.string().email();
 
myRegistry.add(emailSchema, { title: "Email adddress", description: "..." });
myRegistry.get(emailSchema);
// => { title: "Email adddress", ... }

Alternatively, you can use the .register() method on a schema for convenience:

emailSchema.register(myRegistry, { title: "Email adddress", description: "..." })
// => returns emailSchema

The global registry

Zod also exports a global registry z.globalRegistry that accepts some common JSON Schema-compatible metadata:

z.globalRegistry.add(z.string(), { 
  id: "email_address"
  title: "Email address"
  description: "Provide your email"
  examples: ["naomie@example.com"],
  extraKey: "Additional properties are also allowed"
});

.meta()

To conveniently add a schema to z.globalRegistry, use the .meta() method.

z.string().meta({ 
  id: "email_address"
  title: "Email address"
  description: "Provide your email"
  examples: ["naomie@example.com"],
  // ...
});

For compatibility with Zod 3, .describe() is still available, but .meta() is preferred.

z.string().describe("An email address");
 
// equivalent to
z.string().meta({ description: "An email address" });

JSON Schema conversion

Zod 4 introduces first-party JSON Schema conversion via z.toJSONSchema().

import * as z from "zod";
 
const mySchema = z.object({name: z.string(), points: z.number()});
 
z.toJSONSchema(mySchema);
// => {
//   type: "object",
//   properties: {
//     name: {type: "string"},
//     points: {type: "number"},
//   },
//   required: ["name", "points"],
// }

Any metadata in z.globalRegistry is automatically included in the JSON Schema output.

const mySchema = z.object({
  firstName: z.string().meta({ description: "First name" }),
  lastName: z.string().meta({ description: "Last name" }),
  age: z.number().meta({ description: "Age" }),
});
 
z.toJSONSchema(mySchema);
// => {
//   type: "object",
//   properties: {
//     name: { title: "Your full name", type: "string" },
//     email: { title: "Email", type: "string", format: "email" },
//     password: { title: "Password", type: "string", minLength: 8 }
//   },
//   required: [ "name", "email", "password" ]
// }

Refer to the JSON Schema docs for information on customizing the generated JSON Schema.

File schemas

To validate File instances:

const fileSchema = z.file();
 
fileSchema.min(10_000); // minimum .size (bytes)
fileSchema.max(1_000_000); // maximum .size (bytes)
fileSchema.type("image/png"); // MIME type

Internationalization

Zod 4 introduces a new locales API for globally translating error messages into different languages.

import * as z from "zod";
 
// configure English locale (default)
z.config(z.core.locales.en());

At the time of this writing only the English locale is available; I'll be doing a call for PRs once the beta is published. I'll update this section with a list of supported languages as they become available.

Error pretty-printing

The success of the zod-validation-error package demonstrates that there's significanyt demand for an official API for pretty-printing errors. If you are using that package currently, by all means continue using it.

Zod now implements a top-level z.prettyError function for converting a ZodError to a user-friendly formatted string.

const myError = new z.ZodError([
  {
    code: 'unrecognized_keys',
    keys: [ 'extraField' ],
    path: [],
    message: 'Unrecognized key: "extraField"'
  },
  {
    expected: 'string',
    code: 'invalid_type',
    path: [ 'username' ],
    message: 'Invalid input: expected string, received number'
  },
  {
    origin: 'number',
    code: 'too_small',
    minimum: 0,
    inclusive: true,
    path: [ 'favoriteNumbers', 1 ],
    message: 'Too small: expected number to be >=0'
  }
]);
 
z.prettyError(myError);

The returns the following pretty-printable multi-line string:

✖ Unrecognized key: "extraField"
✖ Invalid input: expected string, received number, received number
  → at username
✖ Invalid input: expected number, received string, received string
  → at favoriteNumbers[1]

At the moment this isn't configurable; this may change in the future.

Top-level string formats

The following string formats have been hoisted to be top-level APIs on the z module. This is both more concise and more tree-shakable. The method equivalents (z.string().email(), etc.) are still available but have been deprecated.

z.email();
z.uuidv4();
z.uuidv7();
z.uuidv8();
z.ipv4();
z.ipv6();
z.e164();
z.base64();
z.jwt();
z.ascii();
z.utf8();
z.lowercase();
z.iso.date();
z.iso.datetime();
z.iso.duration();
z.iso.time();

The z.email() API now supports a custom regular expression. There is no one canonical email regex; different applications may choose to be more or less strict. For convenience Zod exports some common ones.

// Zod's default email regex (Gmail rules)
// see colinhacks.com/essays/reasonable-email-regex
z.email(); // z.regexes.email
 
// the regex used by browsers to validate input[type=email] fields
// https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input/email
z.email({ pattern: z.regexes.html5Email });
 
// the classic emailregex.com regex (RFC 5322)
z.email({ pattern: z.regexes.rfc5322Email });
 
// a loose regex that allows Unicode (good for intl emails)
z.email({ pattern: z.regexes.unicodeEmail });

Template literal types

Zod 4 implements z.templateLiteral(). Template literal types are perhaps the biggest feature of TypeScript's type system that wasn't previously representable.

const hello = z.templateLiteral(["hello, ", z.string()]);
// `hello, ${string}`
 
const cssUnits = z.enum(["px", "em", "rem", "%"]);
const css = z.templateLiteral([z.number(), cssUnits ]);
// `${number}px` | `${number}em` | `${number}rem` | `${number}%`
 
const email = z.templateLiteral([
  z.string().min(1),
  "@",
  z.string().max(64),
]);
// `${string}@${string}` (the min/max refinements are enforced!)

Every Zod schema type that can be stringified stores an internal regex: strings, string formats like z.email(), numbers, boolean, bigint, enums, literals, undefined/optional, null/nullable, and other template literals. The z.templateLiteral constructor concatenates these into a super-regex.

Read the template literal docs for more info.

Number formats

New numeric "formats" have been added for representing fixed-width integer types. These return a ZodNumber instance with proper minimum/maximum constraints already added.

z.int();      // [Number.MIN_SAFE_INTEGER, Number.MAX_SAFE_INTEGER],
z.float32();  // [-3.4028234663852886e38, 3.4028234663852886e38]
z.float64();  // [-1.7976931348623157e308, 1.7976931348623157e308]
z.int32();    // [-2147483648, 2147483647]
z.uint32();   // [0, 4294967295]

Similarly the following bigint numeric formats have also been added. These integer types exceed what can be safely represented by a number in JavaScript, so these return a ZodBigInt instance with the proper minimum/maximum constraints already added.

z.int64();    // [-9223372036854775808n, 9223372036854775807n]
z.uint64();   // [0n, 18446744073709551615n]

Stringbool

The existing z.coerce.boolean() API is very simple: falsy values (false, undefined, null, 0, "", NaN etc) become false, truthy values become true.

This is still a good API, and it's behavior aligns with the other z.coerce APIs. But some users requested a more sophisticated "env-style" boolean coercion. To support this, Zod 4 introduces z.stringbool():

const strbool = z.stringbool();
 
strbool.parse("true")         // => true
strbool.parse("1")            // => true
strbool.parse("yes")          // => true
strbool.parse("on")           // => true
strbool.parse("y")            // => true
strbool.parse("enable")       // => true
 
strbool.parse("false");       // => false
strbool.parse("0");           // => false
strbool.parse("no");          // => false
strbool.parse("off");         // => false
strbool.parse("n");           // => false
strbool.parse("disabled");    // => false
 
strbool.parse(/* anything else */); // ZodError<[{ code: "invalid_value" }]>

To customize the truthy and falsy values:

z.stringbool({
  truthy: ["yes", "true"],
  falsy: ["no", "false"]
})

Refer to the z.stringbool() docs for complete information.

Simplified error customization

The majority of breaking changes in Zod 4 revolve around the error customization APIs. They were a bit of a mess in Zod 3; Zod 4 makes things significantly more elegant, to the point where I think it's worth highlighting here.

Long story short, there is now a single, unified error parameter for customizing errors, replacing the following APIs.

Replace message with error. (The message parameter is still supported but deprecated.)

- z.string().min(5, { message: "Too short." });
+ z.string().min(5, { error: "Too short." });

Replace invalid_type_error and required_error with error (function syntax):

// Zod 3
- z.string({ 
-   required_error: "This field is required" 
-   invalid_type_error: "Not a string", 
- });
 
// Zod 4 
+ z.string({ error: (issue) => issue.input === undefined ? 
+  "This field is required" :
+  "Not a string" 
+ });

Replace errorMap with error (function syntax):

// Zod 3 
- z.string({
-   errorMap: (issue, ctx) => {
-     if (issue.code === "too_small") {
-       return { message: `Value must be >${issue.minimum}` };
-     }
-     return { message: ctx.defaultError };
-   },
- });
 
// Zod 4
+ z.string({
+   error: (issue) => {
+     if (issue.code === "too_small") {
+       return `Value must be >${issue.minimum}`
+     }
+   },
+ });

Upgraded z.discriminatedUnion()

Discriminated union support has improved in a couple ways. First, you no longer need to specify the discriminator key. Zod now has a robust way to identify the discriminator key automatically.

// in Zod 4:
const myUnion = z.discriminatedUnion([
  z.object({ type: z.literal("a"), a: z.string() }),
  z.object({ type: z.literal("b"), b: z.number() }),
]);
 
// in Zod 3:
const myUnion = z.discriminatedUnion("type", [
  z.object({ type: z.literal("a"), a: z.string() }),
  z.object({ type: z.literal("b"), b: z.number() }),
]);

If no shared discriminator key is found, Zod will throw an error at schema initialization time.

Discriminated unions schema now finally compose—you can use one discriminated union as a member of another. Zod determines the optimal discrimination strategy.

const BaseError = z.object({ status: z.literal("failed"), message: z.string() });
const MyErrors = z.discriminatedUnion([
  BaseError.extend({ code: z.literal(400) }),
  BaseError.extend({ code: z.literal(401) }),
  BaseError.extend({ code: z.literal(500) })
]);
 
const MyResult = z.discriminatedUnion([
  z.interface({ status: z.literal("success"), data: z.string() }),
  MyErrors
]);

Multiple values in z.literal()

The z.literal() API now optionally supports multiple values.

const httpCodes = z.literal([ 200, 201, 202, 204, 206, 207, 208, 226 ]);
 
// previously in Zod 3:
const httpCodes = z.union([
  z.literal(200),
  z.literal(201),
  z.literal(202),
  z.literal(204),
  z.literal(206),
  z.literal(207),
  z.literal(208),
  z.literal(226)
]);

Refinements live inside schemas

In Zod 3, they were stored in a ZodEffects class that wrapped the original schema. This was inconvenient, as it meant you couldn't interleave .refine() with other schema methods like .min().

z.string()
  .refine(val => val.includes("@"))
  .min(5);
// ^ ❌ Property 'min' does not exist on type ZodEffects<ZodString, string, string>

In Zod 4, refinements are stored inside the schemas themselves, so the code above works as expected.

.overwrite()

The .transform() method is extremely useful, but it has one major downside: the output type is no longer introspectable at runtime. The transform function is a black box that can return anything. This means (among other things) there's no sound way to convert the schema to JSON Schema.

const Squared = z.number().transform(val => val ** 2);
// => ZodPipe<ZodNumber, ZodTransform>

Zod 4 introduces a new .overwrite() method for representing transforms that don't modify the inferred type. Unlike .transform(), this method returns an instance of the original class. The overwrite function is stored as a refinement, so it doesn't (and can't) modify the inferred type.

z.number().overwrite(val => val ** 2);
// => ZodNumber

The existing .trim(), .toLowerCase() and .toUpperCase() methods have been reimplemented using .overwrite().

An extensible foundation: @zod/core

While this will not be relevant to the majority of Zod users, it's worth highlighting. The addition of @zod/mini necessitated the creation of a third package @zod/core that contains the core functionality shared between zod and @zod/mini.

I was resistant to this at first, but now I see it as one of Zod 4's most important features. It lets Zod level up from a simple library to a fast validation "substrate" that can be sprinkled into other libraries.

If you're building a schema library, refer to the implementations of zod and @zod/mini to see how to build on top of the foundation @zod/core provides. Don't hesistate to get in touch in GitHub discussions or via X/Bluesky for help or feedback.

Wrapping up

I'm planning to write up a series of additional posts explaining the design process and rationale behind some major features like @zod/mini and z.interface(). I'll update this section as those get posted.

Zod 4 will remain in beta for roughly 6 weeks as I work with library authors and major adopters to ensure a smooth day-one transition from Zod 3 to Zod 4. I encourage all users of Zod to upgrade their installation and provide feedback during the beta window.

pnpm upgrade zod@v4

Happy parsing!