Upsert with Drizzle in NestJS
Upsert rows with Drizzle in NestJS in one statement. upsert and massUpsert insert a row or update the one that holds its unique key.
To upsert with Drizzle in NestJS, call upsert(dto, { conflictTarget }) on your service. It inserts the row, or updates the existing row that already holds the same unique key, in one statement, and returns the row as stored either way. On PostgreSQL that statement is INSERT ... ON CONFLICT DO UPDATE; on MySQL it is INSERT ... ON DUPLICATE KEY UPDATE. massUpsert does the same for many rows in one transaction.
The conflict target needs a unique constraint
conflictTarget must name a column (or columns) with a unique constraint or unique index in the database. Without one, PostgreSQL refuses the ON CONFLICT clause.
How do I upsert a row?
Call upsert with the row and the unique column that decides "already exists":
const user = await users.upsert(
{ email: 'ada@example.com', name: 'Ada', role: 'admin' },
{ conflictTarget: 'email' },
);On PostgreSQL this runs:
INSERT INTO users (...) VALUES (...)
ON CONFLICT (email) DO UPDATE SET name = excluded.name, role = excluded.role, ...
RETURNING *The signature is upsert(data, upsertOptions?, options?). The second argument is UpsertOptions, the third is the usual SqlOperationOptions (transaction, relations, counts).
What options does upsert take?
UpsertOptions has two fields, and both are optional:
conflictTargetOptionalstring | string[]
Defaults to the primary key
updateOptionalstring[]
Defaults to every column you sent
The default update list includes updatedAt, because the insert sets it, so the timestamp moves when an existing row is updated. createdAt keeps its original value.
How do I get or create a row without changing it?
Pass update: []. The existing row stays as it is and still comes back, which turns upsert into a get-or-create:
const tag = await tags.upsert(
{ name: 'urgent' },
{ conflictTarget: 'name', update: [] },
);With nothing to overwrite, the package sets the conflict target to its own value so that the database still returns the row. On an entity with a version column, the version still goes up by one when the row existed.
Which hooks run on upsert?
validateCreate, beforeCreate and mapCreateDtoToEntity run, because the statement starts as an insert, and writableColumns applies to the body. The after hooks (afterCreate, afterUpdate) do not run: the package cannot tell an insert from an update without an extra query.
With global hooks on, upsert raises one before and one after event with the operation name upsert.
Does upsert work with tenant scope, versions and relations?
Yes, on PostgreSQL:
| Feature | What upsert does |
|---|---|
| Tenant scope | Writes the scope onto the inserted row and adds it to ON CONFLICT DO UPDATE ... WHERE. A conflict with another tenant's row updates nothing and is a 404. |
| Version column | Sets the version to 1 on insert and adds one on update. The version is never taken from the body. |
| Scope and version columns | Never overwritten from the body, even if you list them in update. |
options.relations, options.counts | Loaded onto the returned row, the same as after create. See Relations on writes. |
What errors can upsert return?
Upsert maps driver errors the same way create does:
| Cause | Status |
|---|---|
| A unique violation on a different key than the conflict target | 409 |
| A value the column cannot hold (too long, wrong type, failed check) | 400 |
| A foreign key that points at no row | 400 |
An unknown conflictTarget or update column | 400 |
| On a scoped entity, the key belongs to another tenant | 404 |
// sku is unique too: moving row 1 onto row 2's sku is a 409
await items.upsert({ id: 1, sku: 'A2', name: 'Lamp' }, { conflictTarget: 'id' });The full mapping is on the errors page.
How do I upsert many rows at once?
Call massUpsert(rows, upsertOptions?, options?). It sends one INSERT ... ON CONFLICT for the whole batch, inside a transaction:
const written = await users.massUpsert(
[
{ email: 'ada@example.com', name: 'Ada' },
{ email: 'bob@example.com', name: 'Bob' },
],
{ conflictTarget: 'email' },
);When the statement fails, the failing items are named in a BulkOperationException, each by its index in the input array, and nothing is written.
Two rows of the same batch with the same conflict key are a 400 on PostgreSQL ("the same key appears more than once in one upsert"), because one statement cannot update a row twice. Remove duplicates before you call it.
How does upsert differ on MySQL?
MySQL uses ON DUPLICATE KEY UPDATE, and three things change:
- Any unique key triggers the update. MySQL does not let you choose the key, so
conflictTargetdoes not pick it there. - The rows are read back. MySQL has no
RETURNING, so the package selects the rows by theconflictTargetcolumns after the write. Send a value for every conflict target column, otherwise the call is a 400. - Scoped entities are refused.
ON DUPLICATE KEY UPDATEhas noWHERE, so it cannot keep the update inside one tenant. Any upsert on a scoped entity is a 400 ("upsert on a scoped entity needs PostgreSQL") rather than a risk of overwriting another tenant's row.
Common questions
The primary key. Pass conflictTarget to use another unique column, or a list of columns for a composite unique constraint.
No. It returns the row as stored in both cases, and the after hooks do not run, because telling the two apart would cost an extra query.
Yes. Pass update: [] and the existing row comes back unchanged, which works as a get-or-create.
On PostgreSQL, yes: the scope is written onto new rows and a conflict with another tenant's row is a 404. On MySQL an upsert on a scoped entity is a 400.
Next steps
Bulk operations
massCreate, massUpdate and the other set writes, and the 400 body that names each failed item.
Optimistic locking
Add a version column so a stale write is a 409 instead of a lost update.
Errors
Every exception the package throws and the HTTP status it maps to.
Count, group by and aggregates in Drizzle
Drizzle aggregates in NestJS: count() rows, group by a column with countBy, and relation counts per row with options.counts. SUM needs a custom method.
Bulk insert and update with Drizzle
Bulk insert, update and delete with Drizzle in NestJS. Each mass method is one statement in a transaction, and a failure lists every failed item.