A circular $ref chain — schema A references schema B which references schema A again, directly or through a longer chain — is valid OpenAPI and valid JSON Schema. Validators pass it with no error. The break happens one step later, in tools that fully "dereference" a spec into one flattened document before generating anything: that flattening step can produce an object graph with a real circular reference in memory, and the next operation on that graph (often JSON.stringify) throws. The fix isn't to make the schema less circular — it's to either keep the reference lazy (not fully dereferenced) or restructure the schema so the cycle isn't load-bearing. Reproduced with real tools on 2026-09-21.
How do you reproduce the error?
Take a self-referencing schema — an Employee with an optional manager, itself an Employee:
components:
schemas:
Employee:
type: object
properties:
id:
type: string
name:
type: string
manager:
$ref: '#/components/schemas/Employee'
directReports:
type: array
items:
$ref: '#/components/schemas/Employee'
swagger-cli validate v4.0.4 and redocly lint v2.53.3 (default ruleset) both pass this spec with no error — a circular $ref is not a validation problem in either the OpenAPI or JSON Schema spec. redocly bundle --dereferenced also handles it cleanly, because Redocly's bundler represents the cycle with a YAML anchor rather than infinitely inlining it.
The break shows up in a different layer. We ran @apidevtools/json-schema-ref-parser (the dereferencing library used inside swagger-cli and a number of custom codegen scripts) against the same spec:
import $RefParser from "@apidevtools/json-schema-ref-parser";
const api = await $RefParser.dereference("circular-ref.yaml");
JSON.stringify(api);
dereference() itself succeeds — no error. The JSON.stringify(api) call immediately after it throws:
Converting circular structure to JSON
--> starting at object with constructor 'Object'
| property 'properties' -> object with constructor 'Object'
--- property 'manager' closes the circle
That's a real, reproducible Node.js error (TypeError: Converting circular structure to JSON), not specific to this library — it's what JSON.stringify always throws when it meets an actual circular object reference. $RefParser.dereference() intentionally keeps circular refs as live object pointers rather than infinitely expanding them (infinite expansion would never terminate), which means the dereferenced spec is only safe to use in memory — the moment something tries to serialize it back to JSON or YAML text, it breaks.
Does an SDK generator actually break on this?
Not necessarily — it depends on how the generator consumes the spec. We ran OpenAPI Generator CLI 7.25.0 (typescript-fetch) against the same circular schema, and it produced a correct, working recursive TypeScript interface:
export interface Employee {
id?: string;
name?: string;
manager?: Employee;
directReports?: Array<Employee>;
}
That works because OpenAPI Generator's templates emit a $ref as a type name reference (Employee), not as fully inlined, expanded JSON — TypeScript itself supports self-referencing interfaces natively, so there's nothing to break. The failure mode is specific to pipelines that fully flatten the spec into one JSON or YAML document before handing it to a generator — a common pattern for tools that want "one file, no $refs" as an intermediate format, or for a custom script that bundles a spec before uploading it somewhere.
How different tools react
| Step | Behavior on a circular $ref |
|---|---|
swagger-cli validate / redocly lint |
Valid — circular refs aren't a spec violation |
redocly bundle --dereferenced |
Succeeds — represents the cycle with a YAML anchor |
$RefParser.dereference() (used inside many codegen scripts) |
Succeeds, but returns an object with a real circular reference |
JSON.stringify() on that dereferenced object |
Throws Converting circular structure to JSON |
| OpenAPI Generator CLI (typescript-fetch) | Succeeds — emits a recursive TypeScript interface, no flattening |
The pattern to take from this: the spec itself is never the problem. The problem is any step, anywhere in your pipeline, that assumes a spec can always be fully flattened into non-circular JSON.
How do you fix it?
Two real fixes, depending on which layer is breaking.
If a codegen or docs tool handles $ref natively (most modern SDK generators do): do nothing — the cycle is fine, as demonstrated above. Confirm this rather than assuming it, by generating and actually compiling the output.
If something in your pipeline needs a fully flattened, $ref-free document — a bundler feeding a system that can't resolve pointers, or a custom script calling JSON.stringify on a dereferenced spec — break the cycle in the schema itself by replacing the nested object with an identifier reference:
# Before — Employee nests a full Employee for its manager
Employee:
type: object
properties:
id:
type: string
manager:
$ref: '#/components/schemas/Employee'
# After — manager is a reference by id, not a nested object
Employee:
type: object
properties:
id:
type: string
managerId:
type: string
nullable: true
description: id of this employee's manager, if any
This is also usually the better API design regardless of tooling: a fully nested manager.manager.manager payload has no natural depth limit, and a client has to guess how deep to expect. An id reference plus a separate lookup is bounded and predictable.
How to catch this before it ships
- Don't assume "valid per the validator" means "safe for every downstream tool" — circular refs are exactly the kind of spec-valid pattern that only breaks two or three steps later, which is why testing the actual generated output matters more than a green validator run.
- If you maintain a custom bundling or preprocessing step, test it against a deliberately circular fixture spec before trusting it in CI — the failure (
Converting circular structure to JSONor a stack overflow from unbounded recursion) is easy to reproduce and easy to miss until a real schema happens to be self-referential. - Run the free in-browser OpenAPI validator to confirm the spec itself is valid, then separately confirm your specific generator produces compiling output — those are two different checks, and only the second one catches a codegen-layer circular-ref break.
If you're generating SDKs or MCP tools from a spec with self-referencing or tree-shaped schemas, Sourced's generation pipeline handles $ref-native circular structures without a flattening step — create hosted docs from your repo or start a free report to see the generated types on your own spec.
FAQ
Is a circular $ref invalid OpenAPI?
No. Neither the OpenAPI specification nor JSON Schema forbids a schema from referencing itself, directly or through a chain of other schemas. swagger-cli and redocly lint both validate a circular schema with no error, as reproduced above.
Why does JSON.stringify fail on a dereferenced spec if the spec itself is fine?
Because "dereferencing" replaces every $ref with the actual object it points to. For a circular chain, that produces an object graph where a property eventually points back to an ancestor object in memory — a genuine circular data structure, which JSON.stringify cannot serialize to text because doing so would never terminate.
Does this affect OpenAPI 3.0 and 3.1 equally?
Yes — reference resolution and cycle handling are unchanged between the two versions. This is a JSON Pointer / dereferencing behavior, not a schema-dialect difference.
How do I know if my SDK generator handles circular refs safely?
Generate against a small, deliberately circular fixture spec (a two- or three-schema cycle) and try to compile or run the output. If the generator emits a native recursive type (as OpenAPI Generator's TypeScript output does), you're fine. If generation hangs, crashes, or the build output contains an incomplete type, your generator flattens internally and needs the cycle broken in the spec.
What's the difference between this and a duplicate operationId or an unresolved $ref?
A duplicate operationId and an unresolved $ref are both errors that a validator will reject. A circular $ref is not an error at all — it's a valid pattern that some specific tools can't handle, which makes it harder to catch: there's no red X in your validator output to point you at it.
Should I avoid self-referencing schemas entirely to be safe?
Not necessarily. Tree- and graph-shaped data (org charts, comment threads, category hierarchies) are naturally self-referential, and most current SDK generators handle it fine, as shown above. Only replace the nested reference with an id-based reference if you've confirmed a specific tool in your pipeline breaks on it, or if the unbounded nesting itself is a bad fit for your API's response size.