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":

users.sync.ts
const user = await users.upsert(
  { email: 'ada@example.com', name: 'Ada', role: 'admin' },
  { conflictTarget: 'email' },
);

On PostgreSQL this runs:

upsert.sql
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:

conflictTargetOptional

string | string[]

The unique column or columns that decide whether the row already exists. Use the key in your Drizzle table object. An unknown column is a 400. Ignored on MySQL.

Defaults to the primary key

updateOptional

string[]

The columns to overwrite when the row exists. The default leaves out the conflict target, the primary key, createdAt, the version column and the scope columns. An unknown column is a 400.

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:

tags.sync.ts
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:

FeatureWhat upsert does
Tenant scopeWrites 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 columnSets the version to 1 on insert and adds one on update. The version is never taken from the body.
Scope and version columnsNever overwritten from the body, even if you list them in update.
options.relations, options.countsLoaded 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:

CauseStatus
A unique violation on a different key than the conflict target409
A value the column cannot hold (too long, wrong type, failed check)400
A foreign key that points at no row400
An unknown conflictTarget or update column400
On a scoped entity, the key belongs to another tenant404
items.sync.ts
// 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:

users.import.ts
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 conflictTarget does not pick it there.
  • The rows are read back. MySQL has no RETURNING, so the package selects the rows by the conflictTarget columns after the write. Send a value for every conflict target column, otherwise the call is a 400.
  • Scoped entities are refused. ON DUPLICATE KEY UPDATE has no WHERE, 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

Next steps

Last updated View change history

On this page