Skip to content

payload.delete({ where }) silently fails to delete records — concurrent deleteOne race condition (regression since 3.41.0) #16075

Description

@razmenaia

Bug Description

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 callbacks
const promises = docs.map(async (doc) => {
    // ...
    await payload.db.deleteOne({ where: { id: { equals: id } } });
});

// Line 234-242: "Sequential" mode still doesn't help
if (req.payload.db.bulkOperationsSingleTransaction) {
    for (const promise of promises) {   // promises already started!
        awaitedDocs.push(await promise);
    }
} else {
    awaitedDocs = await Promise.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:

const processDoc = async (doc) => { /* existing callback body */ };

let awaitedDocs = [];
for (const doc of docs) {
    awaitedDocs.push(await processDoc(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';
import config from './src/payload.config';
import { sql } from 'drizzle-orm';

const payload = await getPayload({ config });
const db = payload.db.drizzle;

// Create 5 records
const testIds = [];
for (let i = 0; i < 5; i++) {
  const doc = await payload.create({
    collection: 'vehicle-requirements',
    data: { jobPlan: PLAN_ID, jobPlanDay: DAY_ID, vehicleType: `test-${i}`, quantity: 1 },
  });
  testIds.push(doc.id);
}

// Delete via where
const result = await payload.delete({
  collection: 'vehicle-requirements',
  where: { id: { in: testIds } },
});
console.log('Payload says deleted:', result.docs.length); // 5

// Verify via raw SQL
const remaining = await db.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.

Environment

  • payload: 3.80.0
  • @payloadcms/db-postgres: 3.80.0
  • @payloadcms/drizzle: 3.80.0
  • PostgreSQL 15
  • Node 22
  • macOS

Related

Activity

  1. added a commit that references this issue on Mar 27, 2026
    4388f76
  2. Livog commented on Apr 12, 2026

    @Livog
    Contributor

    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.

  3. added 2 commits that reference this issue on Apr 12, 2026
    70a2a70
    46d733b
  4. Livog commented on Apr 15, 2026

    @Livog
    Contributor

    I no longer have a issue with this one after upgrading to 3.83.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions