ValueError: Circular Reference Detected (tested)

A dict or list that contains itself, directly or through a chain of references, can't be turned into JSON — there's no way to represent infinite nesting as text. json.dumps() detects this and raises ValueError: Circular reference detected instead of hanging or crashing. We measured exactly when it fires and what actually fixes it.

1. The error, from a dict that references itself

import json

d = {"a": 1}
d["self"] = d

json.dumps(d)
ValueError: Circular reference detected

Measured: this happens the moment the encoder revisits a container it's already in the middle of serializing — it doesn't wait to hit a recursion limit or run out of memory, it tracks the objects currently being encoded and raises as soon as one repeats.

2. Same error for a list, and for an indirect two-object cycle

l = [1, 2]
l.append(l)
json.dumps(l)
ValueError: Circular reference detected

a = {}; b = {}
a["b"] = b
b["a"] = a
json.dumps(a)
ValueError: Circular reference detected

Measured: the check isn't limited to direct self-reference — two separate dicts that point back at each other through a chain trigger the exact same error and message, with no indication in the text of how long the cycle is or which object started it.

3. default= does NOT fix this — a common dead end

seen = set()
def default(o):
    if id(o) in seen:
        return "<circular>"
    seen.add(id(o))
    return dict(o) if isinstance(o, dict) else o

json.dumps(d, default=default)
ValueError: Circular reference detected   # default() was never even called

Measured: this is the natural first thing to try, and it doesn't work. default= is only ever called for objects json doesn't natively know how to serialize — dict and list are natively supported, so the encoder handles them itself and the circular-reference check fires before your callback ever runs. If you're debugging why a default= callback "isn't being called," a circular dict or list is one reason why.

4. What actually works: break the cycle before calling json.dumps()

def break_cycles(obj, seen=None):
    if seen is None:
        seen = set()
    if isinstance(obj, dict):
        if id(obj) in seen:
            return "<circular reference>"
        seen = seen | {id(obj)}
        return {k: break_cycles(v, seen) for k, v in obj.items()}
    if isinstance(obj, list):
        if id(obj) in seen:
            return "<circular reference>"
        seen = seen | {id(obj)}
        return [break_cycles(v, seen) for v in obj]
    return obj

json.dumps(break_cycles(d))
{"a": 1, "self": "<circular reference>"}

Measured: walking the structure yourself first, replacing anything already seen on the current path with a placeholder, and then calling json.dumps() on the result works, because by the time the encoder sees it, there's no cycle left — just a plain string marker. This correctly handles both direct self-reference and the indirect two-object cycle above.

5. The option to avoid, except for debugging: check_circular=False

json.dumps(d, check_circular=False)
RecursionError: maximum recursion depth exceeded while encoding a JSON object

Measured: turning off the check doesn't make circular data serializable — it just replaces a clean ValueError with Python's generic RecursionError once the call stack actually overflows, which is slower and a worse error for anyone catching exceptions downstream. There's no legitimate reason to pass check_circular=False in real code; the default behavior is strictly better.

6. Where this actually comes from

In practice this is almost never intentional — it usually comes from ORM objects with back-references (a parent object holding a list of children, each child holding a reference back to its parent) being passed straight into json.dumps() instead of through a serializer that knows to omit back-references, or a caching/memoization structure that stores a node's own container as part of its state.

Frequently asked questions

Why does json.dumps() raise "Circular reference detected" instead of hanging?

We confirmed the encoder tracks which containers are currently being serialized on the active call stack and raises the moment one of them reappears, rather than recursing until it hits a limit or runs out of memory.

Why doesn't a default= callback fix a circular reference error?

Because we measured that default() is only invoked for types json doesn't natively handle — dict and list are natively supported, so the built-in encoder processes them itself and raises before your callback is ever called.

Does json.dumps() detect indirect cycles, like two dicts referencing each other?

Yes — we confirmed a two-object cycle (a['b'] = b, b['a'] = a) raises the identical Circular reference detected error as direct self-reference, with no indication in the message of the cycle's length.

What's the actual fix for serializing a self-referencing structure?

We tested a recursive walker that tracks object ids already seen on the current path and replaces any repeat with a placeholder string before calling json.dumps() on the result — by the time the encoder runs, there's no cycle left to detect.

Should I use check_circular=False to work around this?

No — we measured that it only delays the failure into a generic RecursionError once the call stack overflows, which is slower and a worse error to handle than the clean ValueError the default check gives you; it doesn't make the data any more serializable.

Written with AI assistance. Measured on 2026-10-03 with Python 3.13.5's built-in json module, building self-referencing dicts and lists and calling json.dumps() on them directly, printing the exact exception text. Every message below is copy-pasted output, not paraphrased. PlainHeaders is our extension; Python's json module is built in.