Bulk operations
massCreate, massUpdate, massSoftDelete, massRestore, massDelete: transactional, one savepoint per item, and a 400 body that lists every failed item.
Bulk operations run inside a single database transaction. If any item fails, the entire operation is rolled back and a BulkOperationException (400) is thrown. Its response body lists each failed item with its own status and message.
The five methods
await service.massCreate([dto1, dto2, dto3]);
await service.massUpdate([1, 2, 3], { status: 'archived' });
await service.massSoftDelete([1, 2, 3]);
await service.massRestore([1, 2, 3]);
await service.massDelete([1, 2, 3]);| Method | Returns | Failure mode |
|---|---|---|
massCreate(data[], options?) | Promise<T[]> | Tries every item, then throws BulkOperationException if any failed. |
massUpdate(ids[], data, options?) | Promise<T[]> | Same. |
massSoftDelete(ids[], options?) | Promise<boolean> | Same. |
massRestore(ids[], options?) | Promise<T[]> | Same. |
massDelete(ids[], options?) | Promise<boolean> | Same. |
Transactional semantics
All bulk methods wrap their work in a single transaction. If any row fails (constraint violation, FK error, validation, etc.), the transaction is rolled back. No partial writes are visible to subsequent reads.
Each item runs in its own savepoint inside that transaction. On PostgreSQL a failed statement aborts the whole transaction, so without the savepoint every item after the first failure would also fail with 25P02 ("current transaction is aborted"). With it, only the items that are actually wrong show up in the error list. Before 3.3.1, massCreate([ok, duplicate, ok, ok]) reported three failures; now it reports one.
Still one query per item
The items still run one after another, and update, delete, soft delete and
restore each check that the row exists first, so a bulk call over 100 ids
costs about 200 queries. Set-based bulk writes (UPDATE ... WHERE id IN)
are planned.
A bulk operation that succeeds returns its full result. A bulk operation
that fails throws BulkOperationException and none of the rows are
modified. Use this as a guarantee, not just an optimization.
What does a failed bulk call return over HTTP?
A 400 whose body names every failed item. massCreate identifies an item by its index in the input array; the other methods use the id you passed:
{
"statusCode": 400,
"error": "Bad Request",
"message": "Mass create errors. 1 error(s) occurred.",
"errors": [
{ "index": 1, "status": 409, "message": "users with email 'ann@x.io' already exists" }
]
}status and message are what that item alone would have produced: 409 for a duplicate, 404 for a missing id, 400 for bad input. An error that is not an HTTP exception (a driver fault, say) appears as status: 500 with the message Internal server error, so SQL and parameters never reach the client. Let the exception propagate and Nest's default filter sends this body as is.
BulkOperationException in code
import { BulkOperationException } from 'nestjs-drizzle-crud';
try {
await users.massCreate([{ name: 'a', email: 'a@x.io' }, { name: 'b', email: 'taken@x.io' }]);
} catch (err) {
if (err instanceof BulkOperationException) {
// The original errors, one per failed item
for (const { index, id, error } of err.errors) {
console.error(`Item ${index ?? id} failed:`, error);
}
// The response body shown above
console.log(err.getResponse());
}
throw err;
}The errors property keeps the original Error objects for logging. The response body carries the safe summary.
Lifecycle hooks during bulk operations
The standard beforeCreate / afterCreate (and the corresponding update / delete / softDelete / restore hooks) fire once per row, in array order. A massCreate([a, b, c]) calls beforeCreate three times before the insert, then the insert, then afterCreate three times.
If any row's beforeCreate throws, that item is recorded as failed, the rest still run, and the transaction rolls back at the end. No rows are written.
Passing options
All bulk methods accept the same SqlOperationOptions as their non-bulk counterparts. In particular, you can pass a custom transaction:
await service.executeSqlTransaction(async (tx) => {
await service.massCreate(dtos, { transaction: tx });
await otherService.massUpdate(otherIds, { flag: true }, { transaction: tx });
});See Transactions for the full pattern.
Limits and pagination
Bulk methods don't paginate. They accept the full array in memory and execute it as a single transaction. For very large bulk operations (thousands of rows), consider:
- Chunking on the caller side. Process arrays in batches of 100–500 rows and call
massCreateper batch. - Switching to a streaming insert. Write a custom method that uses Drizzle's
db.insert(...).values(chunk).execute()chain with explicit chunking.
The cap is on the database side, not the package. Single transactions holding thousands of row locks can starve other connections.