If you have spent any time with Zod, you have probably written something like this without thinking twice:
function validateUser(input: unknown) {
const schema = z.object({ name: z.string(), age: z.number() });
return schema.parse(input);
}
It looks innocent, and it works perfectly. But there is a subtle cost hiding in plain sight: that z.object({...}) call rebuilds the entire schema from scratch every single time the function runs. Inside a React component body, that is a fresh schema on every render. Inside a hot server handler, that is a fresh schema on every request. The schema is logically constant, yet the runtime keeps paying to reconstruct it.
babel-plugin-zod-hoist is a Babel plugin by Gajus Kuizinas that fixes this for you at build time. It detects schemas defined inside functions, lifts them out to module scope so they are constructed once at load time, and rewrites the original spot to reference the hoisted constant. You keep writing perfectly normal Zod code, and the optimization happens during compilation with zero source changes.
Why Rebuilding a Schema Hurts
Constructing a Zod schema is not free. Calling z.object(), z.string(), z.discriminatedUnion() and friends allocates objects, wires up internal validation methods, and assembles the schema's internal representation. Doing that once at module load is the right amount. Doing it on every call is wasted CPU and extra pressure on the garbage collector.
The canonical fix has always been to hoist the schema yourself:
const schema = z.object({ name: z.string(), age: z.number() }); // built once
function validateUser(input: unknown) {
return schema.parse(input);
}
That is exactly what good Zod codebases do by hand. The trouble is that the inline pattern is easy to write, easy to miss in review, and tends to creep back in. This plugin performs the hoist automatically, so codebases that already have the anti-pattern get fixed for free, and new instances stop mattering.
How much does it help? Benchmarks in the README, measured with Zod 4.3.5 on Node 22, report that hoisting is 113 to 627 times faster depending on the schema shape. A simple object schema lands around 402x, and a discriminated union climbs to roughly 627x. The richer the schema, the bigger the win, because nested objects, arrays, and unions all cost more to assemble.
One important nuance: this speedup is about construction cost that you no longer pay, not about making .parse() itself faster. More on that distinction later.
Getting It Into Your Build
Install it as a dev dependency, since it only runs at build time:
npm install --save-dev babel-plugin-zod-hoist
yarn add --dev babel-plugin-zod-hoist
Then add it to your Babel config (.babelrc or babel.config.json):
{
"plugins": ["babel-plugin-zod-hoist"]
}
That is the whole setup. It works anywhere Babel sits in the build chain, including Next.js, Create React App via Babel, and custom Babel pipelines. There is nothing to import in your application code and nothing to annotate.
Watching the Transform Work
Lifting Inline Schemas
The core behavior is straightforward. A schema created inside a function gets extracted to module scope under a generated, hashed name, and the original location becomes a reference to it.
// Before
function getSchema() {
return z.object({ name: z.string() });
}
// After
const _schema_94b7f = z.object({ name: z.string() });
function getSchema() {
return _schema_94b7f;
}
The function now hands back the same pre-built object on every call instead of constructing a new one. The hashed name (_schema_94b7f) is internal and you will only ever see it if you inspect compiled output.
Hoisting Derived Schemas Above Imports
The plugin goes a step further and handles derived schemas: chains built from imported base schemas using combinators like .extend(), .pick(), .omit(), and .merge().
import { UserZodSchema } from "./schemas";
function buildPublicUser() {
return UserZodSchema.pick({ id: true, name: true });
}
Because ES modules initialize their imports before any other module code runs, the plugin can safely place a hoisted derived schema above the import statements. By the time the derived schema is constructed, the imported base schema is already initialized, so the ordering is sound. This is a nice detail, because derived chains are common in real applications where one canonical schema spawns many trimmed variants.
Going Deeper
Tuning What Counts as a Schema
Derived chains are matched by the base identifier name, and you control the matcher with the schemaNamePattern option. By default it is the RegExp /ZodSchema$/, so chains derived from identifiers ending in ZodSchema are eligible.
If your project uses a different naming convention, point the pattern at it:
// babel.config.js
module.exports = {
plugins: [
["babel-plugin-zod-hoist", { schemaNamePattern: /Shape$/ }],
],
};
The option accepts three forms:
- A RegExp, as above, for the most direct control.
- A string, which is compiled to a RegExp. This is handy for JSON configs that cannot hold a RegExp literal:
{ "schemaNamePattern": "Shape$" }. null, which disables name-based matching entirely. Withnull, only chains that contain an inlinez.*call are eligible, and identifier names are ignored.
When a base identifier matches the pattern, derived chains built from it get hoisted even if the chain itself contains no explicit z namespace call. That is what lets a bare UserZodSchema.pick({...}) qualify.
The Safety Rules That Keep It Honest
A naive "move everything to the top" transform would be dangerous. This one is deliberately conservative and refuses to hoist anything that could change runtime behavior or trigger a temporal dead zone (TDZ) error. A schema is left exactly where it is when it:
- depends on local variables, such as closure or parameter values,
- references
this, or - depends on top-level
const,let, orvarbindings that could cause a TDZ error if the schema were moved above them.
So a schema that closes over a parameter stays put:
function rangeSchema(max: number) {
// Not hoisted: it depends on `max`, a local parameter.
return z.number().max(max);
}
The rule of thumb is simple: only schemas that are provably constant, with no local or temporal dependencies, get moved. Everything else is untouched. This is the kind of conservative design that lets you adopt the plugin without auditing every schema in your codebase first, since a schema that is not safe to hoist simply behaves as it always did.
Where It Fits, and Where It Doesn't
A few honest caveats are worth keeping in mind:
- It is Babel-only. Projects on pure SWC, esbuild, or Vite without a Babel pass will not get the transform, though Vite and Next can be configured to run Babel if you want it.
- It targets construction, not validation. The plugin avoids rebuilding schemas; it does not make
.parse()or.safeParse()run faster. If your bottleneck is validation throughput rather than schema setup, you want a compiler approach instead. - It is geared toward Zod 4, depending on
zod ^4.3.5.
That validation distinction is the key one. If you also need faster validation, the same author maintains zod-compiler, which compiles schemas into low-overhead validation functions at build time and works across Vite, webpack, esbuild, and Rollup. The two tools attack different costs and pair nicely: hoisting removes repeated construction, compilation removes per-call validation overhead.
The Takeaway
babel-plugin-zod-hoist is about as low-friction as a performance win gets. There is no API to learn, no code to refactor, and no annotations to sprinkle around. You add one plugin entry, keep writing idiomatic Zod, and a common and genuinely costly anti-pattern, schemas built inside components and handlers, quietly stops mattering. The conservative safety model means it only acts when it is provably safe, and the optional schemaNamePattern gives you room to match your own conventions when you want derived schemas hoisted too.
If your app validates a lot of data and you have ever defined a schema inside a render or a request handler, which is to say almost everyone, this plugin is close to free speed. Drop it in, ship it, and let the build step do the tidying that you would otherwise have to remember every time.