colinhacks/zod · error · Error
Cycle detected: #/${seen.cycle?.join("/")}/<root> Set the `
Error message
Cycle detected: #/${seen.cycle?.join("/")}/<root>
Set the `cycles` parameter to `"ref"` to resolve cyclical schemas with defs. What it means
Thrown during `z.toJSONSchema()` when the schema graph contains a cycle (a schema that references itself, directly or indirectly) and the `cycles` option is set to `"throw"`. Note Zod v4 defaults `cycles` to `"ref"` (which resolves cycles with `$defs`/`$ref`), so this error only fires when you explicitly pass `{ cycles: 'throw' }`. The message reports the path of the detected cycle.
Source
Thrown at packages/zod/src/v4/core/to-json-schema.ts:306
// 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 912f0f51b0)
Solutions
- Pass `{ cycles: 'ref' }` (the default) to emit cyclic schemas using `$defs` and `$ref` pointers.
- If the cycle is unintentional, remove the self-reference (e.g. the `z.lazy(() => schema)` pointing back at the root).
- For draft-07 or openapi-3.0 targets, confirm the emitted `$ref`/`definitions` shape is acceptable to your downstream consumer.
Example fix
// before
const Tree = z.lazy(() => z.object({ value: z.number(), children: z.array(Tree) }));
z.toJSONSchema(Tree, { cycles: 'throw' }); // throws
// after
z.toJSONSchema(Tree, { cycles: 'ref' }); // emits $defs + $ref Defensive patterns
Strategy: validation
Validate before calling
// Default is cycles:'ref'; only throw is explicit. To avoid surprises:
function convertSafely(schema) {
return z.toJSONSchema(schema, { cycles: 'ref' }); // always resolve cycles
} Try / catch
try {
z.toJSONSchema(schema, { cycles: 'throw' });
} catch (e) {
if (e instanceof Error && e.message.startsWith('Cycle detected')) {
// retry with cycles: 'ref' to emit $defs/$ref
z.toJSONSchema(schema, { cycles: 'ref' });
}
} Prevention
- Prefer the default cycles:'ref' for any potentially recursive schema.
- Only use cycles:'throw' in tests asserting acyclicity.
- Document recursive schemas (z.lazy) as requiring cycles:'ref' when converting.
When it happens
Trigger: Defining a recursive schema (e.g. a tree/node that contains itself via `z.lazy(...)`) and calling `z.toJSONSchema(schema, { cycles: 'throw' })`. Mutually recursive schemas also trigger it.
Common situations: Explicitly opting into cycle detection to catch accidental recursion; converting a schema tree that was expected to be acyclic but contains a `z.lazy` self-reference.
Related errors
- Error converting schema to JSON.
- [toJSONSchema]: Non-representable type encountered: ${def.ty
- Duplicate schema id "${id}" detected during JSON Schema conv
- Schema is missing an `id` property
- External $ref is not supported, only local refs (#/...) are
AI-assisted analysis of colinhacks/zod@912f0f51b0 (2026-08-03).
Data as JSON: /data/errors/96ac7ae76834e166.json.
Report an issue: GitHub.