Last Updated: 3/20/2026
Merge Queries
The MERGE statement (also known as “upsert” in some databases) combines insert, update, and delete operations into a single atomic statement. Kysely supports merge queries through the db.mergeInto() method, available in dialects that support the SQL standard MERGE syntax.
Basic Merge Structure
A merge query requires three components: a target table, a source table or query, and one or more conditional actions. Start with db.mergeInto() and specify the source with .using():
const result = await db
.mergeInto('person')
.using('pet', 'person.id', 'pet.owner_id')
.whenMatched()
.thenDelete()
.execute()The generated SQL (PostgreSQL):
merge into "person"
using "pet" on "person"."id" = "pet"."owner_id"
when matched then deleteThis deletes rows from person where a matching row exists in pet based on the join condition.
The Using Clause
The .using() method (defined in src/query-builder/merge-query-builder.ts) specifies the source table and join condition. It accepts the same syntax as .innerJoin():
// Simple join on two columns
await db
.mergeInto('person')
.using('pet', 'person.id', 'pet.owner_id')
.whenMatched()
.thenDelete()
.execute()
// Join with a callback for complex conditions
await db
.mergeInto('person')
.using('pet', (join) =>
join
.onRef('person.id', '=', 'pet.owner_id')
.on('pet.species', '=', 'dog')
)
.whenMatched()
.thenDelete()
.execute()You can also use a subquery as the source:
await db
.mergeInto('person')
.using(
db.selectFrom('pet')
.select(['owner_id', 'name'])
.where('species', '=', 'cat')
.as('cat_owners'),
'person.id',
'cat_owners.owner_id'
)
.whenMatched()
.thenUpdateSet({ has_cat: true })
.execute()When Matched Actions
After calling .using(), you get a WheneableMergeQueryBuilder that provides conditional action methods. The .whenMatched() method specifies actions to take when a row in the target table matches a row in the source:
// Delete matched rows
await db
.mergeInto('person')
.using('pet', 'person.id', 'pet.owner_id')
.whenMatched()
.thenDelete()
.execute()
// Update matched rows
await db
.mergeInto('person')
.using('pet', 'person.id', 'pet.owner_id')
.whenMatched()
.thenUpdateSet({ has_pet: true })
.execute()
// Do nothing for matched rows (PostgreSQL)
await db
.mergeInto('person')
.using('pet', 'person.id', 'pet.owner_id')
.whenMatched()
.thenDoNothing()
.execute()The MatchedThenableMergeQueryBuilder class (defined in src/query-builder/merge-query-builder.ts) provides three action methods:
thenDelete(): Delete the matched row from the target tablethenUpdateSet(updates): Update the matched row with the specified valuesthenUpdate(callback): Update using a fullUpdateQueryBuilderfor complex updatesthenDoNothing(): Take no action (PostgreSQL only)
When Matched with Conditions
Add an AND condition to .whenMatched() using .whenMatchedAnd():
await db
.mergeInto('person')
.using('pet', 'person.id', 'pet.owner_id')
.whenMatchedAnd('person.first_name', '=', 'John')
.thenDelete()
.execute()The generated SQL (PostgreSQL):
merge into "person"
using "pet" on "person"."id" = "pet"."owner_id"
when matched and "person"."first_name" = $1 then deleteThe condition can reference columns from both the target table and the source table. Use .whenMatchedAndRef() when both sides of the condition are column references:
await db
.mergeInto('person')
.using('pet', 'person.id', 'pet.owner_id')
.whenMatchedAndRef('person.first_name', '=', 'pet.name')
.thenDelete()
.execute()When Not Matched Actions
The .whenNotMatched() method specifies actions for rows in the source that don’t match any row in the target. Typically, this means inserting new rows:
await db
.mergeInto('person')
.using('pet', 'person.id', 'pet.owner_id')
.whenNotMatched()
.thenInsertValues({
first_name: 'Unknown',
last_name: 'Owner',
})
.execute()The generated SQL (PostgreSQL):
merge into "person"
using "pet" on "person"."id" = "pet"."owner_id"
when not matched then
insert ("first_name", "last_name") values ($1, $2)The NotMatchedThenableMergeQueryBuilder class provides:
thenInsertValues(values): Insert a new row with the specified valuesthenDoNothing(): Take no action (PostgreSQL only)
You can reference source table columns in the insert values:
await db
.mergeInto('person')
.using('pet', 'person.id', 'pet.owner_id')
.whenNotMatched()
.thenInsertValues((eb) => ({
first_name: eb.ref('pet.name'),
last_name: eb.val('(Pet Owner)'),
}))
.execute()When Not Matched with Conditions
Add conditions to .whenNotMatched() using .whenNotMatchedAnd(). Note that you can only reference source table columns, not target table columns (since the target row doesn’t exist):
await db
.mergeInto('person')
.using('pet', 'person.id', 'pet.owner_id')
.whenNotMatchedAnd('pet.species', '=', 'dog')
.thenInsertValues({
first_name: 'Dog',
last_name: 'Owner',
})
.execute()The generated SQL (PostgreSQL):
merge into "person"
using "pet" on "person"."id" = "pet"."owner_id"
when not matched and "pet"."species" = $1 then
insert ("first_name", "last_name") values ($2, $3)When Not Matched By Source
MS SQL Server supports a third condition type: WHEN NOT MATCHED BY SOURCE. This matches rows in the target table that have no corresponding row in the source:
await db
.mergeInto('person')
.using('pet', 'person.id', 'pet.owner_id')
.whenNotMatchedBySource()
.thenDelete()
.execute()This deletes people who don’t own any pets. The whenNotMatchedBySource() method returns a MatchedThenableMergeQueryBuilder (like whenMatched()) because you can reference target table columns.
Multiple When Clauses
Merge queries can have multiple WHEN clauses. Kysely evaluates them in the order you specify:
await db
.mergeInto('person')
.using('pet', 'person.id', 'pet.owner_id')
.whenMatchedAnd('person.age', '<', 18)
.thenUpdateSet({ guardian_required: true })
.whenMatched()
.thenUpdateSet({ has_pet: true })
.whenNotMatchedAnd('pet.species', '=', 'dog')
.thenInsertValues({
first_name: 'Dog',
last_name: 'Owner',
})
.whenNotMatched()
.thenInsertValues({
first_name: 'Pet',
last_name: 'Owner',
})
.execute()The generated SQL (PostgreSQL):
merge into "person"
using "pet" on "person"."id" = "pet"."owner_id"
when matched and "person"."age" < $1 then
update set "guardian_required" = $2
when matched then
update set "has_pet" = $3
when not matched and "pet"."species" = $4 then
insert ("first_name", "last_name") values ($5, $6)
when not matched then
insert ("first_name", "last_name") values ($7, $8)Each row is processed by the first matching WHEN clause. Once a clause matches, subsequent clauses are skipped for that row.
Complex Updates
Use .thenUpdate() instead of .thenUpdateSet() when you need the full power of an UpdateQueryBuilder:
import { sql } from 'kysely'
await db
.mergeInto('person')
.using('pet', 'person.id', 'pet.owner_id')
.whenMatched()
.thenUpdate((ub) => ub
.set('pet_count', (eb) => eb.fn.coalesce(eb.ref('pet_count'), eb.lit(0)) + 1)
.set('updated_at', sql`now()`)
)
.execute()The callback receives an UpdateQueryBuilder (without a table specified) and must return it after calling .set() one or more times.
Returning Results
Like insert, update, and delete queries, merge queries support .returning() and .returningAll() on dialects that support it:
const result = await db
.mergeInto('person')
.using('pet', 'person.id', 'pet.owner_id')
.whenMatched()
.thenUpdateSet({ has_pet: true })
.whenNotMatched()
.thenInsertValues({
first_name: 'Unknown',
last_name: 'Owner',
})
.returningAll()
.execute()
// result is an array of person rows that were inserted or updatedOn dialects that don’t support RETURNING, the result is an array containing a single MergeResult object with a numChangedRows property.
The MergeResult Type
When a merge query doesn’t use .returning(), it returns a MergeResult object (defined in src/query-builder/merge-result.ts):
interface MergeResult {
readonly numChangedRows: bigint | undefined
}The numChangedRows property contains the total number of rows inserted, updated, or deleted by the merge operation. Some dialects may not provide this information, in which case the value is undefined.
Dialect Support
Merge queries are supported in:
- PostgreSQL (15+): Full support including
DO NOTHING - MS SQL Server: Full support including
WHEN NOT MATCHED BY SOURCE - Oracle: Full support
- DB2: Full support
MySQL and SQLite do not support the standard MERGE syntax. Use INSERT ... ON DUPLICATE KEY UPDATE (MySQL) or INSERT ... ON CONFLICT (SQLite) instead.
What’s Next
- CRUD Operations: Learn about standard insert, update, and delete operations.
- Transactions: Ensure merge operations are atomic using transactions.