You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
payload.delete({ where }) silently fails to delete records — concurrent deleteOne race condition (regression since 3.41.0) #16075
payload.delete({ collection, where }) reports success (returns correct IDs) but silently fails to delete 1 or more matching records. Verified with raw SQL immediately after the call — the records still exist in the database.
This is a regression introduced between 3.41.0 and 3.80.0. We confirmed 3.41.0 does not have this bug.
Root Cause
In dist/collections/operations/delete.js, the bulk delete operation uses .map(async ...) to create promises for each document, then either Promise.all or for...of await to process them:
// Line 82: .map() eagerly starts ALL async callbacksconstpromises=docs.map(async(doc)=>{// ...awaitpayload.db.deleteOne({where: {id: {equals: id}}});});// Line 234-242: "Sequential" mode still doesn't helpif(req.payload.db.bulkOperationsSingleTransaction){for(constpromiseofpromises){// promises already started!awaitedDocs.push(awaitpromise);}}else{awaitedDocs=awaitPromise.all(promises);}
The issue:.map(async ...) starts ALL async callbacks immediately. The for...of await loop only controls the order results are collected, not the order of execution. All deleteOne calls run concurrently on the same transaction connection, each performing 3 queries (selectDistinct → findFirst → deleteWhere) that interleave and corrupt each other.
Setting bulkOperationsSingleTransaction: true does not fix the issue because of this same .map() antipattern.
Fix
Replace .map() + for...of await with a proper sequential loop:
constprocessDoc=async(doc)=>{/* existing callback body */};letawaitedDocs=[];for(constdocofdocs){awaitedDocs.push(awaitprocessDoc(doc));}
This ensures each deleteOne completes before the next one starts.
Reproduction
Self-contained script (uses vehicle-requirements — a collection with zero hooks):
import{getPayload}from'payload';importconfigfrom'./src/payload.config';import{sql}from'drizzle-orm';constpayload=awaitgetPayload({ config });constdb=payload.db.drizzle;// Create 5 recordsconsttestIds=[];for(leti=0;i<5;i++){constdoc=awaitpayload.create({collection: 'vehicle-requirements',data: {jobPlan: PLAN_ID,jobPlanDay: DAY_ID,vehicleType: `test-${i}`,quantity: 1},});testIds.push(doc.id);}// Delete via whereconstresult=awaitpayload.delete({collection: 'vehicle-requirements',where: {id: {in: testIds}},});console.log('Payload says deleted:',result.docs.length);// 5// Verify via raw SQLconstremaining=awaitdb.execute(sql`SELECT id FROM vehicle_requirements WHERE id IN (${sql.join(testIds.map(id=>sql`${id}`),sql`, `)})`);console.log('Actually remaining:',remaining.rows.length);// 1 (BUG)
Reproduces within 1-3 iterations consistently on Payload 3.80.0 with @payloadcms/db-postgres.
Control test: Drizzle direct db.delete(table).where(inArray(...)) works perfectly — 0 failures across all iterations. The bug is in Payload's abstraction layer, not Drizzle or Postgres.
Can confirm on 3.82.1. It's not just delete.js — update.js has the exact same .map(async ...) + for...of await pattern (lines 138-218 in dist/collections/operations/update.js). payload.update({ collection, where: { id: { in: [1, 2, 3] } }, data }) with 3 matching docs only updates 1.
One more thing: postgresAdapter() never forwards bulkOperationsSingleTransaction to createDatabaseAdapter() — the flag just isn't in the property list. I tried setting payload.db.bulkOperationsSingleTransaction = true directly after init. The flag does get read at runtime inside each callback, but it doesn't matter because .map() already kicked off all callbacks concurrently. The per-doc initTransaction calls just step on each other on the shared req.
Patched both files the same way as described here (extract callback, sequential for...of when flag is on, Promise.all otherwise) and it fixes it. Opened #16254 with the source-level change.
I don't have full visibility into what else this might affect downstream, so it definitely needs a proper look from the team. If there's a better way to solve this or if the fix needs adjusting, happy to update the PR. Just wanted to get the ball rolling since I could reproduce and verify it.
PS: Analysis and fix by Opus 4.6 — review accordingly.
Activity
fix(delete): process bulk deletes sequentially to prevent silent data…
Livog commentedon Apr 12, 2026
Can confirm on 3.82.1. It's not just
delete.js—update.jshas the exact same.map(async ...)+for...of awaitpattern (lines 138-218 indist/collections/operations/update.js).payload.update({ collection, where: { id: { in: [1, 2, 3] } }, data })with 3 matching docs only updates 1.One more thing:
postgresAdapter()never forwardsbulkOperationsSingleTransactiontocreateDatabaseAdapter()— the flag just isn't in the property list. I tried settingpayload.db.bulkOperationsSingleTransaction = truedirectly after init. The flag does get read at runtime inside each callback, but it doesn't matter because.map()already kicked off all callbacks concurrently. The per-docinitTransactioncalls just step on each other on the sharedreq.Patched both files the same way as described here (extract callback, sequential
for...ofwhen flag is on,Promise.allotherwise) and it fixes it. Opened #16254 with the source-level change.I don't have full visibility into what else this might affect downstream, so it definitely needs a proper look from the team. If there's a better way to solve this or if the fix needs adjusting, happy to update the PR. Just wanted to get the ball rolling since I could reproduce and verify it.
PS: Analysis and fix by Opus 4.6 — review accordingly.
fix: process bulk update/delete docs sequentially when bulkOperations…
fix: process bulk update/delete docs sequentially when bulkOperations…
Livog commentedon Apr 15, 2026
I no longer have a issue with this one after upgrading to 3.83.