payloadcms/payload · error · Error

Circular reference detected: the parent chain contains a loo

Error message

Circular reference detected: the parent chain contains a loop

What it means

Thrown by validateNoCircularReference.checkAncestor when, while walking up the parent chain from the proposed parent, it revisits a node already in the visited set. That indicates a true loop in the chain (A->B->A) rather than a legitimate move into a child.

Source

Thrown at packages/payload/src/hierarchy/hooks/collectionBeforeChange.ts:96

      ? collection.hierarchy.parentFieldName
      : undefined

  if (!parentFieldName) {
    return
  }

  const fieldName = parentFieldName

  async function checkAncestor(
    ancestorId: number | string,
    visitedNodes: Set<string> = new Set(),
  ): Promise<void> {
    // Create unique key for this node
    const nodeKey = `${collection.slug}:${ancestorId}`

    // Check if we've visited this node before (true loop in the chain)
    if (visitedNodes.has(nodeKey)) {
      throw new Error(`Circular reference detected: the parent chain contains a loop`)
    }

    // If we've reached the current document, this means we're trying to move into a child
    if (ancestorId === currentDocId) {
      throw new Error('Cannot move folder into its own subfolder')
    }

    // Add this node to visited set
    visitedNodes.add(nodeKey)

    try {
      const ancestor = (await req.payload.findByID({
        id: ancestorId,
        collection: collection.slug,
        depth: 0,
        req,
        select: {
          [fieldName]: true,

View on GitHub (pinned to 00c58b35c0)

Solutions

  1. Inspect and repair the existing chain with a one-off script before retrying the update.
  2. Serialise hierarchy reparenting writes (lock or queue) to prevent races.
  3. Use the afterChange reparenting path instead of manually reassigning parents across a subtree.
  4. Add a maintenance job that detects and reports cycles in the parent column.

Example fix

// before — direct DB edit created A->B->A, now any update throws
await payload.update({ collection: 'folders', id: bId, data: { parent: aId } })

// after — repair first, then edit
// 1. fix the corrupt link via a script that walks parent once
await payload.db.updateOne({ collection: 'folders', id: aId, data: { parent: null } })
// 2. retry the intended update
await payload.update({ collection: 'folders', id: bId, data: { parent: aId } })
Defensive patterns

Strategy: try-catch

Validate before calling

// Walk the parent chain once; if any id repeats, repair before saving
const seen = new Set<string>()
let cursor = proposedParentId
while (cursor) {
  if (seen.has(String(cursor))) throw new Error('cycle in existing chain')
  seen.add(String(cursor))
  cursor = await getParentOf(cursor)
}

Type guard

function hasCycle(chain: string[]): boolean {
  return new Set(chain).size !== chain.length
}

Try / catch

try {
  await payload.update({ collection, id, data })
} catch (err) {
  if (err instanceof Error && /Circular reference detected/.test(err.message)) {
    // surface to user; offer a repair flow
  }
  throw err
}

Prevention

When it happens

Trigger: Updating a document's parent to an ancestor whose chain, after the update, would loop back to the starting node — typically caused by direct DB edits, concurrent updates, or a previously corrupted chain.

Common situations: Data imported with corrupt parent links; two concurrent updates racing on the same subtree; manual SQL that created a cycle the hook now blocks further edits on.

Related errors


AI-assisted analysis of payloadcms/payload@00c58b35c0 (2026-08-12). Data as JSON: /api/errors/d10f4c9932a82825. Report an issue: GitHub.