Describe the Bug
When blocks containing relationship or link fields are reordered (dragged to a new position) within a layout/blocks field, Payload creates new _rels rows with the updated path indices but never deletes the old rows at the previous path positions. This causes stale relationship references to accumulate in the _rels table over time.
These orphaned rows:
- Reference block indices that no longer exist (e.g.,
layout.12 on a page with only 5 blocks)
- Reference block positions now occupied by a different block type that doesn't have the field
- Grow linearly with each reorder operation (the relationship row gets "stamped" at every position the block passes through)
This has practical consequences: any hook or logic that checks for inbound references (e.g., preventing deletion of referenced documents) will find these stale FK rows and incorrectly block the operation.
Root cause analysis
The issue is in packages/drizzle/src/upsertRow/deleteExistingRowsByPath.ts.
When a document is updated, deleteExistingRowsByPath is called to clean up old _rels rows before inserting new ones. However, it only deletes rows whose exact paths match the paths in the incoming data (relationsToInsert + relationshipsToDelete):
// upsertRow/index.ts, line 356
await deleteExistingRowsByPath({
rows: [...relationsToInsert, ...generalRelationshipDeletes], // ← only paths being written
...
})
deleteExistingRowsByPath collects these paths and deletes with inArray(table[pathColumnName], Array.from(pathsToDelete)) — an exact match.
The problem: When a block with relationships (e.g., CTA) is reordered from position 2 to position 0, and a block without relationships (e.g., Content) now occupies position 2:
- New rels are prepared with path
layout.0.links.0.link.reference
deleteExistingRowsByPath deletes existing rows at path layout.0.links.0.link.reference (exact match)
- Inserts the new row at
layout.0.links.0.link.reference
- The old row at
layout.1.links.0.link.reference is never deleted — because no new relationship is being written at that path (the Content block has no links), so it's never in the paths set
The deletion is driven by the new data, not by what was there before. Paths that no longer have relationships are simply never touched.
Relevant files:
packages/drizzle/src/upsertRow/index.ts (lines 344–374) — orchestrates rels save
packages/drizzle/src/upsertRow/deleteExistingRowsByPath.ts — the exact-match deletion
packages/drizzle/src/transform/write/blocks.ts (line 134) — path generation: ${path || ''}${field.name}.${i}.
Proposed fix
When saving a blocks field, delete all existing _rels rows whose path starts with the blocks field prefix, rather than only deleting paths that match the new data.
Option A (targeted): In transformBlocks (blocks.ts), after iterating all block data, push a prefix-based delete for the entire blocks field path:
// At the end of transformBlocks, signal that all old rels under this
// blocks field should be purged
relationshipsToDelete.push({ path: `${path || ''}${field.name}.`, prefix: true })
Then update deleteExistingRowsByPath to support prefix deletions using LIKE:
if (prefixPathsToDelete.size > 0) {
for (const prefix of prefixPathsToDelete) {
await adapter.deleteWhere({
db,
tableName,
where: and(
eq(table[parentColumnName], parentID),
like(table[pathColumnName], `${prefix}%`),
),
})
}
}
Option B (simpler): In upsertRow/index.ts, before writing rels, collect all blocks field names from the document's field config, then delete all rels rows matching those prefixes:
// Before relationship insert, purge all rels under blocks fields
for (const blocksFieldPath of blocksFieldPaths) {
await adapter.deleteWhere({
db,
tableName: relationshipsTableName,
where: and(
eq(table.parent, insertedRow.id),
like(table.path, `${blocksFieldPath}%`),
),
})
}
This is safe because the new rels are always fully re-inserted after the delete. The same approach would also fix #15976 (rels not cleaned on version restore).
Additional context
Link to the code that reproduces this issue
https://github.com/fkkehlet/payload-rels-repro
Reproduction Steps
- Clone the repo,
pnpm install, configure .env with a Postgres database, run pnpm dev
- Open
/admin, create a Category (e.g., "Test Category")
- Create a Page with this layout:
- Block 1: Content (body: "first")
- Block 2: CTA (heading: "Call to action", add a link → reference the Category)
- Block 3: Content (body: "last")
- Save the page
- Query
pages_rels to see the current state:
SELECT id, path, categories_id FROM pages_rels WHERE parent_id = 1 ORDER BY path;
-- Expected: layout.1.links.0.link.reference → categories_id = 1
- In the admin panel, drag the CTA block from position 2 to position 1 (above the first content block)
- Save the page
- Query
pages_rels again:
SELECT id, path, categories_id FROM pages_rels WHERE parent_id = 1 ORDER BY path;
Expected: One row at layout.0.links.0.link.reference → categories_id = 1
Actual: Two rows:
layout.0.links.0.link.reference → categories_id = 1 (new, correct)
layout.1.links.0.link.reference → categories_id = 1 (old, orphaned — now points to a Content block that has no links field)
Repeating reorders accumulates more orphans. In production we found 46 orphaned rows across 8 pages after normal editing.
Example from production:
id | page_id | path | pages_id | block_at_position
------+---------+----------------------------------+----------+-------------------
696 | 4 | layout.9.links.0.link.reference | 13 | NO_BLOCK (index doesn't exist)
491 | 3 | layout.3.links.0.link.reference | 13 | contentgrid (wrong block type)
2155 | 1 | layout.2.links.0.link.reference | 77 | content_with_media (wrong block type)
Which area(s) are affected?
db: postgres
Environment Info
Payload: 3.84.1
Next.js: 15.5.12
Node.js: 22.22.0
Database: @payloadcms/db-postgres (Neon)
OS: Linux (Fedora 42)
Describe the Bug
When blocks containing relationship or link fields are reordered (dragged to a new position) within a layout/blocks field, Payload creates new
_relsrows with the updated path indices but never deletes the old rows at the previous path positions. This causes stale relationship references to accumulate in the_relstable over time.These orphaned rows:
layout.12on a page with only 5 blocks)This has practical consequences: any hook or logic that checks for inbound references (e.g., preventing deletion of referenced documents) will find these stale FK rows and incorrectly block the operation.
Root cause analysis
The issue is in
packages/drizzle/src/upsertRow/deleteExistingRowsByPath.ts.When a document is updated,
deleteExistingRowsByPathis called to clean up old_relsrows before inserting new ones. However, it only deletes rows whose exact paths match the paths in the incoming data (relationsToInsert+relationshipsToDelete):deleteExistingRowsByPathcollects these paths and deletes withinArray(table[pathColumnName], Array.from(pathsToDelete))— an exact match.The problem: When a block with relationships (e.g., CTA) is reordered from position 2 to position 0, and a block without relationships (e.g., Content) now occupies position 2:
layout.0.links.0.link.referencedeleteExistingRowsByPathdeletes existing rows at pathlayout.0.links.0.link.reference(exact match)layout.0.links.0.link.referencelayout.1.links.0.link.referenceis never deleted — because no new relationship is being written at that path (the Content block has no links), so it's never in the paths setThe deletion is driven by the new data, not by what was there before. Paths that no longer have relationships are simply never touched.
Relevant files:
packages/drizzle/src/upsertRow/index.ts(lines 344–374) — orchestrates rels savepackages/drizzle/src/upsertRow/deleteExistingRowsByPath.ts— the exact-match deletionpackages/drizzle/src/transform/write/blocks.ts(line 134) — path generation:${path || ''}${field.name}.${i}.Proposed fix
When saving a blocks field, delete all existing
_relsrows whose path starts with the blocks field prefix, rather than only deleting paths that match the new data.Option A (targeted): In
transformBlocks(blocks.ts), after iterating all block data, push a prefix-based delete for the entire blocks field path:Then update
deleteExistingRowsByPathto support prefix deletions usingLIKE:Option B (simpler): In
upsertRow/index.ts, before writing rels, collect all blocks field names from the document's field config, then delete all rels rows matching those prefixes:This is safe because the new rels are always fully re-inserted after the delete. The same approach would also fix #15976 (rels not cleaned on version restore).
Additional context
_relsrows not being deleted)Link to the code that reproduces this issue
https://github.com/fkkehlet/payload-rels-repro
Reproduction Steps
pnpm install, configure.envwith a Postgres database, runpnpm dev/admin, create a Category (e.g., "Test Category")pages_relsto see the current state:pages_relsagain:Expected: One row at
layout.0.links.0.link.reference → categories_id = 1Actual: Two rows:
layout.0.links.0.link.reference → categories_id = 1(new, correct)layout.1.links.0.link.reference → categories_id = 1(old, orphaned — now points to a Content block that has nolinksfield)Repeating reorders accumulates more orphans. In production we found 46 orphaned rows across 8 pages after normal editing.
Example from production:
Which area(s) are affected?
db: postgres
Environment Info