This guide explains the key changes between zod-openapi v4 and v5.
You can migrate most of the way with our codemod:
pnpmx codemod-zod-openapi-v5 'src/**/*.ts'
npx codemod-zod-openapi-v5 'src/**/*.ts'You can see the code here: codemod-zod-openapi-v5.
✨ Using native Zod metadata
Zod v4 introduces a native .meta() method for metadata, eliminating the need for runtime extensions:
- No more runtime monkey-patching
- Removed:
extendZodWithOpenApi(z)andimport zod-openapi/extend
# Replace all schema definitions:
- z.string().openapi({ ... })
+ z.string().meta({ ... })💡 Build-time optimization: You can now declare
zod-openapias a dev dependency if you create your schemas at build time.
-
Zod v4 (3.25.74) required
- import { z } from 'zod'; + import * as z from 'zod/v4';
-
Node v20+ required
- Support for Node v18 has been dropped
The internal schema generation has been completely replaced with Zod's native toJSONSchema() method. This should result in an overall performance improvement.
However, this will result in some changes to how some schemas are generated. Please raise a GitHub issue if you find any significant discrepancies.
🆕 OpenAPI version requirements
- OpenAPI 3.1.0 is now the minimum required version
- Support for OpenAPI 3.1.1 has been added
✨ Lazy schemas supported natively
Zod automatically creates lazy components, so you can use
z.lazy()without any additional configuration. It is still recommended to provide anidfor lazy schemas.
🔄 Component references renamed: ref → id
- .openapi({ ref: 'MySchema' })
+ .meta({ id: 'MySchema' })Wherever you previously used ref to create components within parameters, request objects and responses, has been replaced with id for consistency with Zod's syntax.
📝 Input & Output Schema Generation
Zod's native toJSONSchema() handles object schemas differently than our previous implementation:
- 📥 Input schemas: Allow additional properties by default (no
additionalPropertiesspecified) - 📤 Output schemas: Strip extra properties (
additionalProperties: false)
This better aligns with how Zod objects actually works, where input schemas are more permissive and output schemas are stricter and allows for structured outputs for AI applications.
As a result, when a schema containing a zod object with an ID is used in both request and response contexts, the library automatically generates two separate component schemas to properly represent the different behaviors.
How it works:
- Register a schema with an ID:
const schema = z
.object({
name: z.string(),
age: z.number(),
})
.meta({
id: 'Person',
});- When used in both contexts, it automatically generates:
components: {
schemas: {
Person: {
type: 'object',
properties: {
name: { type: 'string' },
age: { type: 'number' },
},
},
PersonOutput: {
type: 'object',
properties: {
name: { type: 'string' },
age: { type: 'number' },
},
additionalProperties: false,
},
},
}Customization options:
- Custom output name:
.meta({ id: 'Person', outputId: 'CustomOutputName' }) - Global suffix: Set
outputIdSuffixinCreateDocumentOptionsto change from default "Output"
In certain cases, the same component can be used for both input and output contexts. This happens when the schemas are identical - for example, with primitive types like z.string().meta({ id: 'SimpleString'}). The library performs an equality check between input and output representations to determine when a single schema can be safely reused for both contexts.
For object schemas, you can explicitly control this behavior by using z.looseObject() (which allows additional properties) or z.strictObject() (which forbids them) instead of the standard z.object().
⚠️ Limitation: If your schema contains dynamically created lazy components, they won't be reused between input and output schemas.
⚡ Minor change: Manual schema generation
Previously, unsupported Zod schemas without a
typefield would throw an error. Now, any schema with fields will be accepted.You can modify this behavior with the new
allowEmptySchemaoption (see New Options below).
⚡ Minor change: .extend() behavior
- No longer creates an embedded
allOfreferenced schema - This functionality would need to be contributed to the main Zod library if desired
🔄 refType → unusedIO
⚙️ effectType
Transforms are not introspectable.
effectTypewas introduced to attempt to address this and to try and keep the transform locked to the same type as the input schema. For transform operations, use Zod's native.overwrite()method, wrap your schema in a.pipe(), or declare a manual type.
🔄 unionOneOf
🔄 enforceDiscriminatedUnionComponents
🔄 defaultDateSchema
These options are replaced by the new override system (see below)
Direct property setting:
This is native to Zod v4 and allows you to set properties directly on the schema.
z.date().meta({
type: 'string',
format: 'date-time',
});Note: Zod's native type representation takes precedence over the fields set in the
meta()method. You can provide anoverrideobject or function to override this behavior.
Simple example:
z.string().meta({
override: {
type: number,
},
});Advanced customization with functions:
// Example: achieving unionOneOf behavior
z.union([z.string(), z.number()]).meta({
override: ({ jsonSchema }) => {
jsonSchema.oneOf = jsonSchema.anyOf;
delete jsonSchema.anyOf;
},
});Global overrides:
import { createDocument } from 'zod-openapi';
createDocument(document, {
override: ({ jsonSchema, zodSchema, io }) => {
const def = zodSchema._zod.def;
if (def.type === 'date' && io === 'output') {
jsonSchema.type = 'string';
jsonSchema.format = 'date-time';
}
if (def.type === 'union') {
jsonSchema.oneOf = jsonSchema.anyOf;
delete jsonSchema.anyOf;
}
},
});Important: Only an
overridefunction can be provided inCreateDocumentOptions. Schema-leveloverridewill always run after the global function.
🔄 cycles, reused
These options are passed directly to Zod's
toJSONSchema()method to control how cycles and reused schemas are handled.See Zod's JSON Schema documentation for details.
🧩 allowEmptySchema
Controls when empty schemas can be created. This controls if zod-openapi will throw an error when an empty schema is encountered.
{ [zodType]: true }— Allow for both input and output{ [zodType]: { input: true, output: true } }— Allow for both input and output{ [zodType]: { input: true } }— Allow for input only{ [zodType]: { output: true } }— Allow for output only
🔄 refType → unusedIO
Renamed to better reflect its purpose: specifying how unused component schemas should be rendered.
🔄 schemaType → io
Renamed to align with Zod's native terminology.
🔄 componentRefPath → schemaComponentRefPath
Renamed for clarity and consistency.
🔄 components → schemaComponents
Renamed for clarity and consistency.
PathItems, SecuritySchemes, Links, and Examples can now be created as reusable components
See ./api.md for detailed API changes in v5.