colinhacks/zod · warning · Error
Cycle detected: #/ / Set the `cycles` parameter to `"ref"`…
Error message
Cycle detected: #/${seen.cycle?.join("/")}/<root>
Set the `cycles` parameter to `"ref"` to resolve cyclical schemas with defs. What it means
Thrown by extractDefs() when ctx.cycles === "throw" (the default) and at least one schema in ctx.seen has a non-empty cycle path. Recursive schemas (e.g. a tree node that references itself) cannot be serialized to a flat JSON Schema without refs; the default is to refuse rather than emit infinitely-deep output. The message shows the cycle path (#/.../<root>) and tells the user to set cycles: "ref" to extract the recursion into $defs with $ref pointers.
Solutions
- Pass { cycles: "ref" } to z.toJSONSchema() so recursive schemas are emitted using $defs and $ref.
- If refs are undesirable, refactor the schema to remove the cycle (e.g. bound depth, separate leaf type).
- Register the recursive sub-schema with an id so the emitted $ref is stable and named.
- For OpenAPI/Swagger consumers, confirm the target draft supports $ref/$defs (draft-2020-12 does).
Example fix
// before
const Tree = z.lazy(() => z.object({ value: z.number(), children: z.array(Tree) }));
const json = z.toJSONSchema(Tree); // throws: cycle
// after
const json = z.toJSONSchema(Tree, { cycles: "ref" }); Defensive patterns
Strategy: fallback
Validate before calling
function toJSONSchemaSafe(schema: z.ZodType) {
try {
return z.toJSONSchema(schema);
} catch (e) {
if (/Cycle detected/.test((e as Error).message)) {
return z.toJSONSchema(schema, { cycles: "ref" });
}
throw e;
}
} Try / catch
try {
return z.toJSONSchema(schema);
} catch (e) {
if (/Cycle detected/.test((e as Error).message)) {
return z.toJSONSchema(schema, { cycles: "ref" });
}
throw e;
} Prevention
- For any recursive (z.lazy) schema, always pass { cycles: "ref" }.
- Register recursive sub-schemas with an id for stable $ref names.
- Confirm the target JSON Schema draft supports $defs/$ref.
When it happens
Trigger: Calling z.toJSONSchema() on a recursive schema — typically one built with z.lazy() that references itself (e.g. a Tree type) — without setting the cycles option. Any self-reference, direct or transitive, produces a cycle in ctx.seen and triggers the throw under the default "throw" policy.
Common situations: Tree/list/graph domain models defined with z.lazy(); category-theoretic recursive types; converting a schema that references a registered sub-schema which in turn references the parent; first attempt at JSON Schema export before learning about the cycles option.
Related errors
- Circular reference not resolved
- Error converting schema to JSON.
- External $ref is not supported, only local refs (#/...) are…
- Reference not found
- Conditional schemas (if/then/else) are not supported
AI-assisted analysis of colinhacks/zod@2d90846af9 (2026-08-11).
Data as JSON: /api/errors/96ac7ae76834e166.
Report an issue: GitHub.
Appendix: source
Thrown at packages/zod/src/v4/core/to-json-schema.ts:307
// defId won't be set if the schema is a reference to an external schema
// or if the schema is the root schema
if (defId) seen.defId = defId;
// wipe away all properties except $ref
const schema = seen.schema;
for (const key in schema) {
delete schema[key];
}
schema.$ref = ref;
};
// throw on cycles
// break cycles
if (ctx.cycles === "throw") {
for (const entry of ctx.seen.entries()) {
const seen = entry[1];
if (seen.cycle) {
throw new Error(
"Cycle detected: " +
`#/${seen.cycle?.join("/")}/<root>` +
'\n\nSet the `cycles` parameter to `"ref"` to resolve cyclical schemas with defs.'
);
}
}
}
// extract schemas into $defs
for (const entry of ctx.seen.entries()) {
const seen = entry[1];
// convert root schema to # $ref
if (schema === entry[0]) {
extractToDef(entry); // this has special handling for the root schema
continue;
}
View on GitHub (pinned to 2d90846af9)