zod correction pack · for projects on zod@^4
What we actually measured
5 Claude models were asked for idiomatic zod code with no tools, purely from training knowledge. 42 reproduced failures across 23 runs, each verified against the release that broke the belief.
| Model | Stated cutoff | zod version attribution stops | Lag inside the window |
|---|---|---|---|
| Claude Opus 5 | 2026-05 | 4.1.0 · 2025-08-23 | ~9 months |
| Claude Fable 5.1 | 2026-06 | 4.2.0 · 2025-12-15 | ~6 months |
| Claude Sonnet 5 | 2026-01 | 4.0.0 · 2025-07-09 | ~6 months |
| Claude Fable 5 | 2026-01 | 4.1.0 · 2025-08-23 | ~5 months |
A recent cutoff is not a defence. Every model here loses track of this library's release history well before the date it states as its own cutoff, and the stopping points cluster far tighter than the cutoffs do.
Read that column precisely. It is the newest zod release whose contents the model can correctly attribute to that release — not the newest zod feature it knows. Past that point a model will often write working code with a newer API while naming the wrong release for it, and that guess runs early — it names a release older than the one that shipped the feature. So the question this pack answers is not "does the model know this API" but "can it be trusted about which version the API arrived in" — which is the question that matters when you are pinned to a version.
Latest zod is 4.6.2 (verified 2026-09-11). Separately from the corrections below, 4 of 23 runs recorded a version fact — the model named a current zod version from memory and was behind. If a model states a version without checking, assume it is behind and check the registry.
How to read an entry
Every entry ends with a Reproduced against line. Where it names models, we have the generated code that got it wrong, dated, with the model's own words in the run write-up. Where it says no model yet, the correction is verified from the release notes but nothing has been probed for it — it is a fix, not a measurement, and the pack says so rather than blurring the two.
The section an entry sits in is the worst case if you act on the stale belief. The severity in brackets after a model's name is what that particular model's output actually did, which can be milder — a model can hold the wrong belief and still, on the day, write code that runs.
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.
The corrections
Breaks the build, or throws at runtime
Act on the stale belief here and the code does not run. Fix these first.
.pick() / .omit() / .partial() on a schema with refinements
Now throws in zod 4.3.0 (2025-12-31) · boundary measured on 21 releases, zod 3.22.4 through 4.6.2
.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.
// Stale
const Signup = z.object({
password: z.string(),
confirmPassword: z.string(),
}).refine((d) => d.password === d.confirmPassword);
Signup.pick({ password: true }); // 4.3+: throws
Signup.partial(); // 4.3+: throws, same rule
// Current
const shape = { password: z.string(), confirmPassword: z.string() };
const Signup = z.object(shape).refine((d) => d.password === d.confirmPassword);
z.object(shape).pick({ password: true }); // derive from the unrefined base
z.object(shape).partial(); // and the same for .partial()
The durable rule for this whole cluster: keep an unrefined
z.object({...})base, derive every variant from that, and apply.refine()last..partial()was added to this fact on 2026-09-12 (JOURNAL/172, BACKLOG 11k-w-a) after the clause was measured and queued in JOURNAL/132. The 4.3.0 release note names only.pick()and.omit(); the pull request it cites for the change, #5317, adds the throw for.partial()in the same function and ships two tests for it ("partial - throws error on schema with refinements"). Executed on all 21 rungs:.pick()(LF1),.omit()(LF1b) and.partial()(LF1c) each return-and-drop on 4.0.0 through 4.2.1 and throw from 4.3.0, contiguous, with the method-named message..omit()had been named in this statement since the fact was filed and had never been probed; it is probed now.introduced_in, severity and every charge are unchanged — all five findings on this fact are.pick()/.omit()misses, which throw from 4.3.0 either way.
Reproduced against: Claude Fable 5 (S1), Claude Fable 5.1 (S1), Claude Opus 5 (S1), Claude Sonnet 5 (S1) — zod/v2, 2026-08-29; zod/v3-a, 2026-09-02; zod/v4-a, 2026-09-02; zod/v6-a, 2026-09-06.
Source: Zod 4.3.0 release notes · 2025-12-31 · zod PR #5317 ("Disallow unsound pick/omit on object schemas with refinements"), the pull request the 4.3.0 release notes name for this change · Executed against the published packages (tools/audit/bisect-facts.mjs rows LF1, LF1b, LF1c, 2026-09-12) · 2026-09-12
.extend() overwriting a property on a refined schema
Now throws in zod 4.1.0 (2025-08-23) · boundary measured on 21 releases, zod 3.22.4 through 4.6.2
.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.
// Stale
const A = z.object({ a: z.string() }).refine(/* ... */);
A.extend({ a: z.number() }); // 4.3+: throws
// Current
A.safeExtend({ a: z.string().min(5).max(10) }); // allowed, refinement preserved
The 4.3.0 release notes describe this change, and the Index dated it from that note until 2026-09-06. Bisected: it shipped in 4.1.0 (2025-08-23). Anything pinned at 4.1.0 or above already has
.safeExtend(). CORRECTED 2026-09-10 (JOURNAL/132): this fact's bisect citation said the 4.3.0 note "re-announces behaviour that shipped two minors earlier". It does not. 4.1.0 made EVERY.extend()of a refined schema throw (Object schemas containing refinements cannot be extended); the 4.3.0 note announces a NARROWING ("if you are overwriting existing properties"), and the narrowing shipped in 4.3.1, 39 minutes after 4.3.0, where the message becomesCannot overwrite keys on object schemas containing refinements. Executed on all nineteen rungs: an.extend()that only adds a key to a refined schema returns a schema that DROPS the refinement on 4.0.0 and 4.0.17, throws on 4.1.0 through 4.3.0, and keeps the refinement from 4.3.1 (probe rows LF2b and LF2c). What this fact dates is unchanged — an overwrite throws on every release from 4.1.0 — and so is every published charge against it, each of which is an overwrite. 4.3.1 shares 4.3.0's publish date (2025-12-31), so no fairness arithmetic moves. Also corrected: the probe's check that.safeExtend()preserves the refinement used a refinement that could never fail, and passed a stub whose.safeExtend()dropped it; re-measured with one that can fail, it preserves on every rung from 4.1.0.
Reproduced against: Claude Opus 5 (S1), Claude Sonnet 5 (S1) — zod/v2, 2026-08-29.
Source: Zod 4.3.0 release notes · 2025-12-31 · Bisected against the published packages (tools/audit/bisect-facts.mjs, 2026-09-06) · 2026-09-06 · Zod 4.1.0 release notes (.safeExtend() section) · 2025-08-23 · Zod 4.3.1 release notes · 2025-12-31 · Executed against the published packages (tools/audit/bisect-facts.mjs rows LF2b/LF2c, 2026-09-10) · 2026-09-10
z.record()
Removed in zod 4.0.0 (2025-07-09) · boundary measured on 21 releases, zod 3.22.4 through 4.6.2
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.
// Stale
z.record(z.number()); // TS2554 on every v4 release; also rejects every key before 4.4.0
// Current
z.record(z.string(), z.number());
Re-verified 2026-09-02 against the shipped packages rather than the release note, and the release note is the trap.
tsc --strictreports TS2554 on 4.3.6, 4.4.0, 4.4.3 and 4.5.4 alike, andv4/classic/schemas.d.tsdeclares the same single two-argumentrecordoverload byte-identically from 4.0.0 through 4.5.4. The runtime half is true and was executed: at 4.0.0 and 4.3.6z.record(z.number()).safeParse({a:1})failsinvalid_key; at 4.4.0 and 4.5.4 it passes, and{a:"nope"}failsinvalid_type— so the single argument really is the value schema again. This entry is the reason the pack is generated. The hand-written version said the form was removed in v4 and will not compile; the 2026-08-31 replacement then read the 4.4.0 note as restoring the form outright, which is wrong for every TypeScript reader. Both errors came from a release note, and neither survived running the code. Date normalised 2026-09-07 from 2025-07-10 to the registry's own publish date for 4.0.0 (2025-07-09); the whole 4.0.0 cohort carried the announcement's day and was the only group in the file that did not match releases.json. No charge moves. SUBPATH MEASURED 2026-09-28 (JOURNAL/330, BACKLOG 0d-vi(iii)): Zod 4 shipped first inside zod@3.25.x as thezod/v4subpath. 3.25.0 (2025-05-19) publishes no build and cannot be imported; 3.25.1, the same day, is the first release that loads. This fact's probe rows, run against that subpath, read the same as at 4.0.0 on 3.25.1, 3.25.20, 3.25.50 and 3.25.76, so a project importingzod/v4has had this behaviour since 2025-05-19.introduced_instays 4.0.0 because it dates whatimport from "zod"gives, and that is v3 on every 3.25.x release.
Reproduced against: Claude Haiku 4.5 (S1) — zod/v1, 2026-08-28.
Source: Zod 4 changelog — Record Schema Changes · Zod 4.4.0 release notes — accurate for the runtime, silent about the unchanged type signature · 2026-04-29 · zod 4.5.4 shipped package — v4/classic/schemas.d.ts declares one two-argument record overload; tsc --strict reports TS2554 for the single-argument call
.merge() on a schema with refinements
Now throws in zod 4.4.0 (2026-04-29) · boundary measured on 21 releases, zod 3.22.4 through 4.6.2
.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.
// Stale
const a = z.object({ a: z.string() }).refine((val) => val.a.length > 0);
a.merge(z.object({ b: z.string() })); // 4.4+: throws
// Current
z.object({ a: z.string(), b: z.string() }).refine((val) => val.a.length > 0);
Reproduced against: no model yet. Verified from the primary source only — this is a correction, not an Index entry.
Source: Zod 4.4.0 release notes · 2026-04-29
.pick() / .omit() with a key that does not exist
Stricter in zod 4.3.0 (2025-12-31) · boundary measured on 21 releases, zod 3.22.4 through 4.6.2
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.
// Stale
const mask = { a: true, nope: true } as const;
const S = A.pick(mask); // 4.3.0+: tsc TS2345. 4.0.0: throws here.
S.safeParse(input); // 4.0.17+: throws Error: Unrecognized key: "nope" HERE, not at the call,
// and safeParse does not catch it — it is a construction error.
// Current
// Mask only keys the schema actually has. A shared list of field names reused across schemas is
// the usual way to get this wrong, so narrow it to this schema's own shape before masking:
const mask = { a: true } as const;
A.pick(mask);
CORRECTED 2026-09-07 (JOURNAL/075, DISTRIBUTION D1 step 4d) from executed code and tsc 5.9.3 --strict on all fifteen bisector rungs. The version 4.3.0 is unchanged for the type check and no published charge moves; the fact was wrong about the runtime, and about which mask forms the type check applies to. (1)
The check is a TypeScript type error, not a runtime throwand.pick() with an unknown key still runs and still returns a schema at runtime, on every 4.x release— misleading and false respectively. The schema is returned from 4.0.17 (on 4.0.0 the call throws outright), and it throwsError: Unrecognized keythe moment anything touches it:.shape,.parse()and.safeParse()all throw on all fifteen rungs. The check moved from eager to lazy at 4.0.17; it never went away. The old citation was written from a probe that called.pick()and looked no further, which is the only way to seeno runtime error. (2)It only bites when the mask is a variable — an inline object literal was already caught by excess-property checking before 4.3.0— false. Excess-property checking never fired, because the parameter is the inferred genericM extends util.Mask<keyof Shape>, which gives it no fixed target type to fire against. Measured: an inline literal with an unknown key compiles clean on all seven rungs below 4.3.0 and errors TS2322 from 4.3.0, exactly where the variable form errors TS2345. (3) JOURNAL/067 filed LF4 asunprobeableon the reasoning that a type error has no runtime shadow, so the runtime half was never executed and the fact carried a false clause until today. The runtime half is probeable and is now probed as LF4b (claim 4.0.0). An unprobeable HALF is not an unprobeable fact. CONSEQUENCE FOR THE BENCHMARK: the runtime behaviour is identical on all fifteen rungs, so LF4's version dependence exists only under a type-checker, and no acceptance assertion that also grades behaviour can attribute a failure on it to 4.3.0 (prompts/benchmark.md §9, third amendment; benchmark task A2 refused). The fact still charges, and is more severe than published: the stale mask breaks the build from 4.3.0 AND throws pastsafeParseon every 4.x.
Reproduced against: no model yet. Verified from the primary source only — this is a correction, not an Index entry.
Source: Zod 4.3.0 release notes · 2025-12-31 · Executed and type-checked against fifteen published zod releases (the bisector ladder, tools/audit/probes/zod.mjs; tsc 5.9.3 --strict, module nodenext) · 2026-09-07 · zod source, src/v4/core/util.ts — the runtime mask check, as shipped in the 4.0.0 package · 2026-09-07
Runs, but is silently wrong
Nothing errors. The behaviour is simply not what a model trained earlier will tell you.
z.fromJSONSchema()
Added in zod 4.2.0 (2025-12-15) · boundary measured on 21 releases, zod 3.22.4 through 4.6.2
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).
const schema = z.fromJSONSchema({
type: "object", // without it: z.any(), nothing enforced
properties: {
name: { type: "string", minLength: 1 },
tags: { type: "array", items: {}, minItems: 1 }, // without items: minItems dropped
},
required: ["name", "tags"],
});
Upstream calls the API experimental and makes no round-trip soundness guarantee. CORRECTED 2026-09-11 (JOURNAL/138). Until today this fact said
Do not add Ajv or json-schema-to-zod for thisand said nothing about what the converter drops. The narrowing was measured on all twelve rungs that have the converter, 4.2.0 through 4.6.2 (probe rows LF6b, LF6c).{ properties: { name: { type: "string" } }, required: ["name"] }accepts{}and{ name: 1 }, and{ minLength: 2 }accepts"a", while the same object withtype: "object"rejects{}.{ type: "array", minItems: 2 }accepts[1], and addingitems: {}makes it reject[1]. A nested property withitemsbut notypeaccepts[1]for a string list.enumandconstwithouttypeare enforced. The shipped converter states both gaps in its own comments:No type specified - empty schema (any)andNo items specified - array of any. The docs (zod.dev/json-schema, read 2026-09-11) mark the API experimental and document neither gap. The recommendation was narrowed, not reversed. The belief this fact corrects, that Zod cannot convert JSON Schema at all, is still wrong, and no charge moves: every finding filed against LF6 records that denial.
Reproduced against: Claude Fable 5 (S2), Claude Opus 5 (S2), Claude Sonnet 5 (S2) — zod/v2, 2026-08-29; zod/v4-a, 2026-09-02.
Source: Zod 4.2.0 release notes · 2025-12-15 · Zod 4.3.0 release notes · 2025-12-31 · zod@4.5.4 published type declarations, v4/classic/from-json-schema.d.ts · zod@4.6.2 shipped converter, v4/classic/from-json-schema.js (convertBaseSchema) · Executed against the published packages (tools/audit/bisect-facts.mjs rows LF6b/LF6c, 2026-09-11) · 2026-09-11
z.xor()
Added in zod 4.2.0 (2025-12-15) · boundary measured on 21 releases, zod 3.22.4 through 4.6.2
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.
const schema = z.xor([z.string(), z.number()]);
schema.parse("hello"); // ok
schema.parse(true); // fails: zero matches
Converts to
oneOfrather thananyOfin JSON Schema output.
Reproduced against: Claude Fable 5 (S2), Claude Opus 5 (S2), Claude Sonnet 5 (S2) — zod/v2, 2026-08-29; zod/v4-a, 2026-09-02.
Source: Zod 4.2.0 release notes · 2025-12-15 · Zod 4.3.0 release notes · 2025-12-31 · zod@4.5.4 published type declarations, v4/classic/schemas.d.ts
object properties typed z.undefined()
Behaviour changed in zod 4.4.0 (2026-04-29) · boundary measured on 21 releases, zod 3.22.4 through 4.6.2
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.
const schema = z.object({ value: z.undefined() });
schema.safeParse({}).success; // false
schema.safeParse({ value: undefined }).success; // true
Also changes
.catch(),.partial(),.default()and.prefault()combinations that relied on missing keys being treated as optional.
Reproduced against: Claude Fable 5 (S2, not chargeable), Claude Opus 5 (S4), Claude Sonnet 5 (S2, not chargeable) — zod/v2, 2026-08-29.
Source: Zod 4.4.0 release notes · 2026-04-29
z.httpUrl()
Stricter in zod 4.4.0 (2026-04-29) · boundary measured on 21 releases, zod 3.22.4 through 4.6.2
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.
// Stale
z.url({ protocol: /^https?$/ }); // plus a manual normalization pass
// Current
z.httpUrl().safeParse("https://example.com").success; // true
z.httpUrl().safeParse("https:/example.com").success; // false, since 4.4.0
Matters most where the URL is a webhook target or is later compared as a string.
Reproduced against: Claude Opus 5 (S2), Claude Sonnet 5 (S2) — zod/v2, 2026-08-29; zod/v3-a, 2026-09-02.
Source: Zod 4.4.0 release notes · 2026-04-29
.exactOptional()
Added in zod 4.3.0 (2025-12-31) · boundary measured on 21 releases, zod 3.22.4 through 4.6.2
Makes a property key-optional (may be omitted) while still rejecting an explicit undefined value — the missing half of exactOptionalPropertyTypes.
const schema = z.object({
a: z.string().optional(), // accepts undefined
b: z.string().exactOptional(), // does not accept undefined
});
Reproduced against: Claude Fable 5 (S2), Claude Opus 5 (S2), Claude Sonnet 5 (S2, not chargeable) — zod/v3-a, 2026-09-02; zod/v4-a, 2026-09-02.
Source: Zod 4.3.0 release notes · 2025-12-31 · zod@4.5.4 published type declarations, v4/classic/schemas.d.ts
intersections involving z.strictObject()
Behaviour changed in zod 4.3.2 (2025-12-31) · boundary measured on 21 releases, zod 3.22.4 through 4.6.2
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.
const C = z.intersection(z.strictObject({ a: z.string() }), z.strictObject({ b: z.string() }));
C.parse({ a: "x", b: "y" }); // { a: "x", b: "y" } from 4.3.2; throws on 4.3.0 and 4.3.1
CORRECTED 2026-09-06 (JOURNAL/066) from 4.3.0 to 4.3.2, and the old correct_code said "ok in 4.3+", which was false of two published releases. Bisected across 4.1.0, 4.2.0, 4.2.1, 4.3.0, 4.3.1, 4.3.2, 4.3.3, 4.3.4, 4.3.5, 4.3.6, 4.4.0, 4.4.3, 4.5.0 and 4.5.4: throws through 4.3.1, parses from 4.3.2. The introduced_on DATE is unchanged - npm publishes 4.3.0 at 05:10, 4.3.1 at 05:49 and 4.3.2 at 05:59 UTC on 2025-12-31 - so no fairness arithmetic anywhere in the Index moves, and no published charge is affected. What moves is the version string a reader would pin on.
Reproduced against: Claude Fable 5 (S2), Claude Fable 5.1 (S2), Claude Sonnet 5 (S2, not chargeable) — zod/v4-a, 2026-09-02; zod/v6-a, 2026-09-06.
Source: Zod 4.3.0 release notes · 2025-12-31 · Zod 4.3.2 release, commit list · 2025-12-31 · Executed against published zod 4.3.0, 4.3.1 and 4.3.2 · 2026-09-06
z.coerce.*
Behaviour changed in zod 4.4.0 (2026-04-29) · boundary measured on 21 releases, zod 3.22.4 through 4.6.2
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.)
RE-DATED 2026-09-07 from 4.0.0 to 4.4.0 (BACKLOG 11k-t-iii), on the fact's first execution. The published date came from the v4 changelog's coercion section and put a silently-wrong behaviour change at the major; measured on nineteen rungs,
z.object({ a: z.coerce.string() }).safeParse({})succeeds with the string "undefined" on 3.22.4 through 4.3.6 and fails only from 4.4.0. Four minors of the v4 line carried the v3 behaviour this entry said v4 had ended. The 4.0.0 half of the old statement was the type-level clause, which is kept above and explicitly not dated by this entry. No published finding rests on this fact, so no charge moves; the re-dating moves the fact from a dead window to a live one: at 4.0.0 it sat below every subject's measured boundary and could charge nobody, and tools/charge-windows.mjs puts 4.4.0 (2026-04-29) as charging Claude Opus 5 and Claude Fable 5.1 with Claude Haiku 4.5 and Claude Sonnet 5 as controls. It has never been probed against a model.
Reproduced against: Claude Fable 5.1 (S2) — zod/v7-a, 2026-09-22.
Source: Zod 4 changelog — Coercion · Bisected against the published packages, 19 rungs from 3.22.4 to 4.5.4 (tools/audit/bisect-facts.mjs, 2026-09-07) · 2026-09-07
z.tuple() defaults
Behaviour changed in zod 4.4.0 (2026-04-29) · boundary measured on 21 releases, zod 3.22.4 through 4.6.2
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.
z.tuple([z.string(), z.string().default("fallback")]).parse(["a"]);
// ["a", "fallback"] from 4.4.0; ["a"] - length 1, no error - on 4.0.0 through 4.3.6
CORRECTED 2026-09-07 (JOURNAL/073, DISTRIBUTION D1 step 4c) from executed code on all fifteen bisector rungs. The version 4.4.0 is unchanged and no published charge moves; three CLAUSES were wrong. (1)
rather than erroring on an under-filled input- no 4.x release errors on one.z.tuple([z.string(), z.string().default("fb")]).safeParse(["a"])succeeds on every rung; what changed is the returned array,["a"]through 4.3.6 and["a","fb"]from 4.4.0. (2)an explicitundefinedsupplied by the caller is preserved- it is not, on any release:.parse(["a", undefined])returns["a","fb"]on all fifteen, so the default applies to an explicitly suppliedundefinedidentically before and after. (3) the old stale_belief described a hard length error that never existed. The severity is unchanged and is now better earned: the pre-4.4.0 failure is a silently short array, not a caught one. A surplus element is stilltoo_bigon every release. Becausez.function()arguments are tuple-shaped, function input arity may also read differently.
Reproduced against: Claude Sonnet 5 (S2, not chargeable) — zod/v2, 2026-08-29.
Source: Zod 4.4.0 release notes · 2026-04-29 · Executed against fifteen published zod releases (the bisector ladder, tools/audit/probes/zod.mjs) · 2026-09-07
z.fromJSONSchema(): which JSON Schema keywords throw and which convert silently
No release changed this · true on 6 releases executed, zod 4.2.0 through 4.6.2
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.
// Stale
// Asserted to throw for a document carrying uniqueItems or minProperties. It does not.
try {
schema = z.fromJSONSchema(doc); // returns a schema for both, on every release
} catch {
return unsupported(doc); // never reached for those two keywords
}
// Current
// Decide from the document, not from whether conversion threw.
const SILENTLY_DROPPED = ["uniqueItems", "minProperties", "maxProperties", "contains", "patternProperties"];
const dropped = SILENTLY_DROPPED.filter((k) => JSON.stringify(doc).includes(`"${k}"`));
const schema = z.fromJSONSchema(doc); // throws only for if/then/else, dependentSchemas, unevaluatedProperties
if (dropped.length) {
// below 4.6.0 these are unenforced (LF32); add them yourself or refuse the document
}
Written 2026-09-22 (JOURNAL/187) from execution, not from documentation, and written because a battery draw was charged for believing the opposite.
zod/v7-aasserted that converting a document carryinguniqueItemsandminPropertiesthrows before any payload is looked at; executed on all six rungs that carry the converter (4.2.0, 4.3.6, 4.4.3, 4.5.4, 4.6.0, 4.6.2) it returns a schema on every one. The draw's MECHANISM was real and its LIST was wrong, which is why this fact records the split rather than a denial: three of the five keywords that draw named do throw, on every rung, exactly as it described. Eight keyword shapes were executed per rung. No documentation was consulted for the throw list and none is cited for it: the citation is the packages. The complementary question — whether a converted schema enforces what it accepted — is LF32's and moved at 4.6.0; this one did not move at all, which is what makes a throw a bad detector on any pin.
Reproduced against: Claude Fable 5.1 (S2) — zod/v7-a, 2026-09-22.
Source: Executed against the published packages (zod/v7 grading run, 2026-09-22) · 2026-09-22 · Zod 4.6.0 release notes · 2026-09-09
safeParse() result.error: when error maps run
Behaviour changed in zod 4.6.0 (2026-09-09) · boundary measured on 21 releases, zod 3.22.4 through 4.6.2 · published after every cutoff in this dataset — listed for completeness, no model can be faulted for it yet
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.
// Stale
// zod 4.0.x-4.5.x
const result = schema.safeParse(input);
z.config(z.locales.fr()); // e.g. the next request's locale
if (!result.success) log(result.error.issues[0].message); // the parse-time locale below 4.6.0, French from 4.6.0
// Current
// Destructure at the call: `error` is read before the configuration can change, on every 4.x release.
const { success, error } = schema.safeParse(input);
z.config(z.locales.fr());
if (!success) log(error.issues[0].message); // the parse-time locale on 4.0.0 through 4.6.2
Written 2026-09-11 (JOURNAL/142) from execution, with the 4.6.0 release note and PR #6519 read first. The note's sentence names three kinds of error map, and each is its own probe row. LF36a covers the global
customError, through.safeParse(),.safeParseAsync()andz.safeParse(). LF36b covers the locale, and LF36c covers a per-schemaerrormap, counted: it runs zero times before the read and once after two reads. All three rise at 4.6.0, contiguous on all 21 rungs. LF36d (.parse()and.parseAsync()carry the parse-time message) and LF36e (destructuring at the call pins it) are true on every 4.x rung, 4.6 included. The LF36f window (safeParse ran the global and per-schema maps at parse time) holds from 4.0.0 up to 4.6.0. Every row restores the global configuration afterwards.z.config()is 4.x only, so every row is false on the 3.x rungs, and zod 3'sz.setErrorMap()was not measured. PR #6519 says the Standard Schema surface (~standard.validate) never takes the lazy path. That was not measured here. The stale_code and correct_code were executed as written on all seventeen 4.x rungs, withschema = z.string()andinput = 12: the stale code logs English through 4.5.4 and French from 4.6.0, and the correct code logs English on all seventeen. Published after every stated cutoff in the corpus, so this fact charges no subject. It is here for the agent reading the pack.
Reproduced against: no model yet. Verified from the primary source only — this is a correction, not an Index entry.
Source: Zod 4.6.0 release notes · 2026-09-09 · zod PR #6519 (commit bec73bea), merged 2026-08-31, as listed in the 4.6.0 release notes · 2026-08-31 · Executed against the published packages (tools/audit/bisect-facts.mjs rows LF36a-LF36f, 2026-09-11) · 2026-09-11
schema metadata members .format / .minLength / .maxLength / .minValue / .maxValue / .isInt / .minDate / .maxDate
Behaviour changed in zod 4.6.0 (2026-09-09) · boundary measured on 21 releases, zod 3.22.4 through 4.6.2 · published after every cutoff in this dataset — listed for completeness, no model can be faulted for it yet
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.
// Stale
// Reading a bound off a schema to build a form hint
const Hour = z.number().min(0).max(23).int();
Hour.minValue; // -9007199254740991 on 4.0-4.5, 0 from 4.6.0
Object.keys(z.string().min(3)).includes("minLength"); // true on 4.0-4.5, false from 4.6.0
// Current
// Chain the format first and read the member by name. The same on every release from 3.22.4.
const Hour = z.number().int().min(0).max(23);
Hour.minValue; // 0
Hour.maxValue; // 23
Written 2026-09-11 (JOURNAL/148) from execution, with the 4.6.0 release note and PR #6554 read first. Probe rows LF44a, LF44b and LF44c rise at 4.6.0, contiguous on all 21 rungs. LF44a checks each of the eight members on a schema that sets it: before the read the key is not own, not in
Object.keys()and not in a spread; the value is right; after the read the key is own. LF44b checks thatz.number().min(0).max(23)with.int()or.check(z.int32())reads 0 and 23, thatz.bigint().min(0n).max(23n).check(z.int64())reads0nand23n, and thatz.string().min(8).length(5).minLengthreads 8. LF44c checks that a read member, once deleted, is gone, and that the next read gives 3 again and makes it own. The LF44d window holds from 4.0.0 up to 4.6.0: all eight own at construction, the safe-integer and int32 minimums,minLength5, and a deleted member readingundefined. LF44e is true on every rung from 4.0.0: the format-first chain reads 0 and 23, and an assignment shadows as an own property. The int64 chain read -9223372036854775808 below 4.6.0. On the zod 3 rungs these members are prototype getters that never become own properties, andz.number().min(0).max(23).int().minValueis 0, so the wrong values belong to 4.0 through 4.5 only.isFiniteis not one of the eight, and it is still an own key before any read on 4.6.0 and 4.6.2. The stale_code and correct_code were executed as written on all 21 rungs. The stale code gives -9007199254740991 and true on 4.0.0 through 4.5.4, and 0 and false on 4.6.0, 4.6.2 and the 3.x rungs. The correct code gives 0 and 23 on every rung, 3.22.4 included. Published after every stated cutoff in the corpus, so this fact charges no subject. It is here for the agent reading the pack.
Reproduced against: no model yet. Verified from the primary source only — this is a correction, not an Index entry.
Source: Zod 4.6.0 release notes · 2026-09-09 · zod PR #6554 (commit 6f048367), merged 2026-09-07, as listed in the 4.6.0 release notes · 2026-09-07 · Executed against the published packages (tools/audit/bisect-facts.mjs rows LF44a-LF44e, 2026-09-11) · 2026-09-11
z.base64() / z.base64url() inside z.templateLiteral(); z.regexes.base64url; z.toJSONSchema(z.base64url())
Behaviour changed in zod 4.6.0 (2026-09-09) · boundary measured on 21 releases, zod 3.22.4 through 4.6.2 · published after every cutoff in this dataset — listed for completeness, no model can be faulted for it yet
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.
// Stale
// A prefixed base64 token, composed
const Token = z.templateLiteral(["tok_", z.base64()]);
Token.safeParse("tok_AAAA").success; // false on 4.0-4.5, true from 4.6.0
Token.safeParse("AAAA").success; // true on 4.0-4.5 (no prefix), false from 4.6.0
Token.safeParse("tok_AAA").success; // false on 4.0-4.5, true from 4.6.0 (bad length)
// Current
// Compose the shape with z.string(), then check the payload with z.base64() itself.
const B64 = z.base64();
const Token = z.templateLiteral(["tok_", z.string()])
.refine((s) => B64.safeParse(s.slice(4)).success, "Invalid base64 payload");
Token.safeParse("tok_AAAA").success; // true on every 4.x release
Token.safeParse("tok_AAA").success; // false on every 4.x release (bad length)
Token.safeParse("AAAA").success; // false on every 4.x release (no prefix)
Written 2026-09-11 (JOURNAL/148) from execution, with the 4.6.0 release note and PRs #6534 and #6527 read first. Probe rows LF42a, LF42b and LF42c rise at 4.6.0, contiguous on all 21 rungs. LF42a checks that a
tok_prefix with a valid payload is accepted, that a bare payload, three=and a non-alphabet character are rejected, and that a template literal with two base64 members accepts"k:AAAA|AAAA". LF42b checks that the composed member accepts payloads of length 1 and 3, after a prefix and before a suffix, whichz.base64()rejects. LF42c checks thatz.regexes.base64url, the emitted pattern and the parser all reject"A"and"AAAAA". The LF42d window holds from 4.0.0 up to 4.6.0: the prefixed payload rejected, the bare payload and the bare prefix accepted, a leading member length-checked, and the emitted base64url pattern accepting"A". LF42e is true on every rung from 4.0.0: the direct verdicts ofz.base64()andz.base64url()on fixed tables, and az.base64url()member accepting"tok_A". The two-member literal broke the same way below 4.6.0 (the survey read"k:AAAA|AAAA"rejected and"AAAA"accepted on every rung from 4.0.0 through 4.5.4). The patternz.toJSONSchema()emits for["id-", z.base64()]begins^id-$|on 4.0.0 through 4.5.4 and is^id-[0-9a-zA-Z+/]*={0,2}$on 4.6.0 and 4.6.2. LF11, about whitespace inz.base64(), still holds on 4.6.0 and 4.6.2 and is not edited. The regex-stack overflow the PR fixes was reproduced with Node 24.13.0's default stack. An 8,000,000-character payload in[z.base64(), "-x"]throwsRangeError: Maximum call stack size exceededon every rung from 4.0.0 through 4.5.4 and parses on 4.6.0 and 4.6.2.z.base64()alone and az.base64url()member parse it on every 4.x rung. The result depends on the stack size, so it is not a probe row. The stale_code and correct_code were executed as written on all 21 rungs. The stale code gives false, true, false on 4.0.0 through 4.5.4 and true, false, true on 4.6.0 and 4.6.2. The correct code gives true, false, false on every 4.x rung and throws on the 3.x rungs, wherez.base64()does not exist. Published after every stated cutoff in the corpus, so this fact charges no subject. It is here for the agent reading the pack.
Reproduced against: no model yet. Verified from the primary source only — this is a correction, not an Index entry.
Source: Zod 4.6.0 release notes · 2026-09-09 · zod PR #6534 (commit 07c43e2a), merged 2026-08-31, as listed in the 4.6.0 release notes · 2026-08-31 · zod PR #6527 (commit 319f47f4), merged 2026-08-31, as listed in the 4.6.0 release notes · 2026-08-31 · Executed against the published packages (tools/audit/bisect-facts.mjs rows LF42a-LF42e, 2026-09-11) · 2026-09-11
z.email() pattern: z.regexes.email / issue.pattern / z.toJSONSchema() / inside z.templateLiteral()
Behaviour changed in zod 4.6.0 (2026-09-09) · boundary measured on 21 releases, zod 3.22.4 through 4.6.2 · published after every cutoff in this dataset — listed for completeness, no model can be faulted for it yet
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.
// Stale
// Reading the halves of an address out of z.regexes.email's capture groups
const [, local, domain] = z.regexes.email.exec("first.last@example.com") ?? [];
local; // "first.las" on 4.0-4.5 (not the local part), undefined from 4.6.0 (no groups)
domain; // "example." on 4.0-4.5, undefined from 4.6.0
// Current
// Validate with z.email(), then split at the last "@". The same on every 4.x release.
const addr = z.email().parse("first.last@example.com");
const at = addr.lastIndexOf("@");
const local = addr.slice(0, at); // "first.last"
const domain = addr.slice(at + 1); // "example.com"
Written 2026-09-11 (JOURNAL/148) from execution, with the 4.6.0 release note and PR #6573 read first. Probe rows LF41a and LF41b rise at 4.6.0, contiguous on all 21 rungs. LF41a checks that
z.regexes.emailhas no lookahead and no capture group, and that the failed issue'spatternand thez.toJSONSchema()pattern are the same string. LF41b checks that[z.email(), "|", z.string()]accepts"a@b.cc|a..b"and"a@b.cc|.."and still rejects"a..b@c.dd|x". LF41c gives the same verdicts on a sixteen-address table, throughz.email()andz.string().email(), on every rung from 4.0.0, wherez.email()first exists. The LF41d window holds from 4.0.0 up to 4.6.0: the two lookaheads, the groups holding"first.las"and"example.", and the composed rejection. Every rung from 4.0.0 through 4.5.4 carries one identicalz.regexes.emailsource, and 4.6.0 and 4.6.2 carry another. The PR's claim that the accepted language is unchanged was checked by a differential. It covered every string of up to eight characters over a nine-symbol alphabet with one member of each character class either pattern names (letter, digit,_,',+,-,.,@) and one character in none of them: 48,427,561 strings. The 4.5.4 and 4.6.0 patterns gave the same verdict on every one. On both rungsz.email().safeParse()agreed with its pattern on all 597,871 strings of up to six characters. The PR also reports a speed-up and a change in the input size at which the pattern overflows the regex stack. Neither was measured here. The stale_code and correct_code were executed as written on all 21 rungs. The stale code gives"first.las"and"example."on 4.0.0 through 4.5.4 andundefinedfor both on 4.6.0 and 4.6.2. The correct code gives"first.last"and"example.com"on every 4.x rung and throws on the four 3.x rungs, wherez.email()does not exist. Published after every stated cutoff in the corpus, so this fact charges no subject. It is here for the agent reading the pack.
Reproduced against: no model yet. Verified from the primary source only — this is a correction, not an Index entry.
Source: Zod 4.6.0 release notes · 2026-09-09 · zod PR #6573 (commit f7fd5548), merged 2026-09-09, as listed in the 4.6.0 release notes · 2026-09-09 · Executed against the published packages (tools/audit/bisect-facts.mjs rows LF41a-LF41d, 2026-09-11) · 2026-09-11
z.emoji() / z.string().emoji()
Stricter in zod 4.6.0 (2026-09-09) · boundary measured on 21 releases, zod 3.22.4 through 4.6.2 · published after every cutoff in this dataset — listed for completeness, no model can be faulted for it yet
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.
// Stale
// zod 3.22.x-4.5.x
const Reaction = z.string().emoji();
Reaction.parse("123"); // passes below 4.6.0, throws from 4.6.0
Reaction.parse("#"); // passes below 4.6.0, throws from 4.6.0
// Current
// Require the anchor yourself and the check agrees on every release from 3.22.4.
const hasAnchor = (s) => /[\p{Extended_Pictographic}\p{Regional_Indicator}\u20e3]/u.test(s);
const Reaction = z.string().emoji().refine(hasAnchor, "Must contain an emoji");
Reaction.parse("\u{1F600}"); // passes on every release
Reaction.parse("123"); // throws on every release
Written 2026-09-11 (JOURNAL/142) from execution, with the 4.6.0 release note and PR #6532 (which fixes issue #6515) read first. Probe row LF35a rises at 4.6.0, contiguous on all 21 rungs. It checks that all six component-only strings are rejected and
😀is accepted in the same row, so a validator that rejected everything could not pass. LF35b is an invariant and holds on every rung. It covers the forms the note calls unchanged, one of each, all accepted, with"abc"and""rejected as controls. LF35c is also an invariant and holds on every rung: a pictograph with a digit before or after it is accepted. The LF35d window (all six component-only strings accepted) holds from 3.22.4 up to 4.6.0. Its start is the ladder's floor, so it is a bound: the behaviour may be older than 3.22.4. Every row runs throughz.string().emoji(), which exists on every rung, and also through the top-levelz.emoji()on the seventeen rungs that have it (from 4.0.0). The correct_code was executed as written on all 21 rungs: it accepts the six unchanged forms, a keycap without U+FE0F,❤and©, and it rejects all six component-only strings. The PR notes that the pattern is emitted intoz.toJSONSchema()output. That was not measured here. Published after every stated cutoff in the corpus, so this fact charges no subject. It is here for the agent reading the pack.
Reproduced against: no model yet. Verified from the primary source only — this is a correction, not an Index entry.
Source: Zod 4.6.0 release notes · 2026-09-09 · zod PR #6532 (commit f83ab511), merged 2026-08-31, as listed in the 4.6.0 release notes · 2026-08-31 · Executed against the published packages (tools/audit/bisect-facts.mjs rows LF35a-LF35d, 2026-09-11) · 2026-09-11
z.enum() / z.nativeEnum() over a numeric TypeScript enum: .options
Behaviour changed in zod 4.6.0 (2026-09-09) · boundary measured on 21 releases, zod 3.22.4 through 4.6.2 · published after every cutoff in this dataset — listed for completeness, no model can be faulted for it yet
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.
// Stale
// zod 4.0.x-4.5.x
enum Country { UK, Germany, France }
const C = z.enum(Country);
C.options; // ["UK", "Germany", "France", 0, 1, 2] below 4.6.0, [0, 1, 2] from 4.6.0
C.options.filter((o) => typeof o === "string"); // the names below 4.6.0, [] from 4.6.0
// Current
// Take the values from what the schema accepts and the names from the enum object: the same on every 4.x release.
enum Country { UK, Germany, France }
const C = z.enum(Country);
const values = C.options.filter((o) => C.safeParse(o).success); // [0, 1, 2]
const names = Object.keys(C.enum).filter((k) => Number.isNaN(Number(k))); // ["UK", "Germany", "France"]
Written 2026-09-11 (JOURNAL/142) from execution, with the 4.6.0 release note and PR #6542 read first. Probe row LF34a (values only, through
z.enum(),z.nativeEnum()and a mixed enum, every option parsing) rises at 4.6.0, contiguous on all 21 rungs. The LF34b window (the six-entry list, three of whose entries the schema rejects) holds from 4.0.0 up to 4.6.0. LF34c (the parser accepts0and rejects"UK",z.toJSONSchema()emits[0, 1, 2], and.enumkeeps the reverse mapping) is true on every 4.x rung, 4.6 included. On 3.xz.enum()takes an array, and.optionsof an enum object is the object itself, so every row is false there. The 3.x numeric-enum schema isz.nativeEnum(), whose.optionsisundefinedon all four 3.x rungs. The enum fixture is the object tsc emits for the declaration, reverse mapping included. The stale_code and correct_code were executed as written on all 21 rungs. The correct code gives[0, 1, 2]and the three names on all seventeen 4.x rungs. Both throw on the four 3.x rungs, wherez.enum()does not take an enum object..exclude()and.extract()take member keys and throwKey 0 not found in enumwhen given values on every 4.x rung. That was observed and is not part of this fact. Published after every stated cutoff in the corpus, so this fact charges no subject. It is here for the agent reading the pack.
Reproduced against: no model yet. Verified from the primary source only — this is a correction, not an Index entry.
Source: Zod 4.6.0 release notes · 2026-09-09 · zod PR #6542 (commit 90269c60), merged 2026-08-31, as listed in the 4.6.0 release notes · 2026-08-31 · Executed against the published packages (tools/audit/bisect-facts.mjs rows LF34a-LF34c, 2026-09-11) · 2026-09-11
z.fromJSONSchema() with minProperties / maxProperties / uniqueItems / contains / minContains / maxContains
Stricter in zod 4.6.0 (2026-09-09) · boundary measured on 21 releases, zod 3.22.4 through 4.6.2 · published after every cutoff in this dataset — listed for completeness, no model can be faulted for it yet
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.
// Stale
// zod 4.2.x-4.5.x
const Tags = z.fromJSONSchema({ type: "array", items: { type: "string" }, uniqueItems: true });
Tags.parse(["a", "a"]); // passes below 4.6.0, throws from 4.6.0
// Current
// zod >= 4.6.0: the converter enforces uniqueItems itself.
// Pinned to 4.2.x-4.5.x: add the check the converter drops.
// (A Set compares primitives only; object items need a structural comparison.)
const Tags = z.fromJSONSchema({ type: "array", items: { type: "string" }, uniqueItems: true })
.refine((a) => new Set(a).size === a.length, "items must be unique");
Written 2026-09-11 (JOURNAL/138) from execution, not from the release note. Probe rows LF32a to LF32c each rise at 4.6.0, contiguous on all 21 rungs (false through 4.5.4, true at 4.6.0 and 4.6.2), and the LF32d window (the six keywords convert without error and go unenforced) holds from 4.2.0 up to 4.6.0. Every case pairs an input the source rejects with one it accepts, so a converter that rejected everything could pass no row. The dialect does not matter, checked on one keyword per dialect on all twelve rungs that have the converter (4.2.0 through 4.6.2):
uniqueItemsunder an explicit draft-7$schema,minPropertiesunder draft-4, anduniqueItemswith the OpenAPI 3.0 target all move at 4.6.0 like the default draft. The keywords are enforced only on a schema that declarestype: without one, the schema isz.any()on every release (LF6). The stale_code and correct_code were executed as written at 4.2.0, 4.5.4, 4.6.0 and 4.6.2. Published after every stated cutoff in the corpus, so this fact charges no subject; it is here for the agent reading the pack.
Reproduced against: no model yet. Verified from the primary source only — this is a correction, not an Index entry.
Source: Zod 4.6.0 release notes · 2026-09-09 · zod PR #6535 (commit eca96871), as listed in the 4.6.0 release notes · 2026-09-09 · Executed against the published packages (tools/audit/bisect-facts.mjs rows LF32a-LF32d, 2026-09-11) · 2026-09-11
z.properties() as a schema / z.instanceof(X).properties()
Behaviour changed in zod 4.6.0 (2026-09-09) · boundary measured on 21 releases, zod 3.22.4 through 4.6.2 · published after every cutoff in this dataset — listed for completeness, no model can be faulted for it yet
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.
// Stale
const OkResponse = z.object({ ok: z.literal(true), status: z.number() });
const res = OkResponse.parse(new Response("hi", { status: 200 }));
await res.text(); // TypeError: res.text is not a function. A plain object came back.
// Current
// zod 4.5.x and 4.6: the spread form works on both
const OkResponse = z.instanceof(Response).check(...z.properties({ ok: z.literal(true), status: z.number() }));
const res = OkResponse.parse(new Response("hi", { status: 200 }));
await res.text(); // "hi": the same Response came back
// zod 4.6.0 and later: z.instanceof(Response).properties({ ok: z.literal(true), status: z.number() })
Written 2026-09-11 (JOURNAL/144) from execution, with the 4.6.0 release note and PR #6536 read first. LF31 dates the name
z.properties()at 4.5.0 and is unchanged; this fact is about what the name returns. Probe rows LF40a (the schema returns the input by identity, a class instance and a plain object with an unlisted key alike, and rejects a failing property, a missing one, a string andnull) and LF40b (z.instanceof(X).properties()returns the same instance and rejects a failing property and a plain object with the right properties) rise at 4.6.0, contiguous on all 21 rungs. LF40c (a transform inside the shape is not applied and a default is not filled) rises at 4.6.0. The LF40d window (anArrayof one check per key, no.parse, and un-spread.check()throwing) holds from 4.5.0 up to 4.6.0. LF40e (the spread form returns the instance and rejects a failing property) is true from 4.5.0, because the 4.6 schema yields itself fromSymbol.iterator. LF40f (z.object()returns a non-instance for a class instance) is an invariant on all 21 rungs. The un-spread.check(z.properties(...))throws "Cannot read properties of undefined (reading 'onattach')" on 4.5.0 and 4.5.4. On 4.6 the schema also reads aMap'ssize, an array'slengthand a getter on a class. The PR says the spread form now reports every failing property instead of stopping at the first, and that thetarget: es5TypeScript setting loses the spread form. Neither was measured here. The stale_code and correct_code were executed as written with Node's globalResponseon all 21 rungs. The stale form'sres.text()throws a TypeError on every rung. The correct form gives "hi" on 4.5.0 through 4.6.2 and throws on every rung below 4.5.0, wherez.propertiesdoes not exist. Published after every stated cutoff in the corpus, so this fact charges no subject. It is here for the agent reading the pack.
Reproduced against: no model yet. Verified from the primary source only — this is a correction, not an Index entry.
Source: Zod 4.6.0 release notes · 2026-09-09 · zod PR #6536 (commit abfb3897), merged 2026-08-31, as listed in the 4.6.0 release notes · 2026-08-31 · Executed against the published packages (tools/audit/bisect-facts.mjs rows LF40a-LF40f, 2026-09-11) · 2026-09-11
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) · boundary measured on 21 releases, zod 3.22.4 through 4.6.2 · published after every cutoff in this dataset — listed for completeness, no model can be faulted for it yet
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.
// Stale
// A number format chained after the bounds
const Hour = z.number().min(0).max(23).int();
const { minimum, maximum } = z.toJSONSchema(Hour);
// 4.0-4.5: minimum -9007199254740991, maximum 9007199254740991
// from 4.6.0: minimum 0, maximum 23
// Current
// Chain the format first and give a single divisor. The same output on every 4.x release.
const Hour = z.toJSONSchema(z.number().int().min(0).max(23)); // minimum 0, maximum 23
const Sixes = z.toJSONSchema(z.number().multipleOf(6)); // multipleOf 6
Written 2026-09-11 (JOURNAL/148) from execution, with the 4.6.0 release note and PRs #6553 (which closes issue #6550) and #6554 read first. Probe rows LF43a, LF43b and LF43c rise at 4.6.0, contiguous on all 21 rungs. LF43a checks that a number format chained after the bounds, as
.int()and as.check(z.int32()), keepsminimum: 0, maximum: 23, and that three chain orders emit one schema. LF43b checks that both divisors of.multipleOf(2).multipleOf(3)are emitted and that.min(8).length(5)emitsminLength: 8, maxLength: 5. LF43c checks thatz.string().max(5).min(8)builds, rejects"","abc"and"abcdefgh", and emits both bounds. The LF43d window holds from 4.0.0 up to 4.6.0: the safe-integer range,multipleOf: 2alone,minLength: 5, and aSyntaxErrornaming{8,5}at construction. LF43e is true on every rung from 4.0.0: the runtime rejects 24, -1 and 1.5 for the hour chain and 4 for the divisor chain, and the format-first chain and a single divisor emit the right bounds. On 4.0.0 through 4.5.4 the construction error is not specific to.length().max(5).min(8),length(5).min(8)andmin(8).max(5)all throw it, andmin(8).length(5)builds, because the later.length()overwrote the minimum. The error comes from deriving the string's pattern for use in a template literal, so a string schema whose bounds could not both hold could not be built at all. Arrays were never affected:z.array(z.string()).length(5).min(8)emitsminItems: 8, maxItems: 5on every 4.x rung. Two chained.regex()checks emit anallOfof both patterns on every 4.x rung.z.toJSONSchema()throws onz.bigint()andz.date()bounds on every 4.x rung, because it cannot represent them, so LF44 covers that half. The stale_code and correct_code were executed as written on all 21 rungs. The stale code gives the safe-integer range on 4.0.0 through 4.5.4 and 0 and 23 on 4.6.0 and 4.6.2. The correct code gives 0, 23 and a lonemultipleOf: 6on every 4.x rung and throws on the 3.x rungs, wherez.toJSONSchema()does not exist. Published after every stated cutoff in the corpus, so this fact charges no subject. It is here for the agent reading the pack.
Reproduced against: no model yet. Verified from the primary source only — this is a correction, not an Index entry.
Source: Zod 4.6.0 release notes · 2026-09-09 · zod PR #6553 (commit 7a002366), merged 2026-09-02, as listed in the 4.6.0 release notes · 2026-09-02 · zod PR #6554 (commit 6f048367), merged 2026-09-07, as listed in the 4.6.0 release notes · 2026-09-07 · Executed against the published packages (tools/audit/bisect-facts.mjs rows LF43a-LF43e, 2026-09-11) · 2026-09-11
z.compile()
Added in zod 4.5.0 (2026-08-28) · boundary measured on 21 releases, zod 3.22.4 through 4.6.2 · published after every cutoff in this dataset — listed for completeness, no model can be faulted for it yet
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.
const CompiledPlayer = z.compile(Player);
CompiledPlayer.parse({ /* ... */ });
Reproduced against: no model yet. Verified from the primary source only — this is a correction, not an Index entry.
Source: Zod 4.5.0 release notes · 2026-08-28
z.fromJSONSchema() with prefixItems / draft-7 array-form items (tuples)
Behaviour changed in zod 4.5.0 (2026-08-28) · boundary measured on 21 releases, zod 3.22.4 through 4.6.2 · published after every cutoff in this dataset — listed for completeness, no model can be faulted for it yet
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.
// Stale
// zod 4.2.x-4.4.x
const Point = z.fromJSONSchema({ type: "array", prefixItems: [{ type: "number" }, { type: "number" }] });
Point.parse([1, 2, 3]); // throws below 4.5.0, passes from 4.5.0
Point.parse([1]); // throws below 4.5.0, passes from 4.5.0
// Current
// State the closure and the required length, and the conversion agrees on every release that has it.
const Point = z.fromJSONSchema({
type: "array",
prefixItems: [{ type: "number" }, { type: "number" }],
items: false, // no third item
minItems: 2, // both positions required
});
Point.parse([1, 2]); // passes on 4.2.0 through 4.6.2; [1, 2, 3] and [1] throw on all of them
Written 2026-09-11 (JOURNAL/140) from execution, with the 4.5.0 release note and its two PRs read first. Probe rows LF33a (extra items allowed by default) and LF33b (positions optional beyond
minItems) each rise at 4.5.0, contiguous on all 21 rungs: false through 4.4.3, true at 4.5.0, 4.5.4, 4.6.0 and 4.6.2. The LF33d window (the tuple closed, every position required,items: trueread as closed) holds from 4.2.0 up to 4.5.0. Both dialects move at the same release: 2020-12prefixItems, and draft-7's array-formitemsunder an explicit draft-7$schemaand also with no$schema. The explicit forms were never broken. Row LF33c (items: false,additionalItems: false, and a typeditemsoradditionalItems) holds on every release that has the converter, from 4.2.0, so on a 4.2.x to 4.4.x pin an open tuple can be had withitems: {}. Optional positions cannot:{ prefixItems: [number, string], minItems: 1 }rejects[1]on every rung below 4.5.0. Every row pairs an input the source schema accepts with one it rejects (a wrong-typed position, or an array shorter thanminItems), so a converter that accepted every array could pass none of them. The stale_code and correct_code were executed as written on all twelve rungs that have the converter. Every schema here declarestype: "array", and it has to. Under LF6's first gap,{ prefixItems: [{ type: "number" }], items: false }with notypeaccepts["x", 1, 2]and42on all twelve rungs. Published after every stated cutoff in the corpus, so this fact charges no subject. It is here for the agent reading the pack.
Reproduced against: no model yet. Verified from the primary source only — this is a correction, not an Index entry.
Source: Zod 4.5.0 release notes (commit list) · 2026-08-28 · zod PR #6020 (commit a106fbe7), merged 2026-08-14, as listed in the 4.5.0 release notes · 2026-08-14 · zod PR #6201 (commit e8034eba), merged 2026-08-14, as listed in the 4.5.0 release notes · 2026-08-14 · Executed against the published packages (tools/audit/bisect-facts.mjs rows LF33a-LF33d, 2026-09-11) · 2026-09-11
.required() on a schema with refinements
Behaviour changed in zod 4.3.0 (2025-12-31) · boundary measured on 21 releases, zod 3.22.4 through 4.6.2
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.
// Stale
const Base = z.object({ a: z.string(), b: z.string().optional() })
.refine((d) => d.a !== "blocked", { message: "blocked" });
// Written on the belief that .required() drops the refinement:
const Full = Base.required().refine((d) => d.a !== "blocked", { message: "blocked" });
Full.safeParse({ a: "blocked", b: "y" }); // 4.3+: two identical issues, not one
// Current
const Base = z.object({ a: z.string(), b: z.string().optional() })
.refine((d) => d.a !== "blocked", { message: "blocked" });
const Full = Base.required(); // the refinement comes with it from 4.3.0
Full.safeParse({ a: "blocked", b: "y" }); // one issue
Full.safeParse({ a: "x" }); // and b is now required
Written 2026-09-12 (JOURNAL/172, BACKLOG 11k-w-a) from execution, with PR #5317 read first. The clause was measured and deliberately not filed in JOURNAL/132; this is the fact. Probe row LF45 rises at 4.3.0, contiguous on all 21 rungs: on 4.0.0 through 4.2.1 the derived schema accepts the one value only the refinement rejects, and from 4.3.0 it rejects it with the refinement's own message. On the four 3.x rungs a refined schema has no
.required()at all. The 4.3.0 release note mentions neither.required()nor.partial(); PR #5317, which the note cites for the throws in LF1, is where both live, and it ships two tests for this direction ("required - preserves refinements", "required - refinement is executed on required schema"). Severity S2 rather than S1 because nothing breaks: the stale belief produces a duplicated refinement, or a review verdict that working code is unsound — the failure mode is the verdict and the extra check, not a build (HARNESS, 2026-09-02). The stale_code and correct_code were executed as written on all 21 rungs. The stale code's re-applied.refine()yields two identicalblockedissues on all ten rungs from 4.3.0 and one on 4.0.0 through 4.2.1; the correct code yields one from 4.3.0 and accepts the same input below it, which is the boundary read from the other side. Both throwBase.required is not a functionon the four 3.x rungs, which is outside this pack'szod@^4. No battery has tested this fact, so it charges nobody. 4.3.0 is the release LF1 is charged on against four subjects (Claude Fable 5.1, Fable 5, Opus 5 and Sonnet 5), so this fact is chargeable material on a surface already known to be live.
Reproduced against: no model yet. Verified from the primary source only — this is a correction, not an Index entry.
Source: 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 · Zod 4.3.0 release notes — the release this behaviour was measured to change at, and it describes only the other direction · 2025-12-31 · Executed against the published packages (tools/audit/bisect-facts.mjs row LF45, 2026-09-12) · 2026-09-12
z.looseRecord()
Added in zod 4.2.0 (2025-12-15) · boundary measured on 21 releases, zod 3.22.4 through 4.6.2
Validates only the keys matching the key schema and passes non-matching keys through unchanged — the representation of JSON Schema patternProperties.
z.looseRecord(z.string().regex(/^S_/), z.string()).parse({ S_name: "John", other: 123 });
// { S_name: "John", other: 123 } — only S_name is validated
Reproduced against: no model yet. Verified from the primary source only — this is a correction, not an Index entry.
Source: Zod 4.2.0 release notes · 2025-12-15 · Zod 4.3.0 release notes · 2025-12-31
z.slugify()
Added in zod 4.1.13 (2025-11-24) · boundary measured on 21 releases, zod 3.22.4 through 4.6.2
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.
z.string().slugify().parse("Hello, World!"); // "hello-world" - works from 4.1.13
Dated 4.3.0 from the announcement, corrected to 4.2.0 on 2026-09-06 from a spot-check that stopped at 4.1.12, and bisected to 4.1.13 the same day. The prior check missed it because 4.1.13 was published five weeks after 4.1.12 and six months into the 4.1 line, which is exactly where a spot-check stops looking.
Reproduced against: no model yet. Verified from the primary source only — this is a correction, not an Index entry.
Source: Zod 4.3.0 release notes · 2025-12-31 · zod@4.5.4 published type declarations, v4/classic/schemas.d.ts · Bisected against the published packages (tools/audit/bisect-facts.mjs, 2026-09-06) · 2026-09-06
z.codec() / z.encode() / z.decode()
Added in zod 4.1.0 (2025-08-23) · boundary measured on 21 releases, zod 3.22.4 through 4.6.2
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.
The code below needs zod 4.4.0 or later. Between 4.1.0 and 4.4.0 this correction does not apply — see the note.
const stringToDate = z.codec(z.iso.datetime(), z.date(), {
decode: (isoString) => new Date(isoString),
encode: (date) => date.toISOString(),
});
const dateToString = z.invertCodec(stringToDate); // 4.4.0
AUDIT 2026-09-02 (JOURNAL/038). The floor is on the last line only:
z.codec()is the 4.1.0 arrival this fact is filed under and compiles from 4.1.0, butz.invertCodec()arrived at 4.4.0, so the block as printed is TS2339 on 4.1.0, 4.2.0 and 4.3.0 — bisected against the installed packages. The fact stated 4.4.0 in prose and in a trailing comment and still carried noreplacement_available_from, which is why the sweep of all 168 facts in JOURNAL/037 read past it and the mechanical audit did not.
Reproduced against: no model yet. Verified from the primary source only — this is a correction, not an Index entry.
Source: Zod 4.1.0 release notes · 2025-08-23 · Zod 4.4.0 release notes · 2026-04-29
defaults inside optionals
Behaviour changed in zod 4.0.0 (2025-07-09) · boundary measured on 21 releases, zod 3.22.4 through 4.6.2
A default applies inside an optional: z.object({ a: z.string().default("tuna").optional() }) parses {} to { a: "tuna" }.
Date normalised 2026-09-07 from 2025-07-10 to the registry's own publish date for 4.0.0 (2025-07-09); the whole 4.0.0 cohort carried the announcement's day and was the only group in the file that did not match releases.json. No charge moves. SUBPATH MEASURED 2026-09-28 (JOURNAL/330, BACKLOG 0d-vi(iii)). This is the one 4.0.0-dated behaviour that did NOT ship with the
zod/v4subpath. Zod 4 was published inside zod@3.25.x from 2025-05-19, but on that subpath{}parses to{}on every sampled release from 3.25.1 to 3.25.68 (3.25.1, .20, .50, .51, .57, .60, .63, .66, .67, .68). It parses to{ a: "tuna" }from 3.25.69, published 2025-07-02 (sampled .69, .70, .72, .75, .76). The change is commit b6fe831e7, "Do not clobber defaults in", which makesZodOptionalrun its inner schema directly when that schema is itself optional-in. So a project importingzod/v4before 3.25.69 does not get this behaviour, even though the Zod 4 changelog describes it as part of Zod 4.introduced_instays 4.0.0 because it dates the root import. The reversed order (LF27b) behaves the same on every rung.
Reproduced against: no model yet. Verified from the primary source only — this is a correction, not an Index entry.
Source: Zod 4 changelog — Defaults · zod commit b6fe831e7, "Do not clobber defaults in" (released in v3.25.69) · 2025-07-02
z.base64()
Stricter in zod 4.0.0 (2025-07-09) · boundary measured on 21 releases, zod 3.22.4 through 4.6.2
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.
z.base64().safeParse("Zm9v").success; // true
z.base64().safeParse("Zm 9v").success; // false, on every 4.x release
The 4.4.0 release note announces this, and the Index dated it from the note until 2026-09-06. No stable 4.x release behaves the other way, so the note describes a change that is not observable in the released line — it is a correction for anyone carrying a v3-era belief, not a 4.4.0 migration item. MEASURED 2026-09-07 (JOURNAL/076): until then this date sat at the bottom rung of a ladder that stopped at 4.0.0, so it was a bound and not a measurement. Against 3.22.4/3.23.8/3.24.4/3.25.76 the probe is false at every rung and true from 4.0.0, so the boundary is the major and the 2026-09-06 correction is now confirmed by a release below it. SUBPATH MEASURED 2026-09-28 (JOURNAL/330, BACKLOG 0d-vi(iii)): Zod 4 shipped first inside zod@3.25.x as the
zod/v4subpath. 3.25.0 (2025-05-19) publishes no build and cannot be imported; 3.25.1, the same day, is the first release that loads. This fact's probe rows, run against that subpath, read the same as at 4.0.0 on 3.25.1, 3.25.20, 3.25.50 and 3.25.76, so a project importingzod/v4has had this behaviour since 2025-05-19.introduced_instays 4.0.0 because it dates whatimport from "zod"gives, and that is v3 on every 3.25.x release.
Reproduced against: no model yet. Verified from the primary source only — this is a correction, not an Index entry.
Source: Zod 4.4.0 release notes · 2026-04-29 · Bisected against the published packages (tools/audit/bisect-facts.mjs, 2026-09-06) · 2026-09-06
record key transforms
No release changed this · true on 21 releases executed, zod 3.22.4 through 4.6.2
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.
z.record(z.string().transform((k) => k.toUpperCase()), z.number()).parse({ foo: 1 });
// { FOO: 1 }
CORRECTED TWICE, and the second correction needed a schema change. Dated 4.4.0 from the release note until 2026-09-06, then moved to 4.0.0 by a bisect whose ladder BOTTOMED OUT at 4.0.0 — the probe was true at the lowest rung and the runner read the floor as a boundary (JOURNAL/076). Re-run 2026-09-07 against a ladder extended below the major (3.22.4, 3.23.8, 3.24.4, 3.25.76): true at all nineteen rungs. Until 2026-09-08 the entry carried
introduced_in: 4.0.0anyway and said in its own statement that nothing happened there, because the schema had no way to file a fact under no release; it now useschange_kind: "invariant"with a null version and the measured range (JOURNAL/080). The stale belief is unaffected and still charges: a model that thinks a key transform is inert is wrong on every release tested, which makes this a stronger correction than a dated one, not a weaker one.
Reproduced against: no model yet. Verified from the primary source only — this is a correction, not an Index entry.
Source: Zod 4.4.0 release notes · 2026-04-29 · Bisected against the published packages (tools/audit/bisect-facts.mjs, 2026-09-06) · 2026-09-06 · zod 3.22.4, shipped package — the same key-transform probe, executed
Deprecated, or a better API now exists
Works today. It is the older idiom, and some of it is scheduled for removal.
custom error messages
Deprecated in zod 4.0.0 (2025-07-09) · boundary measured on 21 releases, zod 3.22.4 through 4.6.2
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.
// Stale
z.string({ required_error: "Required" }); // accepted and ignored from 4.0.0; the issue carries zod's default message
// Current
z.string({ error: "Required" });
Bisected 2026-09-07 (BACKLOG 11k-t-iii), the first execution this fact ever had: the ladder's floor used to BE 4.0.0, so there was no rung below the claim for a probe to be false at. Three rows, one per spelling.
erroris honoured from 4.0.0 and ignored on all four 3.x rungs (confirmed).messageis honoured on all nineteen rungs of both majors (invariant).required_errorandinvalid_type_errorare honoured on the 3.x rungs and silently dropped from 4.0.0 through 4.5.4 (confirmed). The published statement said only "Notmessage,invalid_type_errororrequired_error", which told a reader to rewrite the one spelling that works and gave no warning that the other two fail without a word. The date was right; what the fact does was not. Date normalised 2026-09-07 from 2025-07-10 to the registry's own publish date for 4.0.0 (2025-07-09); the whole 4.0.0 cohort carried the announcement's day and was the only group in the file that did not match releases.json. No charge moves. SUBPATH MEASURED 2026-09-28 (JOURNAL/330, BACKLOG 0d-vi(iii)): Zod 4 shipped first inside zod@3.25.x as thezod/v4subpath. 3.25.0 (2025-05-19) publishes no build and cannot be imported; 3.25.1, the same day, is the first release that loads. This fact's probe rows, run against that subpath, read the same as at 4.0.0 on 3.25.1, 3.25.20, 3.25.50 and 3.25.76, so a project importingzod/v4has had this behaviour since 2025-05-19.introduced_instays 4.0.0 because it dates whatimport from "zod"gives, and that is v3 on every 3.25.x release.
Reproduced against: Claude Haiku 4.5 (S3), Claude Sonnet 5 (S3) — zod/v1, 2026-08-28; zod/v1, 2026-08-29.
Source: Zod 4 changelog — Error Customization · Bisected against the published packages, 19 rungs from 3.22.4 to 4.5.4 (tools/audit/bisect-facts.mjs, 2026-09-07) · 2026-09-07
string format validators
Deprecated in zod 4.0.0 (2025-07-09) · boundary measured on 21 releases, zod 3.22.4 through 4.6.2
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.
// Stale
z.string().email();
// Current
z.email();
Date normalised 2026-09-07 from 2025-07-10 to the registry's own publish date for 4.0.0 (2025-07-09); the whole 4.0.0 cohort carried the announcement's day and was the only group in the file that did not match releases.json. No charge moves. SUBPATH MEASURED 2026-09-28 (JOURNAL/330, BACKLOG 0d-vi(iii)): Zod 4 shipped first inside zod@3.25.x as the
zod/v4subpath. 3.25.0 (2025-05-19) publishes no build and cannot be imported; 3.25.1, the same day, is the first release that loads. This fact's probe rows, run against that subpath, read the same as at 4.0.0 on 3.25.1, 3.25.20, 3.25.50 and 3.25.76, so a project importingzod/v4has had this behaviour since 2025-05-19.introduced_instays 4.0.0 because it dates whatimport from "zod"gives, and that is v3 on every 3.25.x release.
Reproduced against: Claude Haiku 4.5 (S3) — zod/v1, 2026-08-28.
Source: Zod 4 changelog — String Format Validators
ZodError formatting
Deprecated in zod 4.0.0 (2025-07-09) · boundary measured on 21 releases, zod 3.22.4 through 4.6.2
Shape errors for a form with z.treeifyError(result.error) or z.flattenError(result.error), not the .flatten() / .format() methods on the error object.
// Stale
result.error.flatten();
// Current
z.treeifyError(result.error);
Date normalised 2026-09-07 from 2025-07-10 to the registry's own publish date for 4.0.0 (2025-07-09); the whole 4.0.0 cohort carried the announcement's day and was the only group in the file that did not match releases.json. No charge moves. SUBPATH MEASURED 2026-09-28 (JOURNAL/330, BACKLOG 0d-vi(iii)): Zod 4 shipped first inside zod@3.25.x as the
zod/v4subpath. 3.25.0 (2025-05-19) publishes no build and cannot be imported; 3.25.1, the same day, is the first release that loads. This fact's probe rows, run against that subpath, read the same as at 4.0.0 on 3.25.1, 3.25.20, 3.25.50 and 3.25.76, so a project importingzod/v4has had this behaviour since 2025-05-19.introduced_instays 4.0.0 because it dates whatimport from "zod"gives, and that is v3 on every 3.25.x release.
Reproduced against: Claude Haiku 4.5 (S3) — zod/v1, 2026-08-28.
Source: Zod 4 changelog — Error Formatting
schema.validate() / schema.validateAsync() (methods)
Added in zod 4.6.0 (2026-09-09) · boundary measured on 21 releases, zod 3.22.4 through 4.6.2 · published after every cutoff in this dataset — listed for completeness, no model can be faulted for it yet
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.
// Stale
// zod 4.5.x and earlier: the result object and its error are built on every rejection
const ok = Player.safeParse(data).success;
// Current
// zod 4.6.0 and later
const ok = Player.validate(data); // true or false, no ZodError built
const okAsync = await Player.validateAsync(data); // required when a refinement is async
// zod 4.5.x: z.validate(Player, data). The method does not exist there.
Written 2026-09-11 (JOURNAL/144) from execution, with the 4.6.0 release note and PR #6547 read first. Probe row LF37a (
.validate()on a string, a refined string, an object and a compiled object, agreeing withz.validate()on four inputs) rises at 4.6.0, contiguous on all 21 rungs; LF37b (an async refinement:.validate()throws,.validateAsync()returns a promise that resolves true, false for a failing refinement and false for a wrong type) rises at 4.6.0. LF37c (zod/minihasz.validateand its schemas have no.validateor.validateAsync) and LF37d (the top-levelz.validateAsync(), which LF30 does not name) both rise at 4.5.0. The PR says the methods narrow to the schema's INPUT type (soz.string().transform((s) => s.length).validate(v)narrowsvtostring). That is a type-level claim and was not type-checked here. The throw on an async refinement names.parseAsync()in its message, not.validateAsync(). The stale_code and correct_code were executed as written on all 21 rungs: the stale form gives the same boolean as the method on both 4.6 rungs and runs on every rung, and the method form throws a TypeError on every rung below 4.6.0. Published after every stated cutoff in the corpus, so this fact charges no subject. It is here for the agent reading the pack.
Reproduced against: no model yet. Verified from the primary source only — this is a correction, not an Index entry.
Source: Zod 4.6.0 release notes · 2026-09-09 · zod PR #6547 (commit 62e6624b), merged 2026-09-01, as listed in the 4.6.0 release notes · 2026-09-01 · Executed against the published packages (tools/audit/bisect-facts.mjs rows LF37a-LF37d, 2026-09-11) · 2026-09-11
z.iban()
Added in zod 4.6.0 (2026-09-09) · boundary measured on 21 releases, zod 3.22.4 through 4.6.2 · published after every cutoff in this dataset — listed for completeness, no model can be faulted for it yet
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.
// Stale
// a shape-only pattern: no checksum
const Iban = z.string().regex(/^[A-Z]{2}\d{2}[A-Z0-9]{11,30}$/);
Iban.safeParse("DE89370400440532013001").success; // true, but the checksum is wrong
// Current
// zod 4.6.0 and later. z.iban() takes the electronic form only, so strip the printed grouping first.
const Iban = z.preprocess(
(s) => (typeof s === "string" ? s.replace(/\s+/g, "").toUpperCase() : s),
z.iban(),
);
Iban.parse("de89 3704 0044 0532 0130 00"); // "DE89370400440532013000"
Iban.safeParse("DE89370400440532013001").success; // false: bad checksum
// No per-country length check is included. Add a .refine() if you need one.
Written 2026-09-11 (JOURNAL/144) from execution, with the 4.6.0 release note and PR #6571 read first. Probe row LF38a (two valid IBANs and a valid
02check-digit IBAN pass; a bad checksum, the spaced form, the lowercase form and the99twin of the02IBAN fail, and99satisfies mod 97 as02does) rises at 4.6.0, contiguous on all 21 rungs. LF38b (with a valid checksum, the nonexistent countryz.string().iban;z.string().regex(z.regexes.iban)accepts a bad checksum thatz.iban()rejects) rise at 4.6.0. The PR states the registry omission as a decision, followingz.creditCard()(LF31), and puts the 02-98 bound in the pattern soz.toJSONSchema()carries it: the emitted schema isformat: "iban"plus that pattern. The stale_code and correct_code were executed as written on all 21 rungs. The stale pattern accepts the bad checksum on every rung. The correct code gives the two commented results on 4.6.0 and 4.6.2 and throws on every rung below, wherez.ibandoes not exist. Published after every stated cutoff in the corpus, so this fact charges no subject. It is here for the agent reading the pack.
Reproduced against: no model yet. Verified from the primary source only — this is a correction, not an Index entry.
Source: Zod 4.6.0 release notes · 2026-09-09 · zod PR #6571 (commit 22bed613), merged 2026-09-09, as listed in the 4.6.0 release notes · 2026-09-09 · Executed against the published packages (tools/audit/bisect-facts.mjs rows LF38a-LF38c, 2026-09-11) · 2026-09-11
z.withParser() / z.INVALID
Added in zod 4.6.0 (2026-09-09) · boundary measured on 21 releases, zod 3.22.4 through 4.6.2 · published after every cutoff in this dataset — listed for completeness, no model can be faulted for it yet
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.
// Stale
// zod 4.6: a parser that checks the shape and forgets the refinement
const Adult = z.object({ age: z.number() }).refine((u) => u.age >= 18);
const Fast = z.withParser(Adult, (i) => (typeof i?.age === "number" ? { age: i.age } : z.INVALID));
Fast.safeParse({ age: 12 }).success; // true: the refinement never ran
// Current
// zod 4.6.0 and later. The parser enforces every check and returns z.INVALID for anything it rejects.
const Adult = z.object({ age: z.number() }).refine((u) => u.age >= 18);
const Fast = z.withParser(Adult, (i) =>
typeof i?.age === "number" && i.age >= 18 ? { age: i.age } : z.INVALID,
);
Fast.safeParse({ age: 12 }).success; // false: the runtime parses it and the refinement rejects it
Fast.parse({ age: 30, extra: 1 }); // { age: 30 }
Written 2026-09-11 (JOURNAL/144) from execution, with the 4.6.0 release note and PR #6575 read first. Probe row LF39a (the result is a new schema; an identity parser keeps an unknown key the base object would strip; a parser returning 42 for a string input is trusted, while the base schema still rejects the string) rises at 4.6.0, contiguous on all 21 rungs. LF39b (
z.INVALIDfor every input: 5 parses to 5, and "x" fails with the runtime'sinvalid_typeissue) and LF39c (z.number().refine((n) => n > 10)rejects 5, and the same schema behind an identity parser accepts it) rise at 4.6.0. Also executed on the 4.6 rungs and not given a row: an error the parser throws propagates as it is and is not turned into a ZodError; the parser is called with one argument; andz.withParser(schema, 5)does not throw at construction.z.INVALIDisSymbol(zod.compile.invalid)on 4.6.0 and 4.6.2,undefinedon every rung from 4.0.0 through 4.5.4, and the object{ status: "aborted" }on all four 3.x rungs. The stale_code and correct_code were executed as written on all 21 rungs: the two commented results (true and false) and{ age: 30 }on 4.6.0 and 4.6.2, and both throw below 4.6.0, wherez.withParserdoes not exist. Published after every stated cutoff in the corpus, so this fact charges no subject. It is here for the agent reading the pack.
Reproduced against: no model yet. Verified from the primary source only — this is a correction, not an Index entry.
Source: Zod 4.6.0 release notes · 2026-09-09 · zod PR #6575 (commit 213ee75d), merged 2026-09-09, as listed in the 4.6.0 release notes · 2026-09-09 · Executed against the published packages (tools/audit/bisect-facts.mjs rows LF39a-LF39c, 2026-09-11) · 2026-09-11
z.creditCard() / z.properties() / .deepPartial() / .exactPartial()
Added in zod 4.5.0 (2026-08-28) · boundary measured on 21 releases, zod 3.22.4 through 4.6.2 · published after every cutoff in this dataset — listed for completeness, no model can be faulted for it yet
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.
z.creditCard().safeParse("4242424242424242").success; // true (Luhn)
z.deepPartial(z.object({ a: z.object({ b: z.string() }) })); // top-level function
z.object({ a: z.string() }).exactPartial(); // method
Published until 2026-09-06 as ".deepPartial()", which reads as a method and is not one. Every other clause of this fact bisects clean to 4.5.0.
Reproduced against: no model yet. Verified from the primary source only — this is a correction, not an Index entry.
Source: Zod 4.5.0 release notes · 2026-08-28 · Bisected against the published packages (tools/audit/bisect-facts.mjs, 2026-09-06) · 2026-09-06
z.validate()
Added in zod 4.5.0 (2026-08-28) · boundary measured on 21 releases, zod 3.22.4 through 4.6.2 · published after every cutoff in this dataset — listed for completeness, no model can be faulted for it yet
z.validate(schema, input) returns a boolean without a full parse — a fast path when you only need validity.
Reproduced against: no model yet. Verified from the primary source only — this is a correction, not an Index entry.
Source: Zod 4.5.0 release notes · 2026-08-28
.superRefine({ when })
Added in zod 4.4.0 (2026-04-29) · boundary measured on 21 releases, zod 3.22.4 through 4.6.2
.superRefine() takes a when option for conditional refinement, so a guard clause inside the callback is no longer the only way to express it.
Reproduced against: no model yet. Verified from the primary source only — this is a correction, not an Index entry.
Source: Zod 4.4.0 release notes · 2026-04-29
empty z.union([]) / z.xor([])
Behaviour changed in zod 4.4.0 (2026-04-29) · boundary measured on 21 releases, zod 3.22.4 through 4.6.2
Empty unions and discriminated unions construct without crashing and fail at parse time instead of construction time.
4.4.0 is a repair, not a new behaviour: 4.0.0 already constructed an empty union, a 4.0.x patch broke it, and it stayed broken from 4.0.17 through 4.3.6. A reader on any 4.0.17-4.3.x release does hit the construction-time crash the note describes. 2026-09-07 (JOURNAL/076), with the ladder extended below the major: the behaviour is also true on 3.22.4 through 3.25.76, so an empty union constructed and deferred its failure for the whole of zod 3 as well. 4.4.0 restores the original behaviour rather than introducing one, and the 4.0.17-4.3.6 window is the only stretch in either major line where the construction crashes.
Reproduced against: no model yet. Verified from the primary source only — this is a correction, not an Index entry.
Source: Zod 4.4.0 release notes · 2026-04-29 · Bisected against the published packages (tools/audit/bisect-facts.mjs, 2026-09-06) · 2026-09-06
z.cuid()
Stricter in zod 4.4.0 (2026-04-29) · boundary measured on 21 releases, zod 3.22.4 through 4.6.2
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.
// Stale
z.cuid().safeParse("cABCDEFGH").success; // true before 4.4.0, false from 4.4.0
z.cuid().safeParse("cjld2cj").success; // false before 4.4.0, TRUE from 4.4.0
// Current
// A leading c plus 6+ lowercase alphanumerics. Prefer z.cuid2() for new ids.
z.cuid().safeParse("cjld2cjxh0000qzrmn831i7rn").success; // true
The release note says only "tightened", which is half the change: the length floor moved the other way, so a 7-character id that 4.3.x rejected is accepted from 4.4.0. The deprecation of CUID v1 is documentary — no shipped 4.x release rejects a well-formed v1 id, and no
@deprecatedtag is present in the 4.4.0 or 4.5.4 typings.
Reproduced against: no model yet. Verified from the primary source only — this is a correction, not an Index entry.
Source: Zod 4.4.0 release notes · 2026-04-29 · Bisected against the published packages (tools/audit/bisect-facts.mjs, 2026-09-06) · 2026-09-06
.with()
Added in zod 4.3.0 (2025-12-31) · boundary measured on 21 releases, zod 3.22.4 through 4.6.2
.with() is the readable alias for .check(), added because not everything composable is a 'check'.
z.string().with(z.minLength(5), z.toLowerCase());
Reproduced against: no model yet. Verified from the primary source only — this is a correction, not an Index entry.
Source: Zod 4.3.0 release notes · 2025-12-31 · zod@4.5.4 published type declarations, v4/classic/schemas.d.ts
z.function()
Behaviour changed in zod 4.0.0 (2025-07-09) · boundary measured on 21 releases, zod 3.22.4 through 4.6.2
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.
const fn = z.function({ input: [z.string()], output: z.number() })
.implement((s) => s.length);
Bisected 2026-09-07 (BACKLOG 11k-t-iii), the first execution this fact ever had. The dated clause holds: on the four 3.x rungs
z.function({ input, output })returns a ZodFunction schema whose.implement()throws (me._def.args.safeParse is not a function), and from 4.0.0 the{ input, output }factory works and.implementAsync()exists — both confirmed at 4.0.0, false at 3.25.76. The clause that did not hold is "not a schema": 4.0.0 and 4.0.17 expose a bare$ZodFunctionwith no.parseand no.safeParse, and 4.1.0 restores both. So the sentence was true of the two releases anyone bisecting a v3->v4 migration would have checked, and false of the eleven 4.x releases after them — the second window-shaped fact this file has (see LF5), found the same way. Date normalised 2026-09-07 from 2025-07-10 to the registry's own publish date for 4.0.0 (2025-07-09); the whole 4.0.0 cohort carried the announcement's day and was the only group in the file that did not match releases.json. No charge moves. SUBPATH MEASURED 2026-09-28 (JOURNAL/330, BACKLOG 0d-vi(iii)): Zod 4 shipped first inside zod@3.25.x as thezod/v4subpath. 3.25.0 (2025-05-19) publishes no build and cannot be imported; 3.25.1, the same day, is the first release that loads. This fact's probe rows, run against that subpath, read the same as at 4.0.0 on 3.25.1, 3.25.20, 3.25.50 and 3.25.76, so a project importingzod/v4has had this behaviour since 2025-05-19.introduced_instays 4.0.0 because it dates whatimport from "zod"gives, and that is v3 on every 3.25.x release.
Reproduced against: no model yet. Verified from the primary source only — this is a correction, not an Index entry.
Source: Zod 4 changelog — z.function() · Bisected against the published packages, 19 rungs from 3.22.4 to 4.5.4 (tools/audit/bisect-facts.mjs, 2026-09-07) · 2026-09-07
Wrong facts about the library
Not code — versions, minimums and metadata that models state confidently and get wrong.
installing and importing zod
Behaviour changed in zod 4.0.0 (2025-07-09) · boundary measured on 21 releases, zod 3.22.4 through 4.6.2
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.
import * as z from "zod";
Date normalised 2026-09-07 from 2025-07-10 to the registry's own publish date for 4.0.0 (2025-07-09); the whole 4.0.0 cohort carried the announcement's day and was the only group in the file that did not match releases.json. No charge moves.
Reproduced against: Claude Sonnet 5 (S4) — zod/v1, 2026-08-29.
Source: Zod 4 changelog
Not corrections — recorded for honesty
These are real reproduced failures that we do not charge to the model, because the release that made the belief wrong was published after that model's stated cutoff. They are scheduled retests, not passes.
- Claude Fable 5 · claims Zod cannot enforce key presence with an undefined value — 4.4.0 postdates this subject's stated 2026-01 cutoff. Recorded as context and as a retest target for the next Fable release.
- Claude Sonnet 5 · claims Zod cannot distinguish a missing key from an explicit undefined — 4.4.0 postdates this subject's stated 2026-01 cutoff, so this is outside the probe fairness window and is recorded as context, not charged. It IS chargeable against Opus 5 (F6 of that run), whose cutoff is 2026-05.
- Claude Sonnet 5 · wrong runtime prediction for tuple defaults — Same as F5 — 4.4.0 postdates the stated 2026-01 cutoff. Recorded as context and as a retest target.
- Claude Sonnet 5 · Denies that the library has any way to make an object key omittable while rejecting an explicit undefined value, and argues the gap is structural — NOT chargeable against this draw. 4.3.0 published 2025-12-31; this draw stated its own cutoff as "roughly early-to-mid 2025" and explicitly repudiated the January 2026 its environment reported. Under the probe fairness rule a finding is chargeable only where the release precedes the subject's STATED cutoff, and this one does not. It becomes chargeable against any draw of this subject that states a cutoff after 2025-12-31 - which this battery's own blind twin did, in the arm that charges nothing.
- Claude Sonnet 5 · Denies that the library can consume a JSON Schema document at runtime and prescribes Ajv as a new dependency — NOT chargeable against this draw, for the same reason as F1: 4.2.0 published 2025-12-15, after the "early-to-mid 2025" this draw states for itself.
- Claude Sonnet 5 · Denies that the library has an exclusive union primitive — NOT chargeable against this draw's stated cutoff of "roughly early-to-mid 2025".
- Claude Sonnet 5 · States that an intersection of two strict object schemas cannot parse an object carrying keys from both sides — NOT chargeable against this draw's stated cutoff.
Claims seen in a run but not yet verified against a primary source. Never treated as findings:
- Does the corpus want a charging rule for the inverse of a stale prior - a belief that is false for every release below the subject's cutoff and true of the library as it stands today? (open since 2026-09-22)
- Is
z.fromJSONSchema()'s throw-on-unsupported list documented anywhere as a list, and does it namecontains? (open since 2026-09-22) - Zod 4 coercion accepts unknown input and changed missing-key behaviour for objects with coerced fields. Does this subject know either change? (open since 2026-08-28)
- This draw named
.safeExtend()as a 4.1 addition that "exists precisely because plain.extend()handles existing object-level checks unsoundly" while simultaneously asserting that Zod 4 lifted the restriction on.pick()/.extend()over refined schemas. Those two statements are in tension and the battery has no way to score a subject that holds both. The Index's finding F2 charges the second; the first is closer to right than anythingzod/v2recorded. (open since 2026-09-01)
Findings, code and citations: data/zod/ — one JSON file and one write-up per model, each finding carrying the release that broke the belief, its publication date and a verbatim quote from the primary source. Corrections: data/zod/facts.json. This file is generated by tools/build-corrections.mjs; if the prose and the data ever disagree, that is a bug in the generator, not a stale pack.
The Stale Priors Index, Unattended Works (https://stalepriors.com/corrections/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>.