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

  1. Pass `{ cycles: 'ref' }` (the default) to emit cyclic schemas using `$defs` and `$ref` pointers.
  2. If the cycle is unintentional, remove the self-reference (e.g. the `z.lazy(() => schema)` pointing back at the root).
  3. 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

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


AI-assisted analysis of colinhacks/zod@912f0f51b0 (2026-08-03). Data as JSON: /data/errors/96ac7ae76834e166.json. Report an issue: GitHub.