Back to blog
Guides8 min read

Zod 4 migration guide: what changed from Zod 3, in order

A Zod 4 migration guide: which Zod 3 spellings are deprecated, which are unchanged, and the order to change them in on a codebase already shipping.

Most of a Zod 4 migration is nothing

The surprising thing about a Zod 4 migration is how little of a large schema file has to change. .min(), .max(), .regex(), .describe(), .optional(), .nullable(), .default(), z.enum, z.literal, z.array, z.object and z.infer all mean in Zod 4 exactly what they meant in Zod 3. If your schemas are mostly objects of constrained fields — and most schemas are — the diff is a handful of lines, not a rewrite.

What did change clusters into four places: string formats, object modes, JSON Schema, and error reading. Everything below walks those four, says whether the old spelling is deprecated or actually gone, and then gives an order to do the work in. If the modifiers themselves are the part you are unsure about — what .optional() costs you that .nullable() does not — that is the prerequisite, and it is covered in optional vs nullable vs default, and what z.infer derives.

Deprecated is not removed

This is the single most useful fact about Zod 3 to Zod 4, and it is the one most write-ups blur. The Zod 3 spellings of the changed APIs are deprecated in Zod 4, not deleted. z.string().email() still parses. z.object({}).strict() still rejects unknown keys. Your editor will strike them through and your build will not break.

That matters for planning. A Zod 4 migration is not a flag day where the app is down until every file is converted. You can upgrade the dependency, fix the genuinely breaking parts in an afternoon, and let the deprecated spellings age out file by file. The real breakage is confined to the error-reading APIs and some error-customization options, which get their own section below.

String formats moved to the top level

In Zod 3, a formatted string was a method chained onto z.string(). In Zod 4 each format is its own schema function:

// Zod 4
email: z.email(),
id: z.uuid(),
site: z.url(),
createdAt: z.iso.datetime(),

// Zod 3 (deprecated in 4, still works)
email: z.string().email(),
id: z.string().uuid(),
site: z.string().url(),
createdAt: z.string().datetime(),

The top-level forms return string schemas, so everything you already chain still chains. z.email().max(254).describe("Billing contact.").optional() is valid and reads the same way the Zod 3 version did. ISO formats live under the z.iso namespace — z.iso.datetime(), z.iso.date(), z.iso.time() — because the date-and-time family is large enough to deserve one.

This whole category is cosmetic. The parsers behave the same, the inferred types are the same, and nothing in your app changes when you convert a line. Which is why it belongs late in the migration order.

Object modes are functions now

Zod objects strip unknown keys by default in both majors. The two non-default modes are what moved:

// Zod 4
z.strictObject({ … })   // an unknown key is a parse error
z.looseObject({ … })    // unknown keys are kept in the output

// Zod 3 (deprecated in 4, still works)
z.object({ … }).strict()
z.object({ … }).passthrough()

Pick strict for anything where an unexpected key is more likely a typo than a feature — config files, internal payloads you control both ends of. Pick loose for an envelope you are passing through without owning its contents. The default strip is right for most public API bodies, since a client sending an extra field should not be a 400.

The conversion is mechanical but it moves the call site, so a find-and-replace has to handle the wrapping rather than just the suffix. Do this one by hand, or with a codemod, not with a regex.

Records take two arguments

z.record in Zod 4 requires both the key schema and the value schema:

counts: z.record(z.string(), z.number())

The single-argument z.record(z.number()) was valid Zod 3 shorthand. The two-argument form is valid in both majors, so it is the one to write today even if you have not upgraded yet — it is the rare change you can make before the migration rather than during it.

JSON Schema is built in

Zod 4 ships z.toJSONSchema(). In Zod 3 this needed a third-party package:

z.toJSONSchema(userSchema)
// { type: "object", properties: { email: { type: "string", format: "email" } }, … }

If you are converting Zod schemas to JSON Schema anywhere, this is the change with the highest payoff per line, because it deletes a dependency. Field descriptions from .describe() survive the conversion, which is the reason to have written them.

That path matters more than it used to. A Zod object is what a model reads before it calls a tool, so the schema is the interface — the same shape that becomes a tool's inputSchema when you are building a custom MCP server with the TypeScript SDK. A description your migration drops is a field the model then has to guess at.

Error reading is the part that actually breaks

.parse() still throws a ZodError and .safeParse() still returns { success, data | error }. What changed is how you turn that error into something a human or a form can read. Zod 4 provides top-level functions:

const result = userSchema.safeParse(body)
if (!result.success) {
  console.error(z.prettifyError(result.error))
  return Response.json({ errors: z.flattenError(result.error) }, { status: 400 })
}
  • z.prettifyError(error) — a multi-line human-readable string. Logs, CLI output, anything a developer reads.
  • z.flattenError(error) — form errors keyed by field name. The usual shape for a form UI with one message per input.
  • z.treeifyError(error) — a nested tree that mirrors the schema. What you want when the schema has nested objects and a flat key list would lose the path.

These replace the error.format() and error.flatten() methods from Zod 3. Error customization also consolidated: Zod 3's several message options collapse toward a single error parameter in Zod 4, though passing message still works.

Treat this section as the real work. Everything else on this page is a spelling; this is the one where an unconverted call site can change what your API returns to a user. Grep for .format() and .flatten() on a Zod error before you grep for anything else.

The order to do it in

On a codebase of any size, do the migration in this sequence. Each step is independently shippable, which is the point.

  1. Write the two-argument z.record first, while still on Zod 3. It is valid in both, so it costs you nothing and removes one item from the post-upgrade list.
  2. Upgrade the dependency and run a full typecheck. The Zod 4 package still exports the previous major under the zod/v3 subpath, so a very large codebase can import both side by side during the transition — leave untouched modules on import { z } from "zod/v3" and convert the rest module by module, rather than all at once.
  3. Fix the error-reading call sites. .format() and .flatten() on a ZodError become z.treeifyError and z.flattenError, and any error-message options that no longer typecheck become error. This is the step with user-visible behavior attached, so it gets its own commit and its own test pass.
  4. Delete the JSON Schema dependency if you had one, and swap it for z.toJSONSchema.
  5. Convert object modes. .strict() and .passthrough() to z.strictObject and z.looseObject, one file at a time.
  6. Convert string formats last, or never in one go. This is the largest count of lines and the smallest amount of risk. Let the editor's strikethrough drive it — convert the formats in a file the next time you have that file open for another reason.

Steps 1 through 4 are a day at most on a typical app. Steps 5 and 6 are housekeeping, and treating them as blockers is what makes a Zod 4 migration feel bigger than it is.

Zod 4 vs Zod 3, as a diff

Zod 4 first, Zod 3 second.

  • Emailz.email() · z.string().email()
  • UUIDz.uuid() · z.string().uuid()
  • URLz.url() · z.string().url()
  • ISO datetimez.iso.datetime() · z.string().datetime()
  • Reject unknown keysz.strictObject({}) · z.object({}).strict()
  • Keep unknown keysz.looseObject({}) · z.object({}).passthrough()
  • Recordz.record(k, v) · z.record(v), though z.record(k, v) works in 3 too
  • JSON Schemaz.toJSONSchema(s) · a third-party package
  • Readable errorsz.prettifyError(e) · e.format() / e.flatten()

Unchanged in both: .min, .max, .regex, .describe, .optional, .nullable, .default, .nullish, z.enum, z.literal, z.array, z.object, z.infer, .parse and .safeParse.

Seeing both spellings side by side

The fastest way to internalise the diff is to look at the same schema written twice. The free Zod schema builder has a Zod 3 / Zod 4 switch that flips exactly the lines on the list above and leaves everything else alone. Build an object with a few formats, a strict root and a record, then toggle the version — the lines that change are the ones your migration touches, and the lines that stay put are the ones you can leave alone with a clear conscience.

It runs in the browser, needs no account, and emits the schema module with z.infer written out beside it. If the schema is destined for a model rather than a form, the MCP server generator takes the same shape as a tool's input and output schema.

Starting a new project? Start on Zod 4. Its top-level formats and error helpers are what future documentation will assume, and starting there means never scheduling the migration at all.

Q&A

Question: Is Zod 4 a breaking change from Zod 3?

Short answer: Partly. The changed spellings — z.string().email(), .strict(), .passthrough() — are deprecated in Zod 4 rather than removed, so they still parse and your build will not fail on them. The genuinely breaking parts are the error-reading APIs, where error.format() and error.flatten() give way to z.treeifyError and z.flattenError, plus some error-customization options.

Question: What are the main Zod 4 breaking changes to look for first?

Short answer: Grep for .format() and .flatten() called on a ZodError, and for error-message options that no longer typecheck. Those are the call sites with user-visible behavior attached. String formats and object modes can wait, because the Zod 3 spellings keep working.

Question: Do I have to migrate all my schemas at once?

Short answer: No, and you should not. Deprecated spellings coexist with new ones in the same file, so a Zod 4 migration can proceed file by file over weeks. The Zod 4 package also keeps the previous major available at the zod/v3 subpath, which lets a very large codebase import both side by side while it converts.

Question: What is the difference between z.strictObject and .strict()?

Short answer: Nothing, behaviorally — both reject input containing keys the schema does not declare. z.strictObject({ … }) is the Zod 4 spelling and z.object({ … }).strict() is the deprecated Zod 3 one. The same pairing applies to z.looseObject and .passthrough(), which keep unknown keys instead of dropping them.

Question: Which Zod APIs are identical in Zod 3 and Zod 4?

Short answer: The ones you use most. .min, .max, .regex, .describe, .optional, .nullable, .default, .nullish, z.enum, z.literal, z.array, z.object, z.infer, .parse and .safeParse all behave the same way in both majors. That is why the diff on a typical schema file is a handful of lines.

Target-specific scaffolding

Continue this learning path

Permissions, manifests, capabilities, agent projects and MCP servers for browser extensions, desktop, mobile, and backend agents.

Explore platform generators

Design your application

Design Web, Desktop, Mobile, Terminal, or Extension projects with a target-aware canvas and exporter.

Open projects

We'd like to use Google cookies to understand how Nodlume is used and to measure our advertising. Nothing loads until you choose, and declining does not affect anything in the app.