Skip to content

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

@razmenaia

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions