12k
All articles

Making Zod Validation Faster With Compiled Schemas

See how Zod compiled schemas speed up validation, where gains matter, and how to handle compile costs, unsupported schemas, CSP restrictions, and Cloudflare Workers.

OpenReplay Team
OpenReplay Team
Making Zod Validation Faster With Compiled Schemas

Since Zod 4.5.0, z.compile() turns a schema into a specialised JavaScript validator. It checks valid input faster and returns exactly the same results and errors as the uncompiled schema.

If your handler parses the same large request body thousands of times a minute, a validation layer that walks the schema tree on every call shows up in your CPU profiles. The Zod 4.5 release added a fix for that. This article follows on from OpenReplay’s guide to validating data in TypeScript with Zod. It covers how to turn compilation on, what it costs, where the speedup is large enough to matter, and what happens under a strict Content Security Policy or on runtimes such as Cloudflare Workers.

Key Takeaways

  • z.compile(schema) builds a validator with no loops or branching walk and runs it through new Function(). Valid input is checked by a plain run of typeof tests rather than by stepping through the schema tree.
  • Invalid input gains almost nothing, because a failed parse runs the fast path first and then the full standard parser to build the errors.
  • In Zod’s own tight-loop benchmark, compiled objects with 5, 10, 20 and 50 keys run 1.8x, 2.2x, 5.0x and 10.2x faster, so small schemas gain little.
  • The compiler adds about 7 KB gzipped to any bundle that calls z.compile() or imports zod/compile.
  • { strict: true } makes uncompilable schemas throw ZodCompileAsyncError or ZodCompileUnsupportedError instead of silently falling back.

How Do Compiled Zod Schemas Work?

A compiled Zod schema validates input with generated code instead of walking the schema tree. Zod reads the whole schema a single time, writes out a short piece of JavaScript with no loops in it, and turns that into a function with new Function(). From then on, valid input is checked by reading each property and testing its typeof, one line after another. If the fast path rejects an input, Zod runs the standard parser on it. That is why a compiled schema reports the same issues and error messages as the original. You still use .parse(), .safeParse() and the same inferred types.

Turning On Compilation

You can opt in two ways: compile specific schemas with z.compile(), or compile every schema in the app by importing zod/compile globally. Per-schema compilation gives you precise control over hot paths. Global mode needs only one line.

Per-Schema With z.compile()

z.compile() gives you back a new, compiled schema. The schema you passed in stays as it was. Any method that builds a new schema from a compiled one, such as .refine(), .extend() or .optional(), hands back an uncompiled schema. Build the finished schema first and compile it last:

import * as z from "zod";

const Base = z.object({
  id: z.string(),
  type: z.string(),
  createdAt: z.number(),
});

const notInFuture = (e: { createdAt: number }) => e.createdAt <= Date.now();

// ❌ .refine() returns a new schema, and it is not compiled
const Wrong = z.compile(Base).refine(notInFuture);

// ✅ finish the schema, then compile it
const WebhookEvent = z.compile(Base.refine(notInFuture));

const result = WebhookEvent.safeParse(payload);

Nothing tells you at runtime that Wrong is uncompiled. It validates correctly, just more slowly. The strict option (covered below) won’t catch this either, because it only throws when a schema can’t be compiled at all. The safe habit is to make z.compile() the last call in the chain.

Globally With zod/compile

With compile-by-default, Zod compiles each schema you create after the import. It does this lazily, on that schema’s first parse:

import "zod/compile"; // must run before any module that defines schemas
import * as z from "zod";

const User = z.object({ name: z.string() });
User.parse({ name: "ok" }); // compiled on first parse

Load order is easy to get wrong in ESM, so let the runtime load the module first:

node --import zod/compile app.js    # ESM
node --require zod/compile app.cjs  # CommonJS

Bun users can list zod/compile under preload in bunfig.toml instead. Global mode is meant for applications. Libraries shouldn’t turn it on for their consumers.

What Does Zod Compilation Cost?

Zod schema compilation has three costs, and it does little for invalid input. A rejected parse runs the fast path, fails, and then runs the full standard parser, which takes up almost all of the time. The three costs are:

  1. One-time compile work for each schema, done when the schema is compiled or, in global mode, on its first parse.
  2. Bundle size. The compiler adds about 7 KB gzipped (28 KB minified). Zod’s own table puts a four-key object schema at 31.1 KB gzipped with the compiler, up from 24.1 KB. For Zod Mini the jump is bigger, from 4.6 KB to 13.2 KB. Bundles that never call z.compile() or import zod/compile drop the compiler entirely during tree-shaking.
  3. Double execution on failure. A refinement or transform runs a single time when the input is valid, but can run two times when it isn’t. A refinement that logs, counts or writes will do so twice for a rejected webhook.

At a request boundary with sustained traffic, the one-time work pays for itself quickly. In a one-off script or a CLI that parses one config file, the compile work and extra bytes may never be recovered. Traffic dominated by bad input, such as bot probes or forged webhook signatures, also recovers little.

Where Do Zod Performance Gains Show?

Zod performance gains from compilation grow with schema size. Wide objects and tuples benefit most, because the generated code checks each key in turn without the standard parser’s per-key loop. Zod’s own benchmark times every schema on its own, over and over in a tight loop. The standard parser does its best in that setting, so the gains here are smaller than in the main chart at the top of the same page.

SchemaSpeedup
Object, 5 keys1.8x
Object, 10 keys2.2x
Object, 20 keys5.0x
Object, 50 keys10.2x
Tuple, 1 item2.2x
Tuple, 3 items2.5x
Tuple, 5 items3.0x
Tuple, 10 items3.7x

A three-field login form won’t notice the difference. A 50-key event payload parsed on every request will. Larger figures, such as the “up to 44x” (and up to 46x on rejected input) quoted for zod-compiler, come from separate third-party tools that generate validators at build time. They don’t describe Zod’s built-in runtime compiler.

Which Zod Schemas Won’t Compile?

Some Zod schema features can’t be compiled. When z.compile() meets one, it doesn’t throw. It quietly gives you back the schema you passed in, with no compilation. The unsupported list determines whether you lose the whole schema or just one child:

FeatureEffect
Async refinements, transforms or checks anywhere in the treeWhole schema falls back
.catch() with a callback (.catch(value) compiles)Whole schema falls back
Union with an unsupported memberWhole schema falls back
z.xor(), recursive schemas, z.coerce.*, checks with a custom whenUncompiled
Unsupported child inside an object, array, tuple, record or intersectionOnly that child uses the standard parser

Compilation never applies to z.encode() or to async parsing. Both always go through the standard parser. If your handlers call safeParseAsync, compiling the schema doesn’t help them.

To stop a later edit from quietly disabling compilation on a hot path, compile in CI with { strict: true }:

import { test } from "node:test";
import assert from "node:assert/strict";
import * as z from "zod";
import { WebhookEvent, OrderBody } from "../src/schemas.js";

test("hot-path schemas compile", () => {
  for (const schema of [WebhookEvent, OrderBody]) {
    assert.doesNotThrow(() => z.compile(schema, { strict: true }));
  }
});

test("async refinements are rejected under strict", () => {
  const Handle = z.string().refine(async (v) => v.length > 2);
  assert.throws(() => z.compile(Handle, { strict: true })); // ZodCompileAsyncError
});

With strict set, an async schema raises ZodCompileAsyncError, and any other schema Zod can’t compile raises ZodCompileUnsupportedError. Without strict, neither error is thrown.

Does z.compile() Work Under CSP and on Cloudflare Workers?

z.compile() fails safely but gives you nothing where dynamic code generation is forbidden. new Function() is blocked on any page whose Content Security Policy leaves 'unsafe-eval' out of script-src (or out of default-src when there is no script-src). Zod also names Cloudflare Workers as an environment where new Function() is blocked. If you set jitless, global mode switches itself off:

// config.ts: import this module before any module that defines schemas
import * as z from "zod";

z.config({ jitless: true });

Calling z.compile() yourself is different. Zod reads it as a clear request, so it tries to generate code even when jitless is on. If the environment blocks new Function, you simply get the schema back uncompiled. Validation keeps working. You have, however, shipped about 7 KB gzipped of compiler that can never run, so leave zod/compile and z.compile() out of builds that target those environments.

Conclusion

Compilation makes valid input faster to check without changing results or errors, and the gain grows with schema width. Start by profiling your request boundary. Compile your widest, busiest schemas last in their build chain, and add a strict test so they stay compilable. Keep the compiler out of builds for runtimes and CSP policies that block new Function. Install the current Zod 4 release rather than pinning 4.5.0, so you pick up fixes to the compiler.

FAQs

Is there a faster way than safeParse to reject invalid input in Zod?

Yes. Zod 4.6 added .validate(), which only tells you whether the input is valid. It skips building a ZodError, so turning bad input away costs very little. On a compiled schema with invalid input, it can be up to 35x faster than .safeParse().success. It also narrows the type: when it returns true, TypeScript treats the value as the schema's input type. Use it when you only need a yes or no answer, and keep .safeParse() when you need the error details.

Can I use a precompiled Zod parser in environments that block new Function?

Yes, from Zod 4.6. z.compile() does two jobs: it generates a parser and attaches it to the schema. z.withParser() does only the second job. You give it a parser made elsewhere, for example by a build step or a native compiler, and it attaches that parser with the same rules z.compile() follows. Because the generated code ships as ordinary JavaScript, the runtime never needs new Function.

What is the difference between z.compile() and zod-compiler?

z.compile() is built into Zod and generates validators at runtime with new Function(), inside your process. zod-compiler (gajus/zod-compiler) is a separate third-party tool that generates validators at build time, through bundler plugins for Vite, webpack, esbuild, Rollup and others, or through a CLI. Its build-time output is plain code with no eval, so CSP rules cannot disable it. Its optional runtime mode does use new Function, though. The current zod-compiler release requires Zod 4.5 or later, and its 1.x line covers Zod 4.0 to 4.4.

DevTools for the frontend

Gain Debugging Superpowers

Unleash the power of session replay to reproduce bugs, track slowdowns and uncover frustrations in your app. Get complete visibility into your frontend with OpenReplay — the most advanced open-source session replay tool for developers.

Star on GitHub12k

We use cookies to improve your experience. By using our site, you accept cookies.