Zod logo

Migration guide

To learn more about the performance enhancements and new features of Zod 4, read the introductory post.

This migration guide aims to list the breaking changes in Zod 4 in order of highest to lowest impact. Every effort was made to prevent breaking changes, but some are unavoidable.

Note — Zod 3 exported a large number of undocumented, internal utility types and functions that are not considered part of the public API. Changes to those are not documented here.

To install the beta:

npm upgrade zod@next

Error customization

This is perhaps the most visible of the removed APIs. Zod 4 standardizes the APIs for error customization under a single, unified error param.

deprecates message

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

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

drops invalid_type_error and required_error

The invalid_type_error / required_error params have been dropped. These were hastily added years ago as a way to customize errors that was less verbose than errorMap. They came with all sorts of footguns (they can't be used in conjunction with errorMap) and do not align with Zod's actual issue codes (there is no required issue code).

These can now be cleanly represented with the new error parameter.

z.string({ 
  error: (issue) => issue.input === undefined 
    ? "This field is required" 
    : "Not a string" 
});

drops errorMap

This is renamed to error. Error maps can also now return a string or undefined (which yields control to the next error map in the chain).

z.string({
  error: (issue) => {
    if (issue.code === "too_small") {
      return `Value must be >${issue.minimum}`
    }
  },
});

ZodError

no longer extends Error

It is very slow to instantiate Error instances in JavaScript, as the initialization process snapshots the call stack. The magnitude of this performance hit is too much to justify, and extending Error adds little value anyway. The .stack trace includes lots of Zod internals and can be confusing.

In Zod 4 the ZodError class no longer extends the plain JavaScript Error class. Instead, ZodError implements the Error interface. This means all error-handling code should still work with the exception of instanceof Error checks.

try {
  z.string().parse(data)
} catch(err) {
  if (err instanceof z.ZodError) {
    // handle ZodError
  }
  
  if (err instanceof Error) {
    // handle regular Error
  }
}

updates issue formats

The issue formats have been dramatically streamlined.

import * as z from "zod"; // v4
 
type IssueFormats = 
  | z.core.$ZodIssueInvalidType
  | z.core.$ZodIssueTooBig
  | z.core.$ZodIssueTooSmall
  | z.core.$ZodIssueInvalidStringFormat
  | z.core.$ZodIssueNotMultipleOf
  | z.core.$ZodIssueUnrecognizedKeys
  | z.core.$ZodIssueInvalidValue
  | z.core.$ZodIssueInvalidUnion
  | z.core.$ZodIssueInvalidKey // new: used for z.record/z.map 
  | z.core.$ZodIssueInvalidElement // new: used for z.map/z.set
  | z.core.$ZodIssueCustom;

Below is the list of Zod 3 issues types and their Zod 4 equivalent:

import * as z from "zod"; // v3
 
export type IssueFormats =
  | z.ZodInvalidTypeIssue // ♻️ renamed to z.core.$ZodIssueInvalidType
  | z.ZodTooBigIssue  // ♻️ renamed to z.core.$ZodIssueTooBig
  | z.ZodTooSmallIssue // ♻️ renamed to z.core.$ZodIssueTooSmall
  | z.ZodInvalidStringIssue // ♻️ z.core.$ZodIssueInvalidStringFormat
  | z.ZodNotMultipleOfIssue // ♻️ renamed to z.core.$ZodIssueNotMultipleOf
  | z.ZodUnrecognizedKeysIssue // ♻️ renamed to z.core.$ZodIssueUnrecognizedKeys
  | z.ZodInvalidUnionIssue // ♻️ renamed to z.core.$ZodIssueInvalidUnion
  | z.ZodCustomIssue // ♻️ renamed to z.core.$ZodIssueCustom
  | z.ZodInvalidEnumValueIssue // ❌ merged in z.core.$ZodIssueInvalidValue
  | z.ZodInvalidLiteralIssue // ❌ merged into z.core.$ZodIssueInvalidValue
  | z.ZodInvalidUnionDiscriminatorIssue // ❌ throws an Error at schema creation time
  | z.ZodInvalidArgumentsIssue // ❌ z.function throws ZodError directly
  | z.ZodInvalidReturnTypeIssue // ❌ z.function throws ZodError directly
  | z.ZodInvalidDateIssue // ❌ merged into invalid_type
  | z.ZodInvalidIntersectionTypesIssue // ❌ removed (throws regular Error)
  | z.ZodNotFiniteIssue // ❌ infinite values no longer accepted (invalid_type)

While certain Zod 4 issue types have been merged, dropped, and modified, each issue remains structurally similar to Zod 3 counterpart (identical, in most cases). All issues still conform to the same base interface as Zod 3, so most common error handling logic will work without modification.

export interface $ZodIssueBase {
  readonly code?: string;
  readonly input?: unknown;
  readonly path: PropertyKey[];
  readonly message: string;
}

changes error map precedence

The error map precedence has been changed to be more consistent. Specifically, an error map passed into .parse() no longer takes precedence over a schema-level error map.

const mySchema = z.string({ error: () => "Schema-level error" });
 
// in Zod 3
mySchema.parse(12, { error: () => "Contextual error" }); // => "Contextual error"
 
// in Zod 4
mySchema.parse(12, { error: () => "Contextual error" }); // => "Schema-level error"

deprecates .format()

The .format() method on ZodError has been deprecated. Instead use the top-level z.treeifyError() function. Read the Formatting errors docs for more information.

deprecates .flatten()

The .flatten() method on ZodError has also been deprecated. Instead use the top-level z.treeifyError() function. Read the Formatting errors docs for more information.

drops .formErrors

This API was identical to .flatten(). It exists for historical reasons and isn't documented.

deprecates .addIssue() and .addIssues()

Directly push to err.issues array instead, if necessary.

myError.issues.push({ 
  // new issue
});

z.number()

no infinite values

POSITIVE_INFINITY and NEGATIVE_INFINITY are no longer considered valid values for z.number().

.int() accepts safe integers only

The z.number().int() API no longer accepts unsafe integers (outside the range of Number.MIN_SAFE_INTEGER and Number.MAX_SAFE_INTEGER). Using integers out of this range causes spontaneous rounding errors.

z.string() updates

deprecates .email() etc

String formats are now represented as subclasses of ZodString, instead of simple internal refinements. As such, these APIs have been moved to the top-level z namespace. Top-level APIs are also less verbose and more tree-shakable.

z.email();
z.uuid();
z.url();
z.emoji();         // validates a single emoji character
z.base64();
z.base64url();
z.nanoid();
z.cuid();
z.cuid2();
z.ulid();
z.ipv4();
z.ipv6();
z.cidr();          // ip range
z.iso.date();
z.iso.time();
z.iso.datetime();
z.iso.duration();

The method forms (z.string().email()) still exist and work as before, but are now deprecated.

z.string().email(); // ❌ deprecated
z.email(); // ✅ 

drops z.string().ip()

This has been replaced with separate .ipv4() and .ipv6() methods. Use z.union() to combine them if you need to accept both.

z.string().ip() // ❌
z.string().ipv4() // ✅
z.string().ipv6() // ✅

updates z.string().ipv6()

Validation now happens using the new URL() constructor, which is far more robust than the old regular expression approach. Some invalid values that passed validation previously may now fail.

drops z.string().cidr()

Similarly, this has been replaced with separate .cidrv4() and .cidrv6() methods. Use z.union() to combine them if you need to accept both.

z.string().cidr() // ❌
z.string().cidrv4() // ✅
z.string().cidrv6() // ✅

z.coerce updates

The input type of all coerced booleans is now unknown.

const schema = z.coerce.string();
type schemaInput = z.input<typeof schema>;
 
// Zod 3: string;
// Zod 4: unknown;

z.object()

These modifier methods on the ZodObject class determine how the schema handles unknown keys. In Zod 4, this functionality now exists in top-level functions. This aligns better with Zod's declarative-first philosophy, and puts all object variants on equal footing.

deprecates .strict()

// Zod 3
z.object({ name: z.string() }).strict();
 
// Zod 4
z.strictObject({ name: z.string() });

deprecates .passthrough()

// Zod 3
z.object({ name: z.string() }).passthrough();
 
// Zod 4
z.looseObject({ name: z.string() });

deprecates .strip()

This was never particularly useful, as it was the default behavior of z.object().

The equivalents for z.interface() also exist. Read more about z.interface() here.

z.interface({ name: z.string() });
z.looseInterface({ name: z.string() });
z.strictInterface({ name: z.string() });

drops .nonstrict()

This long-deprecated alias for .strip() has been removed.

drops .deepPartial()

This has been long deprecated in Zod 3 and it now removed in Zod 4. There is no direct alternative to this API. There were lots of footguns in its implementation, and its use is generally an anti-pattern.

changes z.unknown() optionality

The z.unknown() and z.any() types are no longer marked as "key optional" in the inferred types.

const mySchema = z.object({
  a: z.any(),
  b: z.unknown()
});
// Zod 3: { a?: any; b?: unknown };
// Zod 4: { a: any; b: unknown };

z.nativeEnum() deprecated

The z.nativeEnum() method is deprecated in favor of just z.enum(). The z.enum() API has been overloaded to support both.

enum Color {
  Red = "red",
  Green = "green",
  Blue = "blue",
}
 
const ColorSchema = z.enum(Color); // ✅

As part of this refactor of ZodEnum, a number of long-deprecated and redundunant features have been removed. These were all identical and only existed for historical reasons.

ColorSchema.enum.Red; // ✅ => "Red" (canonical API)
ColorSchema.Enum.Red; // ❌ removed
ColorSchema.Values.Red; // ❌ removed

z.array()

changes .nonempty() type

This now behaves identically to z.array().min(1). The inferred type does not change.

const NonEmpty = z.array(z.string()).nonempty();
 
type NonEmpty = z.infer<typeof NonEmpty>; 
// Zod 3: [string, string[]]
// Zod 4: string[]

The old behavior is now better represented with z.tuple() and a "rest" argument. This aligns more closely to TypeScript's type system.

z.tuple([z.string()], z.string());
// => [string, string[]]

z.promise() deprecated

There's rarely a reason to use z.promise(). If you have an input that may be a Promise, just await it before parsing it with Zod.

If you are using z.promise to define an async function with z.function(), that's no longer necessary either; see the ZodFunction section below.

z.function()

The result of z.function() is no longer a Zod schema. Instead, it acts as a standalone "function factory" for defining Zod-validated functions. The API has also changed; you define an input and output schema upfront, instead of using args() and .returns() methods.

const myFunction = z.function({
  input: [z.object({
    name: z.string(),
    age: z.number().int(),
  })],
  output: z.string(),
});
 
myFunction.implement((input) => {
  return `Hello ${input.name}, you are ${input.age} years old.`;
});

adds .implementAsync()

To define an async function, use implementAsync() instead of implement().

myFunction.implementAsync(async (input) => {
  return `Hello ${input.name}, you are ${input.age} years old.`;
});

.refine()

ignores type predicates

In Zod 3, passing a type predicate as a refinement functions could still narrow the type of a schema. This wasn't documented but was discussd in some issues. This is no longer the case.

const mySchema = z.unknown().refine((val): val is string => {
  return typeof val === "string"
});
 
type MySchema = z.infer<typeof mySchema>; 
// Zod 3: `string`
// Zod 4: still `unknown`

z.ostring(), etc dropped

The undocumented convenience methods z.ostring(), z.onumber(), etc. have been removed. These were shorthand methods for defining optional string schemas.

z.literal() drops symbol support

Symbols aren't considered literal values, nor can they be simply compared with ===. This was an oversight in Zod 3.

.create() factories dropped

Previously all Zod classes defined a static .create() method. These are now implemented as standalone factory functions.

z.ZodString.create(); // ❌ 

z.discriminatedUnion()

You no longer need to specify a discriminator key (though you still can if you wish; it is ignored).

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() }),
]);

z.record()

drops single argument usage

Before, z.record() could be used with a single argument. This is no longer supported.

// Zod 3
z.record(z.string()); // ✅
 
// Zod 4
z.record(z.string()); // ❌
z.record(z.string(), z.string()); // ✅

improves enum support

Records have gotten a lot smarter. In Zod 3, passing an enum into z.record() as a key schema would result in a partial type

const myRecord = z.record(z.enum(["a", "b", "c"]), z.number()); 
// { a?: number; b?: number; c?: number; }

In Zod 4, this is no longer the case. The inferred type is what you'd expect, and Zod ensures exhaustiveness; that is, it makes sure all enum keys exist in the input during parsing.

const myRecord = z.record(z.enum(["a", "b", "c"]), z.number());
// { a: number; b: number; c: number; }

z.intersection()

throws Error on merge conflict

Zod intersection parses the input against two schemas, then attempts to merge the results. In Zod 3, when the results were unmergable, Zod threw a ZodError with a special "invalid_intersection_types" issue.

In Zod 4, this will throw a regular Error instead. The existence of unmergable results indicates a structural problem with the schema: an intersection of two incompatible types. Thus, a regular error is more appropriate than a validation error.

Internal changes

The typical user of Zod can likely ignore everything below this line. These changes do not impact the user-facing z APIs.

There are too many internal changes to list here, but some may be relevant to regular users who are (intentionally or not) relying on certain implementation details. These changes will be of particular interest to library authors building tools on top of Zod.

updates generics

The generic structure of several classes has changed. Perhaps most significant is the change to the ZodType base class:

// Zod 3
class ZodType<Output, Def extends z.ZodTypeDef, Input = Output> {
  // ...
}
 
// Zod 4
class ZodType<Output = unknown, Input = unknown> {
  // ...
}

The second generic Def has been entirely removed. Instead the base class now only tracks Output and Input. While previously the Input value defaulted to Output, it now defaults to unknown. This allows generic functions involving z.ZodType to behave more intuitively in many cases.

function inferSchema<T extends z.ZodType>(schema: T): T {
  return schema;
};
 
inferSchema(z.string()); // z.ZodString

The need for z.ZodTypeAny has been eliminated; just use z.ZodType instead.

adds z.core

Many utility functions and types have been moved to the new @zod/core package, to facilitate code sharing between zod and @zod/mini. The contents of @zod/core from zod/@zod/mini using the z.core namespace. Check z.core if any internal APIs you rely on are missing; they've likely been moved there.

import * as z from "zod";
 
function handleError(iss: z.core.utils.$ZodError) {
  // do stuff
}

moves ._def

The ._def property is now moved to ._zod.def. The structure of all internal defs is subject to change; this is relevant to library authors but won't be comprehensively documented here.

drops ZodEffects

This doesn't affect the user-facing APIs, but it's an internal change worth highlighting. It's part of a larger restructure of how Zod handles refinements.

Previously both refinements and transformations lived inside a wrapper class called ZodEffects. That means adding either one to a schema would wrap the original schema in a ZodEffects instance. In Zod 4, refinements now live inside the schemas themselves. More accurately, each schema contains an array of "checks"; the concept of a "check" is new in Zod 4 and generalizes the concept of a refinement to include potentially side-effectful transforms like z.toLowerCase().

This is particularly apparent in the @zod/mini API, which heavily relies on the .check() method to compose various validations together.

import * as z from "@zod/mini";
 
z.string().check(
  z.minLength(10),
  z.maxLength(100),
  z.toLowerCase(),
  z.trim(),
);

adds ZodTransform

Meanwhile, transforms have been moved into a dedicated ZodTransform class. This schema class represents an input transform; in fact, you can actually define standalone transformations now:

import * as z from "zod";
 
const schema = z.transform(input => String(input));
 
schema.parse(12); // => "12"

This is primarily used in conjunction with ZodPipe. The .transform() method now returns an instance of ZodPipe.

z.string().transform(val => val); // ZodPipe<ZodString, ZodTransform>

drops ZodPreprocess

As with .transform(), the z.prepreprocess() function now returns a ZodPipe instance instead of a dedicated ZodPreprocess instance.

z.preprocess(val => val, z.string()); // ZodPipe<ZodTransform, ZodString>

drops ZodBranded

Branding is now handled with a direct modification to the inferred type, instead of a dedicated ZodBranded class. The user-facing APIs remain the same.