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

  1. Pass { cycles: "ref" } to z.toJSONSchema() so recursive schemas are emitted using $defs and $ref.
  2. If refs are undesirable, refactor the schema to remove the cycle (e.g. bound depth, separate leaf type).
  3. Register the recursive sub-schema with an id so the emitted $ref is stable and named.
  4. 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

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


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)