# zod — corrections from the Stale Priors Index

<!--
     Stale Priors Index · Unattended Works · https://stalepriors.com/rules/zod.md

     The Stale Priors Index, Unattended Works (https://stalepriors.com/rules/zod.md), CC BY 4.0. Retrieved 2026-09-11.
     Licence: https://creativecommons.org/licenses/by/4.0/  ·  SPDX-License-Identifier: CC-BY-4.0
     Quotations from third-party changelogs remain their authors'; nothing here grants any
     right in the libraries described. Full map: https://stalepriors.com/data/LICENSE

     Changed since the 2026-09-11 sweep: 10 of the 46 entries below, newest 2026-09-28
     — a change to this record, not a re-check against a source. The note below says why.

     GENERATED FILE — DO NOT EDIT BY HAND. Your edit will be destroyed by the next build.
     Rebuild:  node tools/build-rules.mjs
     Source:   data/zod/facts.json
-->

For projects on **zod@^4**. 46 corrections, 44 of them dated to the release that changed the behaviour; every entry links the primary source it was verified against. Latest zod: 4.6.2, verified 2026-09-11. Do **not** use this if you are pinned to zod@3.

**This is the statements only** — no reproduced model failures, no code, no evidence. The full pack has all three: https://stalepriors.com/corrections/zod.md

**Two dates, and they answer different questions.** The entries below were last checked against their primary sources on 2026-09-11 — that date is the floor, and it is the only verification claim this file makes. Separately, 10 of the 46 entries have had their own text change since that sweep, the newest on 2026-09-28. A text change is a change to the record — a re-scored severity, a corrected release date, a clarified note — and it is **not** a re-check against the source. Do not read the later date as the fresher of two checks; per-entry dates are in `substance_changed_on` in the JSON.

**What was measured, and what this file is not.** The benchmark run on this library measured zod × Claude Opus 5, tasks drawn 2026-09-07, cells recorded 2026-09-07: the **full zod correction pack**, 51,247 characters of it — terminal pass rate 24/24 without it against 23/23 with it, first attempt 22/24 against 23/23, and a cost that takes 28.5 tasks in one session to repay, counted in this run's mix, where 12 of the bare arm's 36 cells were null tasks. **This file is a subset of that pack and no measurement covers it.** Every cell: https://stalepriors.com/benchmark

The corrections below are 40,329 characters. Read that as a cost: a rules file is added to requests, so it is a decision to make rather than a default to accept.

## Breaks the build, or throws at runtime

### `.merge() on a schema with refinements`

**Now throws in zod 4.4.0** (2026-04-29).

`.merge()` throws when the receiver has refinements. Prefer `.extend()` / `.safeExtend()` for object composition; `.merge()` is discouraged for new code regardless.

*The stale belief:* That `.merge()` is the normal way to combine two object schemas.

*Verified against:* [Zod 4.4.0 release notes](https://github.com/colinhacks/zod/releases/tag/v4.4.0)

### `.pick() / .omit() / .partial() on a schema with refinements`

**Now throws in zod 4.3.0** (2025-12-31).

`.pick()`, `.omit()` and `.partial()` throw when called on an object schema that carries a refinement. Each throws its own message, naming the method it was called as: `.partial() cannot be used on object schemas containing refinements`. None of the three silently drops the refinement any more. Rebuild from the shape instead: `z.object(schema.shape).pick({ ... })`, `z.object(schema.shape).partial()`. `.required()` is the one derivation in this family that went the other way at the same release — it now keeps the refinement (LF45).

*The stale belief:* That `.pick()`, `.omit()` or `.partial()` on a refined schema succeeds and quietly discards the refinement — true up to 4.2.1.

*Verified against:* [Zod 4.3.0 release notes](https://github.com/colinhacks/zod/releases/tag/v4.3.0)

### `.pick() / .omit() with a key that does not exist`

**Stricter in zod 4.3.0** (2025-12-31).

Object masking methods validate that the keys you pass actually exist on the schema. There are two checks with two different histories, and the fact previously described only one of them. At RUNTIME an unknown mask key throws `Error: Unrecognized key: "<key>"` on every 4.x release tested, 4.0.0 through 4.5.4 — it is never silently ignored. What changed at 4.0.17 is only WHEN: on 4.0.0 the `.pick()` / `.omit()` call itself throws, and from 4.0.17 the call returns a schema and the throw fires the first time that schema is used — reading `.shape`, or `.parse()`, or `.safeParse()`, which throws rather than returning `{ success: false }` because this is a construction error and not a validation failure. What 4.3.0 adds is the TYPE check: the parameter gains `& Record<Exclude<keyof M, keyof Shape>, never>`, so from 4.3.0 the same code also fails to compile. Both mask forms are affected identically — an inline object literal errors TS2322 and a mask held in a variable TS2345 — and both compile clean on every release below 4.3.0.

*The stale belief:* That a mask key which is not in the shape is silently ignored. It is not, on any 4.x release: the schema throws `Unrecognized key` as soon as it is used, and `safeParse()` throws with it. From 4.3.0 the code additionally fails to compile.

*Verified against:* [Zod 4.3.0 release notes](https://github.com/colinhacks/zod/releases/tag/v4.3.0)

### `.extend() overwriting a property on a refined schema`

**Now throws in zod 4.1.0** (2025-08-23).

`.extend()` throws when it overwrites an existing property on a schema that has refinements. Use `.safeExtend()`, which preserves the refinement and statically prevents you from changing a property's type signature. On 4.1.0 through 4.3.0 the throw is wider: ANY `.extend()` of a refined schema throws, even one that only adds new keys, so on those releases use `.safeExtend()` for every extension. From 4.3.1 an `.extend()` that only adds keys is allowed and keeps the refinement.

*The stale belief:* That `.extend()` may freely overwrite properties on any object schema.

*Verified against:* [Zod 4.3.0 release notes](https://github.com/colinhacks/zod/releases/tag/v4.3.0)

### `z.record()`

**Removed in zod 4.0.0** (2025-07-09).

Write `z.record(keySchema, valueSchema)`. It is the only form that works across the whole v4 line. Zod 4.0 removed the v3 single-argument form; 4.4.0 restored it **at runtime only** — the type declaration was never given a one-argument overload, so `z.record(z.number())` still fails to compile on every v4 release including 4.5.4 (`TS2554: Expected 2-3 arguments, but got 1`). In JavaScript, or with the type error suppressed, the single-argument call behaves differently either side of 4.4.0: on 4.0.0–4.3.6 it constructs and then rejects every key with `invalid_key`, and from 4.4.0 it validates values as the v3 form did.

*The stale belief:* That `z.record(z.number())` — the Zod 3 single-argument form — is current. Or, after reading the 4.4.0 release note, that it is current again.

*Verified against:* [Zod 4 changelog — Record Schema Changes](https://zod.dev/v4/changelog)

## Runs, but is silently wrong

### `safeParse() result.error: when error maps run`

**Behaviour changed in zod 4.6.0** (2026-09-09).

From 4.6.0, `.safeParse()`, `.safeParseAsync()` and `z.safeParse()` build a failed result's `error` on its first read, and every error map runs then. That covers the global `customError`, the locale set with `z.config()`, and a schema's own `error` option. On 4.0.0 through 4.5.x they ran at parse time. So code that changes `z.config()` between the parse and the read, such as a per-request locale in a server, gets the configuration that is live at the read in 4.6. Before 4.6 it got the one that was live at the parse. An error map with a side effect (a counter, a log line, a metric) does not run at all in 4.6 if nothing reads `result.error`, and it runs once however often the error is read. `.parse()` and `.parseAsync()` are unaffected: they build and throw the error at the call on every 4.x release. Destructuring the result at the call (`const { success, error } = schema.safeParse(x)`) reads `error` immediately, because it is an own enumerable getter, so that pattern gets the parse-time message on every 4.x release, 4.6 included. `result.error` is still a `ZodError`, and it is the same object on every read.

*The stale belief:* That a `safeParse` error's messages are fixed when `safeParse` returns, and that an error map runs once per failed parse. From 4.6.0 both happen at the first read of `result.error`, against whatever configuration is live then, and not at all if the error is never read.

*Verified against:* [Zod 4.6.0 release notes](https://github.com/colinhacks/zod/releases/tag/v4.6.0)

### `schema metadata members .format / .minLength / .maxLength / .minValue / .maxValue / .isInt / .minDate / .maxDate`

**Behaviour changed in zod 4.6.0** (2026-09-09).

From 4.6.0 eight members of a Zod Classic schema are prototype getters computed from its checks: `.format`, `.minLength`, `.maxLength`, `.minValue`, `.maxValue`, `.isInt`, `.minDate` and `.maxDate`. Each becomes an own property only when it is first read. Until then its key is missing from `Object.keys()`, a spread and `JSON.stringify()`, and `Object.assign({}, schema)` copies only the members already read. Deleting one restores the getter, and the next read recomputes it. On 4.0 through 4.5 they were own properties written at construction in chain order, so a value could be wrong where a format check followed a bound. `z.number().min(0).max(23).int().minValue` was `-9007199254740991`, and zod 3 and 4.6 both say `0`. The same chain with `.check(z.int32())` gave `-2147483648`, and `z.string().min(8).length(5).minLength` was `5`, where 4.6 says `8`. From 4.6.0 each value is the tightest bound, from the same fold `z.toJSONSchema()` uses. Read a member directly by name, never by enumerating the schema. On a pin below 4.6.0, chain the format check before the bounds.

*The stale belief:* That a schema's `.minValue`, `.maxValue` or `.minLength` reports the bound it enforces, and that these are ordinary own properties you can enumerate. On 4.0 through 4.5 a format check chained after a bound overwrote it with the format's range. From 4.6.0 the members are lazy getters that no enumeration sees until they are read.

*Verified against:* [Zod 4.6.0 release notes](https://github.com/colinhacks/zod/releases/tag/v4.6.0)

### `z.base64() / z.base64url() inside z.templateLiteral(); z.regexes.base64url; z.toJSONSchema(z.base64url())`

**Behaviour changed in zod 4.6.0** (2026-09-09).

From 4.6.0 a `z.base64()` inside `z.templateLiteral()` checks the base64 alphabet and at most two trailing `=`, and not the length. `z.templateLiteral(["tok_", z.base64()])` accepts `"tok_AAAA"` and rejects `"AAAA"`, and it also accepts `"tok_A"` and `"tok_AAA"`, although `z.base64()` on its own rejects `"A"` and `"AAA"`. On 4.0 through 4.5 the composed pattern was broken. `z.base64()`'s pattern began with `^$|`, and composition leaked that alternation, so the same template literal rejected every value with a payload and accepted a bare payload with the prefix missing (`"AAAA"`) and the prefix alone (`"tok_"`). A second base64 member broke the same way. Where `z.base64()` came first, as in `[z.base64(), "-x"]`, the value was checked and the length was enforced. `z.base64()` and `z.base64url()` used directly did not change, and a `z.base64url()` member is length-lax on every 4.x release. Also from 4.6.0, `z.regexes.base64url` and the `pattern` that `z.toJSONSchema(z.base64url())` emits reject a length of 1 mod 4 (`"A"`, `"AAAAA"`), as `z.base64url()` always has. On 4.0 through 4.5 the emitted pattern accepted them. To check a base64 payload inside a template literal on any 4.x release, refine the value with `z.base64()` itself.

*The stale belief:* That `z.templateLiteral(["tok_", z.base64()])` accepts `tok_` followed by valid base64 and nothing else. On 4.0 through 4.5 it rejected every such value and accepted a bare payload with no prefix. From 4.6.0 it accepts the prefixed value but also accepts a payload of the wrong length.

*Verified against:* [Zod 4.6.0 release notes](https://github.com/colinhacks/zod/releases/tag/v4.6.0)

### `z.email() pattern: z.regexes.email / issue.pattern / z.toJSONSchema() / inside z.templateLiteral()`

**Behaviour changed in zod 4.6.0** (2026-09-09).

From 4.6.0 the pattern behind `z.email()` has no lookaheads and no capture groups. Every copy of it changed: `z.regexes.email`, the `pattern` on a failed `z.email()` issue, and the `pattern` that `z.toJSONSchema()` emits. What `z.email()` accepts did not change. Do not read the parts of an address out of `z.regexes.email`'s groups. From 4.6.0 there are none, and on 4.0 through 4.5 the two groups held fragments: `"first.las"` and `"example."` for `first.last@example.com`, never the local part and the domain. Validate with `z.email()` and split the string at its last `@`. In a template literal the email segment no longer reaches past itself. On 4.0 through 4.5 its no-consecutive-dots lookahead scanned the rest of the string, so `z.templateLiteral([z.email(), "|", z.string()])` rejected `"a@b.cc|a..b"`. From 4.6.0 it accepts it. The email segment still rejects `a..b@c.dd` on every release.

*The stale belief:* That `z.regexes.email` captures the local part and the domain, so `exec()` can split an address. On 4.0 through 4.5 its two groups held fragments of the address, and from 4.6.0 it has no groups. Also, that composing `z.email()` into a template literal constrains only the email segment. Below 4.6.0 its lookahead also rejected `..` anywhere after it.

*Verified against:* [Zod 4.6.0 release notes](https://github.com/colinhacks/zod/releases/tag/v4.6.0)

### `z.emoji() / z.string().emoji()`

**Stricter in zod 4.6.0** (2026-09-09).

From 4.6.0, `z.emoji()` and `z.string().emoji()` reject a string made only of Unicode `Emoji_Component` code points. That covers `"123"`, `"#"`, `"*"`, and a lone zero-width joiner, variation selector (U+FE0F) or skin-tone modifier. The pattern now requires at least one pictograph, regional indicator or keycap. All six were accepted on every earlier release the ladder reaches, from 3.22.4 through 4.5.x, so a field validated with `.emoji()` accepted a plain number. Pictographs, keycaps (`1️⃣`), flags, subdivision flags, skin-tone-modified emoji and ZWJ sequences are accepted on every release, 4.6 included. The check has never meant "only emoji". `"😀1"` and `"1😀"` pass on every release, 4.6 included, because digits are components and one pictograph anchors the string. On a pin below 4.6.0, `.emoji()` does not stop a digit string. After the upgrade, stored values that used to pass may fail.

*The stale belief:* That `.emoji()` has always rejected strings with no emoji in them. Below 4.6.0 it accepted `"123"`, `"#"`, `"*"` and lone joiners, selectors and skin-tone modifiers. Only 4.6.0 requires a pictograph, regional indicator or keycap.

*Verified against:* [Zod 4.6.0 release notes](https://github.com/colinhacks/zod/releases/tag/v4.6.0)

### `z.enum() / z.nativeEnum() over a numeric TypeScript enum: .options`

**Behaviour changed in zod 4.6.0** (2026-09-09).

From 4.6.0, `.options` on a `z.enum()` (or the deprecated `z.nativeEnum()`) built from a numeric TypeScript enum lists the enum's values only. `enum Country { UK, Germany, France }` gives `[0, 1, 2]`. On 4.0.0 through 4.5.x it listed every value of the compiled enum object, reverse mappings included, so it gave `["UK", "Germany", "France", 0, 1, 2]`, and the schema itself rejects the three names. Code that iterates `.options` to build a select list, a lookup table or a second schema gets three fewer entries after the upgrade, and on a 4.0 to 4.5 pin it gets entries that do not parse. Nothing else about the schema moved. The parser accepted exactly the values on every 4.x release, `z.toJSONSchema()` emits `enum: [0, 1, 2]` on every one of them, and `.enum` still returns the enum object with its reverse mappings in 4.6. A string enum is unchanged. An enum that mixes string and numeric members loses only the numeric members' reverse keys: `{ A = "a", B = 1 }` goes from `["B", "a", 1]` to `["a", 1]`.

*The stale belief:* That `.options` of a numeric-enum schema is the same list on every 4.x release. On 4.0.0 to 4.5.x it includes the member names from the enum's reverse mapping, which the schema rejects. From 4.6.0 it holds only the values.

*Verified against:* [Zod 4.6.0 release notes](https://github.com/colinhacks/zod/releases/tag/v4.6.0)

### `z.fromJSONSchema() with minProperties / maxProperties / uniqueItems / contains / minContains / maxContains`

**Stricter in zod 4.6.0** (2026-09-09).

From 4.6.0, a schema converted by `z.fromJSONSchema()` enforces `minProperties`, `maxProperties`, `uniqueItems`, `contains`, `minContains` and `maxContains`. Uniqueness is structural, so `[{ a: 1 }, { a: 1 }]` counts as a duplicate. On 4.2.0 through 4.5.x the converter accepted all six keywords and enforced none of them. Conversion succeeded with no error or warning, and the resulting schema accepted input its source JSON Schema rejects. On a 4.2.x to 4.5.x pin, either upgrade to 4.6.0 or add each check yourself with `.refine()`. After upgrading, expect a converted schema to reject input it used to accept.

*The stale belief:* That a converted schema handles these six keywords the same way on every release. It does not. 4.2.0 to 4.5.x ignore all six, so the schema accepts what its source rejects. 4.6.0 enforces them, so input a converted schema accepted before the upgrade can be rejected after it.

*Verified against:* [Zod 4.6.0 release notes](https://github.com/colinhacks/zod/releases/tag/v4.6.0)

### `z.properties() as a schema / z.instanceof(X).properties()`

**Behaviour changed in zod 4.6.0** (2026-09-09).

From 4.6.0 `z.properties(shape)` is a schema. It checks the named properties on any object, class instances included, and returns the input itself: the same object, with its prototype, methods and unlisted keys intact. `z.instanceof(X).properties(shape)` does the same after the class check, and it can be chained. The shape is checked and its results are thrown away: a `.default()` inside it is not filled and a `.transform()` is not applied, so the output is always the input as it was. On 4.5.x `z.properties()` returned an array of `z.property()` checks, one per key, with no `.parse()`. It worked only when spread into `.check()`, and passing it un-spread throws. Neither `z.properties(shape).parse()` nor `z.instanceof(X).properties()` exists there. The spread form, `z.instanceof(X).check(...z.properties(shape))`, works on both. `z.object()` is not a substitute. On every release it returns a new plain object for a class instance, so the prototype is gone.

*The stale belief:* That checking a few properties of a class instance (a `Response`, a `URL`) is a job for `z.object()`, and that it hands the instance back. On every release it returns a new plain object with the methods gone. Also, that `z.properties()` works like `z.object()` and applies defaults and transforms. It checks in place and returns the input untouched.

*Verified against:* [Zod 4.6.0 release notes](https://github.com/colinhacks/zod/releases/tag/v4.6.0)

### `z.toJSONSchema() bounds from chained checks (.min() / .max() / .length() / .multipleOf() / .int() and the other number formats)`

**Behaviour changed in zod 4.6.0** (2026-09-09).

From 4.6.0 `z.toJSONSchema()` combines a schema's checks as a conjunction, so it emits the tightest bounds and chain order does not change the output. On 4.0 through 4.5 each check wrote its bounds in chain order, and the emitted schema could be wider than what the runtime enforces. A number format chained after the bounds replaced them with its own range: `z.number().min(0).max(23).int()` emitted `minimum: -9007199254740991, maximum: 9007199254740991`, and with `.check(z.int32())` it emitted the int32 range. A second `.multipleOf()` was dropped, so `.multipleOf(2).multipleOf(3)` emitted `multipleOf: 2`, which accepts 4. And `z.string().min(8).length(5)` emitted `minLength: 5`. From 4.6.0 these emit `minimum: 0, maximum: 23`, `multipleOf: 2` with `allOf: [{ multipleOf: 3 }]`, and `minLength: 8, maxLength: 5`. The runtime enforced every bound on every release, so only the emitted schema was wrong. Also from 4.6.0, a string schema whose minimum exceeds its maximum, such as `z.string().max(5).min(8)`, can be built, and it rejects every input. On 4.0 through 4.5 building it threw `SyntaxError: Invalid regular expression: /^[\s\S]{8,5}$/`. On a pin below 4.6.0, chain the format check before the bounds and give a single divisor.

*The stale belief:* That `z.toJSONSchema()` emits the bounds a schema enforces, whatever order its checks are chained in. On 4.0 through 4.5 a format check chained after `.min()` and `.max()` replaced them with the format's full range, and a second `.multipleOf()` was dropped. A tool schema, form or OpenAPI document generated from it accepted values the server then rejected.

*Verified against:* [Zod 4.6.0 release notes](https://github.com/colinhacks/zod/releases/tag/v4.6.0)

### `z.compile()`

**Added in zod 4.5.0** (2026-08-28).

`z.compile(schema)` pre-compiles a schema and parses roughly 3–9x faster on objects, arrays and unions. A compiled schema is used exactly like an uncompiled one.

*Verified against:* [Zod 4.5.0 release notes](https://github.com/colinhacks/zod/releases/tag/v4.5.0)

### `z.fromJSONSchema() with prefixItems / draft-7 array-form items (tuples)`

**Behaviour changed in zod 4.5.0** (2026-08-28).

From 4.5.0, `z.fromJSONSchema()` converts a positional tuple the way JSON Schema defines it. A `prefixItems` list (draft 2020-12), or an array-form `items` list (draft-7), no longer closes the array. Extra items are allowed unless the schema says `items: false` (2020-12) or `additionalItems: false` (draft-7), and they are typed by `items` or `additionalItems` where the schema gives one. The positions are optional beyond `minItems`, so a shorter array, `[]` included, is accepted unless `minItems` requires more. On 4.2.0 through 4.4.x the converter built a closed tuple with every position required. `{ type: "array", prefixItems: [{ type: "number" }] }` rejected both `[1, 2]` and `[]`, which JSON Schema accepts, and `items: true` was read as closed as well. The conversion succeeded with no error or warning. On a 4.2.x to 4.4.x pin, a converted tuple rejects valid input. After upgrading to 4.5.0 it accepts input it used to reject, so a schema that relied on the closed reading needs `items: false` (or `additionalItems: false`) and a `minItems`, which converts the same way on every release.

*The stale belief:* That a converted `prefixItems` tuple behaves the same way on every release. It does not. 4.2.0 to 4.4.x close the tuple and require every position, which JSON Schema does not, so valid input is rejected. 4.5.0 follows the spec, so input that used to be rejected is accepted after the upgrade.

*Verified against:* [Zod 4.5.0 release notes (commit list)](https://github.com/colinhacks/zod/releases/tag/v4.5.0)

### `object properties typed z.undefined()`

**Behaviour changed in zod 4.4.0** (2026-04-29).

A property whose schema accepts `undefined` and is not `.optional()` is required: the key must be present, the value may be `undefined`. Zod can therefore distinguish a missing key from an explicit `undefined` — no `z.preprocess` or `in` check needed. Use `.optional()` only when the key itself may be absent.

*The stale belief:* That Zod cannot tell a missing key from a key explicitly set to `undefined`.

*Verified against:* [Zod 4.4.0 release notes](https://github.com/colinhacks/zod/releases/tag/v4.4.0)

### `z.coerce.*`

**Behaviour changed in zod 4.4.0** (2026-04-29).

An object with coerced fields errors on a missing key instead of silently defaulting: `z.object({ a: z.coerce.string() }).safeParse({})` fails from 4.4.0. Below it — including every 4.0.x to 4.3.x release — it SUCCEEDS with `{ a: "undefined" }`, the string, exactly as zod 3 did. Declare the fallback with `.default()`. (Separately, and at the 4.0.0 major: `z.coerce.*` accepts `unknown` input in v4. That is a type-level change with no runtime shadow — at runtime both majors coerce whatever they are handed — so it is not what this entry's version dates.)

*Verified against:* [Zod 4 changelog — Coercion](https://zod.dev/v4/changelog)

### `z.httpUrl()`

**Stricter in zod 4.4.0** (2026-04-29).

`z.httpUrl()` is the validator for http/https URLs — it checks the protocol and that the hostname is a domain — and since 4.4.0 it rejects a missing slash after the protocol instead of accepting the value the `URL` constructor silently repairs. Do not hand-roll a normalization step, and do not reach for `z.url({ protocol: /^https?$/ })`.

*The stale belief:* That URL validation inherits the WHATWG `URL` constructor's leniency, so `"https:/example.com"` has to be normalized by hand.

*Verified against:* [Zod 4.4.0 release notes](https://github.com/colinhacks/zod/releases/tag/v4.4.0)

### `z.tuple() defaults`

**Behaviour changed in zod 4.4.0** (2026-04-29).

A default in a tuple position materializes in parsed output when the input omits that position. Before 4.4.0 the omitted position was silently DROPPED, not rejected: the parse succeeded and returned a shorter array, so the default never appeared and downstream indexing read `undefined`. Nothing errored on either side of the change.

*The stale belief:* That a `.default()` in a tuple position does not fill a position the input omits, so the row has to be padded by hand before parsing.

*Verified against:* [Zod 4.4.0 release notes](https://github.com/colinhacks/zod/releases/tag/v4.4.0)

### `.exactOptional()`

**Added in zod 4.3.0** (2025-12-31).

Makes a property key-optional (may be omitted) while still rejecting an explicit `undefined` value — the missing half of `exactOptionalPropertyTypes`.

*Verified against:* [Zod 4.3.0 release notes](https://github.com/colinhacks/zod/releases/tag/v4.3.0)

### `.required() on a schema with refinements`

**Behaviour changed in zod 4.3.0** (2025-12-31).

From 4.3.0, `.required()` on an object schema that carries a refinement KEEPS the refinement: the derived schema still runs it. On 4.0.0 through 4.2.1 the same call returned a schema with the refinement silently gone. So `refined.required()` needs no workaround from 4.3.0, and re-applying `.refine()` to the result runs the check twice. This is the one derivation that went this way at 4.3.0 — the same release made `.pick()`, `.omit()` and `.partial()` throw on a refined schema instead (LF1). Neither `.required()` nor `.partial()` is named in the 4.3.0 release note.

*The stale belief:* That `.required()` on a refined schema silently drops the refinement, so the derived schema has to have the refinement re-applied — true up to 4.2.1, and it is the belief that produces a duplicated check, or a review that tells the author of `refined.required()` to rewrite working code.

*Verified against:* [zod PR #5317 ("Disallow unsound pick/omit on object schemas with refinements"), the pull request the 4.3.0 release notes name for the LF1 throws](https://github.com/colinhacks/zod/pull/5317)

### `intersections involving z.strictObject()`

**Behaviour changed in zod 4.3.2** (2025-12-31).

An intersection rejects only the keys unrecognized by BOTH sides. Previously an unrecognized key from either side errored, which made an intersection of two z.strictObject() schemas unsatisfiable. Announced in the 4.3.0 notes; the code shipped in 4.3.2, 49 minutes later the same day, so 4.3.0 and 4.3.1 still throw.

*Verified against:* [Zod 4.3.0 release notes](https://github.com/colinhacks/zod/releases/tag/v4.3.0)

### `z.fromJSONSchema()`

**Added in zod 4.2.0** (2025-12-15).

Zod converts JSON Schema to Zod at runtime with `z.fromJSONSchema()`, so you do not need `json-schema-to-zod` for this. Supports draft-2020-12, draft-7, draft-4 and OpenAPI 3.0. `z.toJSONSchema()` is the other direction. The conversion is lossy in two ways that fail silently, on every release that has it (4.2.0 through 4.6.2). First, a schema or subschema with no `type` (and no `enum` or `const`) becomes `z.any()` and accepts every value, so its `properties`, `required`, `minLength`, `minimum` and every other constraint go unenforced. Second, an array schema with no `items` drops `minItems` and `maxItems`. Give every schema and subschema an explicit `type`, and give every array an `items` (`items: {}` is enough). A JSON Schema you do not control may break either rule, and the converted schema then accepts input the document rejects. Below 4.6.0 six further keywords are ignored as well (LF32).

*The stale belief:* That Zod's JSON Schema support is one-way (Zod to JSON Schema only).

*Verified against:* [Zod 4.2.0 release notes](https://github.com/colinhacks/zod/releases/tag/v4.2.0)

### `z.looseRecord()`

**Added in zod 4.2.0** (2025-12-15).

Validates only the keys matching the key schema and passes non-matching keys through unchanged — the representation of JSON Schema `patternProperties`.

*Verified against:* [Zod 4.2.0 release notes](https://github.com/colinhacks/zod/releases/tag/v4.2.0)

### `z.xor()`

**Added in zod 4.2.0** (2025-12-15).

Exclusive union: passes only when exactly one option matches, failing on zero matches and on more than one. Do not hand-roll it with double `safeParse` plus `superRefine`.

*The stale belief:* That Zod has only `z.union()` (any match) and cannot express exclusivity.

*Verified against:* [Zod 4.2.0 release notes](https://github.com/colinhacks/zod/releases/tag/v4.2.0)

### `z.slugify()`

**Added in zod 4.1.13** (2025-11-24).

String-to-slug transform. Do not hand-roll a regex chain. It shipped UNDOCUMENTED in the patch release 4.1.13 (2025-11-24) and was announced in the 4.3.0 release notes thirty-seven days later, so a reader who dates it from the announcement will believe two whole minors cannot do it.

*Verified against:* [Zod 4.3.0 release notes](https://github.com/colinhacks/zod/releases/tag/v4.3.0)

### `z.codec() / z.encode() / z.decode()`

**Added in zod 4.1.0** (2025-08-23).

A codec is a bidirectional transformation and replaces a pair of one-way `.transform()` schemas kept in sync by hand. `z.invertCodec()` (4.4.0) flips one.

*The stale belief:* That a round-trip needs two separate `.transform()` schemas.

*Verified against:* [Zod 4.1.0 release notes](https://github.com/colinhacks/zod/releases/tag/v4.1.0)

### `defaults inside optionals`

**Behaviour changed in zod 4.0.0** (2025-07-09).

A default applies inside an optional: `z.object({ a: z.string().default("tuna").optional() })` parses `{}` to `{ a: "tuna" }`.

*Verified against:* [Zod 4 changelog — Defaults](https://zod.dev/v4/changelog)

### `z.base64()`

**Stricter in zod 4.0.0** (2025-07-09).

`z.base64()` rejects whitespace rather than stripping it `atob()`-style. This is true of the whole 4.x line — `z.base64()`, `z.string().base64()` and `z.base64url()` all reject it from 4.0.0 — even though the 4.4.0 notes announce it as a change.

*The stale belief:* That line-wrapped base64 (PEM, MIME) validates.

*Verified against:* [Zod 4.4.0 release notes](https://github.com/colinhacks/zod/releases/tag/v4.4.0)

### `record key transforms`

**No release changed this.** Stable across every release measured.

Record schemas run transforms on record keys, so a key transform changes the parsed output shape. **There is no release at which this became true.** It holds on every zod executed, 3.22.4 through 4.5.4, even though the 4.4.0 notes announce it as new — so a reader on zod 3 is inside this correction, not outside it, and pinning an older version does not avoid it.

*The stale belief:* That a transform on the key schema of a record is inert.

*Verified against:* [Zod 4.4.0 release notes](https://github.com/colinhacks/zod/releases/tag/v4.4.0)

### `z.fromJSONSchema(): which JSON Schema keywords throw and which convert silently`

**No release changed this.** Stable across every release measured.

`z.fromJSONSchema()` refuses some unrepresentable keywords loudly and accepts others in silence, and the split does not move. **There is no release at which it changed.** On every release the converter has existed — 4.2.0 through 4.6.2 — `if`/`then`/`else` throws ("Conditional schemas (if/then/else) are not supported"), `dependentSchemas` throws, and `unevaluatedProperties` throws; while `uniqueItems`, `minProperties`, `contains` and `patternProperties` all convert without an error or a warning. So a throw is not a reliable signal that a document is out of scope: a document whose only unrepresentable keywords are in the second group converts cleanly and yields a validator that is weaker than the document it came from. Do not use conversion success as the check. Inspect the document for the keywords you care about, or add them yourself. This is about whether conversion THROWS, and is distinct from whether a converted schema ENFORCES a keyword, which did change — see LF32: the four listed above that convert are all unenforced below 4.6.0, and `uniqueItems`, `minProperties` and `contains` become enforced at 4.6.0 while `patternProperties` is a separate question this fact does not measure.

*The stale belief:* That `z.fromJSONSchema()` is fail-loud — that it throws on any keyword it cannot represent, so a document that converts has been fully honoured. It throws on three keywords and silently accepts at least four others.

*Verified against:* [Executed against the published packages (zod/v7 grading run, 2026-09-22)](https://registry.npmjs.org/zod)

## Deprecated, or a better API now exists

### `schema.validate() / schema.validateAsync() (methods)`

**Added in zod 4.6.0** (2026-09-09).

From 4.6.0 every Zod Classic schema has `.validate(input)` and `.validateAsync(input)` methods. They return a boolean (a promise of one, for the async form) without building a `ZodError`, they run refinements, and they give the same answer as the top-level `z.validate(schema, input)` and `z.validateAsync(schema, input)`, which arrived in 4.5.0. A schema from `z.compile()` has them too. On 4.5.x only the top-level functions exist and `schema.validate` is `undefined`, so the method form throws a `TypeError` there. `zod/mini` never gets the methods and keeps the top-level functions. A schema with an async refinement makes `.validate()` and `z.validate()` throw ("Encountered Promise during synchronous parse"), not return `false`, so use `.validateAsync()` for it.

*The stale belief:* That a Zod schema has no `.validate()` method, so a yes/no check means `.safeParse(x).success`, or `z.validate(schema, x)` on 4.5.x. Both still work, and from 4.6.0 the method is the direct form. Also, that a boolean check cannot throw: with an async refinement the synchronous form throws.

*Verified against:* [Zod 4.6.0 release notes](https://github.com/colinhacks/zod/releases/tag/v4.6.0)

### `z.iban()`

**Added in zod 4.6.0** (2026-09-09).

From 4.6.0 `z.iban()` validates an IBAN in electronic format and checks its ISO 7064 MOD 97-10 checksum. The format is uppercase with no spaces or hyphens: a two-letter country code, check digits from 02 to 98, then 11 to 30 letters or digits. It does not consult the country registry, so a string with a valid checksum passes even when its country code does not exist or its length is wrong for its country. It is top-level only; there is no `z.string().iban()` method. `z.regexes.iban` is the shape alone, so `z.string().regex(z.regexes.iban)` accepts a bad checksum that `z.iban()` rejects. A failure is an `invalid_format` issue with `format: "iban"`.

*The stale belief:* That Zod has no IBAN validator, so it takes a hand-written regex (which usually skips the checksum) or another library. On 4.6, also that `z.iban()` accepts the grouped form banks print (`DE89 3704 ...`) or checks each country's account length. It does neither.

*Verified against:* [Zod 4.6.0 release notes](https://github.com/colinhacks/zod/releases/tag/v4.6.0)

### `z.withParser() / z.INVALID`

**Added in zod 4.6.0** (2026-09-09).

From 4.6.0 `z.withParser(schema, parser)` returns a new schema that parses with a parser you supply, one generated at build time or by another compiler. It is for environments where `z.compile()`'s `new Function` is blocked, such as a strict Content Security Policy. The original schema is not changed. The parser returns the parsed value or `z.INVALID`, a symbol exported from 4.6.0. Returning `z.INVALID` hands the input to Zod's runtime, which parses it normally: a valid input still succeeds and an invalid one gets the runtime's `ZodError`. Anything else the parser returns is the result, and nothing checks it. The base schema's refinements do not run, unknown keys are not stripped unless the parser strips them, and a wrong value comes back as it is. So the parser has to do everything the schema would have done. A parser that returns a promise makes the promise itself the result, reported as a success. On zod 3, `INVALID` is an unrelated object (`{ status: "aborted" }`), and 4.0 through 4.5 have none.

*The stale belief:* That `z.compile()` is the only precompiled fast path, and a CSP without `unsafe-eval` blocks it. Or, with `z.withParser()`, that Zod still checks what the parser returns or runs the schema's refinements. It does neither when the parser returns a value.

*Verified against:* [Zod 4.6.0 release notes](https://github.com/colinhacks/zod/releases/tag/v4.6.0)

### `z.creditCard() / z.properties() / .deepPartial() / .exactPartial()`

**Added in zod 4.5.0** (2026-08-28).

4.5.0 added `z.creditCard()` (12-19 digits plus a Luhn checksum) and `z.properties()` as the multi-property counterpart to `z.property()`, and returned deep and exact partials. Note the two shapes differ: `z.deepPartial(schema)` is a top-level function, while `.exactPartial()` is a method on the schema.

*Verified against:* [Zod 4.5.0 release notes](https://github.com/colinhacks/zod/releases/tag/v4.5.0)

### `z.validate()`

**Added in zod 4.5.0** (2026-08-28).

`z.validate(schema, input)` returns a boolean without a full parse — a fast path when you only need validity.

*Verified against:* [Zod 4.5.0 release notes](https://github.com/colinhacks/zod/releases/tag/v4.5.0)

### `.superRefine({ when })`

**Added in zod 4.4.0** (2026-04-29).

`.superRefine()` takes a `when` option for conditional refinement, so a guard clause inside the callback is no longer the only way to express it.

*Verified against:* [Zod 4.4.0 release notes](https://github.com/colinhacks/zod/releases/tag/v4.4.0)

### `empty z.union([]) / z.xor([])`

**Behaviour changed in zod 4.4.0** (2026-04-29).

Empty unions and discriminated unions construct without crashing and fail at parse time instead of construction time.

*Verified against:* [Zod 4.4.0 release notes](https://github.com/colinhacks/zod/releases/tag/v4.4.0)

### `z.cuid()`

**Stricter in zod 4.4.0** (2026-04-29).

The `z.cuid()` pattern changed at 4.4.0 from `/^[cC][^\s-]{8,}$/` to `/^[cC][0-9a-z]{6,}$/`. It is tightened on the alphabet — uppercase letters and punctuation after the leading `c` are now rejected — and LOOSENED on length, from a minimum of 9 characters to 7. CUID v1 is deprecated; well-formed v1 identifiers still validate.

*The stale belief:* That `z.cuid()` accepts any non-whitespace, non-dash run of 8+ characters after the leading `c`, and that short ids are rejected on length.

*Verified against:* [Zod 4.4.0 release notes](https://github.com/colinhacks/zod/releases/tag/v4.4.0)

### `.with()`

**Added in zod 4.3.0** (2025-12-31).

`.with()` is the readable alias for `.check()`, added because not everything composable is a 'check'.

*Verified against:* [Zod 4.3.0 release notes](https://github.com/colinhacks/zod/releases/tag/v4.3.0)

### `custom error messages`

**Deprecated in zod 4.0.0** (2025-07-09).

Custom errors use the `error` parameter: `z.email({ error: "..." })`. The three v3 spellings do NOT behave alike, and the difference is the whole trap. `message` still works: it is honoured identically on every release measured from 3.22.4 to 4.5.4, so v3 code using it is merely old, not broken. `invalid_type_error` and `required_error` are the ones that break — from 4.0.0 they are accepted without any error and then **silently ignored**, and the issue carries zod's default message instead of the one you wrote.

*The stale belief:* That the v3 error params are interchangeable with `error`, or that they fail loudly when they are not. The common half-migration is a v4 format function still carrying a v3 error param — invisible while it is `message`, which works, and silent when it is `required_error`, which does not.

*Verified against:* [Zod 4 changelog — Error Customization](https://zod.dev/v4/changelog)

### `string format validators`

**Deprecated in zod 4.0.0** (2025-07-09).

Format validators are top-level functions in v4: `z.email()`, `z.uuid()`, `z.url()`, `z.ipv4()`. The method-style `z.string().email()` form is the v3 idiom.

*Verified against:* [Zod 4 changelog — String Format Validators](https://zod.dev/v4/changelog)

### `z.function()`

**Behaviour changed in zod 4.0.0** (2025-07-09).

`z.function()` takes `{ input, output }` and returns a factory you call `.implement()` or `.implementAsync()` on; the v3 shape `z.function(z.tuple([...]), z.number())` is gone and a v3-shaped call does not work in v4. Do not, however, take "a factory, not a schema" further than 4.0.x: `.parse()` and `.safeParse()` are absent only on 4.0.0 and 4.0.17, and from 4.1.0 they are back on the factory — `.parse(fn)` returns the implemented, validating wrapper and `.parse(42)` throws, on every release through 4.5.4.

*Verified against:* [Zod 4 changelog — z.function()](https://zod.dev/v4/changelog)

### `ZodError formatting`

**Deprecated in zod 4.0.0** (2025-07-09).

Shape errors for a form with `z.treeifyError(result.error)` or `z.flattenError(result.error)`, not the `.flatten()` / `.format()` methods on the error object.

*Verified against:* [Zod 4 changelog — Error Formatting](https://zod.dev/v4/changelog)

## Wrong facts about the library

### `installing and importing zod`

**Behaviour changed in zod 4.0.0** (2025-07-09).

`npm install zod` has installed v4 since 4.0.0 and the import path is plain `"zod"`. Generated code does not need a v3/v4 fallback branch, and `zod/v4` is not the subpath to reach for on a new project.

*The stale belief:* That v4 is opt-in behind a subpath, so generated code should hedge with a v3 fallback block.

*Verified against:* [Zod 4 changelog](https://zod.dev/v4/changelog)

---

*The Stale Priors Index, Unattended Works (https://stalepriors.com/rules/zod.md), CC BY 4.0. Retrieved 2026-09-11. Licence: <https://creativecommons.org/licenses/by/4.0/> (`SPDX-License-Identifier: CC-BY-4.0`).*
*Quoted changelog text belongs to its authors; nothing here grants any right in the libraries described.*
*Terms in full: <https://stalepriors.com/data/LICENSE>.*
