Create, update & delete
The write surface — create, createMany, update, updateMany, updateEach, delete, deleteMany, upsert, and upsertMany.
The write methods mirror the read API and return predictable shapes. Single-row mutations return the affected row; batch mutations return a summary.
create
Inserts one row and returns the created record:
const user = await client.users.create({
data: {
email: 'alice@example.com',
name: 'Alice',
active: true,
},
});createMany
Inserts many rows in one statement and returns a batch summary:
const batch = await client.users.createMany({
data: [
{ email: 'a@example.com', name: 'A', active: true },
{ email: 'b@example.com', name: 'B', active: false },
],
});
batch.count; // number inserted
batch.data; // inserted rows, when the driver supports RETURNINGcreate and createMany both support skipDuplicates:
const maybeCreated = await client.users.create({
data: {
email: 'alice@example.com',
name: 'Alice',
active: true,
},
skipDuplicates: ['email'],
});With single-row create, a skipped insert returns null. With createMany, count only includes rows that were actually inserted.
update
Updates a single matching row. The result is nullable (the target may not exist) and also exposes .throw():
const updated = await client.users.update({
where: { id: 1 },
data: { name: 'Alice Updated' },
});
if (!updated) {
throw new Error('Update target not found');
}Relation writes
Single-row create, update, and upsert accept relation commands inside their data payloads. Selectors must match exactly one related row:
const post = await client.posts.create({
data: {
author: { connect: { email: 'alice@example.com' } },
title: 'Connected post',
},
});
await client.users.update({
where: { id: 1 },
data: {
posts: {
connect: [{ id: 2 }, { id: 3 }],
disconnect: { id: 1 },
},
},
});
await client.users.update({
where: { id: 1 },
data: {
groups: { set: [{ id: 4 }, { id: 5 }] },
},
});create and the upsert.create branch support connect. update and the upsert.update branch support connect, disconnect, and set; set cannot be combined with the other commands for the same relation. To-one set: null disconnects an optional relation.
The root mutation and every relation change run in one implicit transaction unless the call is already inside client.transaction(). Required foreign keys, missing selectors, non-unique selectors, and invalid commands fail the entire operation and roll back scalar changes. createMany, updateMany, updateEach, and upsertMany intentionally remain scalar batch APIs.
updateMany
Updates every matching row and returns the affected count:
const batch = await client.users.updateMany({
where: { active: true },
data: { active: false },
});
batch.count; // rows updatedupdateEach
Updates many rows with different values in a single statement. Instead of applying the same value to all matching rows (like updateMany), updateEach generates a CASE expression per column so each row can have its own value:
const batch = await client.users.updateEach({
by: users.id,
data: [
{ id: 1, name: 'Alice Updated', city: 'New York' },
{ id: 2, name: 'Bob Updated', city: 'Chicago' },
],
update: {
name: (row) => row.name,
city: (row) => row.city,
},
});This generates a single SQL statement:
UPDATE users
SET
name = CASE WHEN id = 1 THEN 'Alice Updated' WHEN id = 2 THEN 'Bob Updated' ELSE name END,
city = CASE WHEN id = 1 THEN 'New York' WHEN id = 2 THEN 'Chicago' ELSE city END
WHERE id IN (1, 2)Options
| Option | Type | Required | Description |
|---|---|---|---|
by | TableColumn | Yes | Drizzle column instance used to match rows (e.g., users.id) |
data | Row[] | Yes | Source rows containing the by column value plus any extra fields |
update | object | Yes | Per-column callbacks (row) => value that resolve the new value for each row |
where | WhereArg | No | Extra filter combined with the generated by IN (...) predicate |
select | SelectInput | No | Scalar column projection for returned rows (relation include not supported) |
onEmpty | 'return' | 'throw' | No | Behavior when data is empty. Defaults to 'return' |
meta | Meta | No | Custom metadata forwarded to hooks |
Return type
updateEach returns Promise<BatchResult<T>>:
{
count: number; // number of rows affected
data?: T[]; // returned rows when `select` is provided and driver supports RETURNING
}Using where with updateEach
Combine a where filter with the by column to scope which rows are eligible for update:
const batch = await client.users.updateEach({
by: users.id,
data: [
{ id: 1, name: 'Alice' },
{ id: 2, name: 'Bob' },
],
update: { name: (row) => row.name },
where: { active: true }, // only update active users
});Projecting results
Use select to return specific scalar columns after the update:
const batch = await client.users.updateEach({
by: users.id,
data: [
{ id: 1, name: 'Alice' },
{ id: 2, name: 'Bob' },
],
update: { name: (row) => row.name },
select: { id: true, name: true, email: true },
});
console.log(batch.data); // [{ id: 1, name: 'Alice', ... }, ...]Empty input handling
By default, passing an empty data array returns { count: 0 }. Set onEmpty: 'throw' to raise an error instead:
// Returns { count: 0 }
await client.users.updateEach({
by: users.id,
data: [],
update: { name: (row) => row.name },
});
// Throws BetterDrizzleError
await client.users.updateEach({
by: users.id,
data: [],
update: { name: (row) => row.name },
onEmpty: 'throw',
});Duplicate detection
updateEach rejects duplicate by values in the data array. Each row must have a unique match column value:
// Throws — duplicate id values
await client.users.updateEach({
by: users.id,
data: [
{ id: 1, name: 'Alice' },
{ id: 1, name: 'Bob' }, // duplicate!
],
update: { name: (row) => row.name },
});When to use updateEach vs updateMany
Use updateMany when | Use updateEach when |
|---|---|
| All matching rows should have the same value | Each row needs a different value |
A simple WHERE filter is sufficient | You need per-row logic via callbacks |
| You don't need to return updated rows | You want to select and return specific columns |
delete
Deletes a single matching row and returns it (nullable, with .throw()):
const deleted = await client.users.delete({
where: { id: 1 },
});deleteMany
Deletes every matching row and returns the affected count:
const batch = await client.users.deleteMany({
where: { active: false },
});
batch.count; // rows deletedupsert
"Create if missing, update if present" — without branching in your own code:
const user = await client.users.upsert({
where: { email: 'alice@example.com' },
create: {
email: 'alice@example.com',
name: 'Alice',
active: true,
},
update: {
name: 'Alice Renamed',
},
});upsert is usually cleaner than "find, then branch, then write" when the behavior is genuinely upsert-shaped.
upsertMany
For bulk create-or-update flows, use upsertMany instead of looping over upsert():
import { sql } from 'drizzle-orm';
const result = await client.users.upsertMany({
data: [
{ email: 'alice@example.com', name: 'Alice', active: true },
{ email: 'bob@example.com', name: 'Bob', active: false },
],
target: ['email'],
update: (ctx) => ({
name: ctx.excluded.name,
active: ctx.excluded.active,
updatedAt: sql`now()`,
}),
batchSize: 500,
select: {
id: true,
email: true,
name: true,
},
});upsertMany is designed around native conflict handling, not userland loops.
targetis requiredupdatecan be'all', a column list, an object, or a functionbatchSizechunks large inserts without changing the APIwhereis available for SQL-only conflict-update predicates- relation
includeis intentionally not supported
Projecting write results
create, update, delete, upsert, createMany, updateEach, and upsertMany can project results. updateEach and upsertMany support select; the others support select and include:
const user = await client.users.create({
data: { email: 'writer@example.com', name: 'Writer', active: true },
select: { id: true, email: true },
});
// ^? { id: number; email: string }Notes
createMany,updateMany,updateEach, anddeleteManyreturn a{ count, data? }summary, not individual rows.upsertManyalso returns{ count, data? }.updateanddeleteare nullable by default because the target row may not exist — handlenull, or call.throw().- Returning affected rows (
data) depends on the driver supportingRETURNING. updateEachis native-first — it generates a singleUPDATE ... CASEstatement instead of per-row loops.updateEachrejects duplicatebyvalues in the input data.upsertManyis intentionally native-first and currently fails fast on unsupported dialects instead of degrading to slow per-row loops.- Need lifecycle behavior on writes — timestamps, soft delete, audit trails? That belongs in plugins or hooks, not duplicated per call.