Upserts
Insert a row, or update the one that is already there, in one statement.
Setup
The code of this recipe depends on these declarations: the imports, the schema classes, the repo, and the migration that creates the tables. Run the recipe in an empty database of its own: each recipe creates its own tables, and some have the same names (posts) with other columns.
import { postgres } from "@egon-orm/postgres";
import { change, createRepo, from, Schema, t, timestamps } from "egon-orm";
import { createMigrator, defineMigration } from "egon-orm/migration";
const repo = createRepo({ adapter: postgres({ connectionString: process.env.DATABASE_URL }) });
class Setting extends Schema("settings", {
key: t.string().primaryKey(),
value: t.json<unknown>(),
...timestamps(),
}) {}
class DailyView extends Schema("daily_views", {
pageId: t.integer().primaryKey(),
day: t.date().primaryKey(), // a "YYYY-MM-DD" string
views: t.integer(),
}) {}
// The tables, created by a migration.
const tables = defineMigration({
up(m) {
m.createTable("settings", (table) => {
table.text("key").notNull().primaryKey();
table.jsonb("value").notNull();
table.timestamps();
});
m.createTable("daily_views", (table) => {
table.integer("page_id").notNull().primaryKey(); // a composite key: page and day
table.date("day").notNull().primaryKey();
table.integer("views").notNull();
});
},
down(m) {
m.dropTable("daily_views");
m.dropTable("settings");
},
});
/** Creates the tables, in the database that DATABASE_URL names. */
export async function createTables(): Promise<void> {
const admin = postgres({ connectionString: process.env.DATABASE_URL });
await createMigrator({
adapter: admin,
migrations: [{ version: 20261021001201, name: "upserts_tables", migration: tables }],
}).up();
await admin.close();
}Insert or update one row
The examples below start from these rows, after creating the tables:
await createTables();// Insert the setting, or replace its value (and updatedAt) if the key exists.
const saved = await repo.insertOrThrow(change(Setting, { key: "theme", value: "dark" }), {
onConflict: { target: ["key"], update: ["value", "updatedAt"] },
});targetis the unique key that may conflict: fields,{ constraint: "name" }, or afragmentfor an index on an expression. MySQL takes no target: any unique key conflicts there.updateis"all"(every inserted column), a list of fields, or{ except: [...] }. ListupdatedAtwhen the update should touch it.- The returned row is the one in the table after the statement, whichever happened.
Insert unless it exists
// Insert unless it exists; data is null when it did.
const created = await repo.insert(change(Setting, { key: "theme", value: "blue" }), {
onConflict: "nothing",
});
if (created.success && created.data === null) {
// the key was taken: nothing was written
}With onConflict: "nothing", data is null when the row existed; its type includes null only with this option.
Many rows at once
// An analytics import: store the latest totals per page and day, many rows in one
// statement. A row that is already there takes the imported values.
await repo.insertAll(
DailyView,
[
{ pageId: 1, day: "2026-10-20", views: 1 },
{ pageId: 2, day: "2026-10-20", views: 1 },
],
{ onConflict: { target: ["pageId", "day"], update: "all" } },
);insertAll skips changesets: use it for data you trust, such as imports.
update writes the values of the row being inserted; it does not compute from the row that is there. To increment a counter, insert the row if it is missing, and otherwise update it with an expression:
export async function countView(pageId: number, day: string) {
const inserted = await repo.insertOrThrow(change(DailyView, { pageId, day, views: 1 }), {
onConflict: "nothing",
});
if (inserted === null) {
await repo.updateAll(
from(DailyView, "v")
.where({ pageId, day })
.set(({ v }) => ({ views: v.views.add(1) })),
);
}
}DailyView.day is a t.date() field: a calendar date with no time, which reads and writes as a "YYYY-MM-DD" string (Types), so countView takes the day as one. Counting the same page and day twice:
await countView(3, "2026-10-21"); // a new row: views 1
await countView(3, "2026-10-21"); // the same row: views 2
const counted = await repo.getOrThrow(DailyView, { pageId: 3, day: "2026-10-21" });
counted.views; // => 2Inserting first is safe when two requests count at once: one inserts, the other finds the row there and increments it.
Good to know
- An upsert is not
insertOrUpdate.insertOrUpdatechooses insert or update from the changeset (new record or loaded row) and never asks the database;onConflictlets the database decide. - A changeset with nested association changes cannot be combined with
onConflict. - To read a row or create it, see Find or create.
See also
- Upserts in the repo guide
- Bulk writes

