---
title: Write data
description: Insert, patch, delete, and append-upsert rows on a board, from the embedded engine or the HTTP client.
---

Every write goes to the board's write path, never to a replica, and every write returns the watermark it committed at. Hand that watermark back as `minWatermark` (Engine) or through `atLeast()` (client) to read your own write. See the [consistency model](/concepts/consistency) for the full picture.

## Insert one row

**Engine**

```ts title="insert.ts"
import { Engine } from "lakefront";

const result = await engine.insert("acme_roadmap", {
id: "task-1",
state: "active",
title: "Ship the thing",
priority: 3,
});

const watermark = result.unwrap();

````

**HTTP client**

```ts title="insert.ts"
import { connect } from "lakefront/client";

const db = connect({ url }).board("acme_roadmap");

const watermark = (
  await db.insert({ id: "task-1", state: "active", title: "Ship the thing" })
).unwrap();
````

`insert` and every mutation below accept a board that does not exist yet under the default `autoCreate: "on-write"`, and a column that has not been seen before under the default `columns: "infer"`. Both are configurable. See the [schema guide](/guides/schema).

## Update one row

```ts title="update.ts"
await engine.update("acme_roadmap", "task-2", { status: "done" });
```

The client version is identical in shape: `db.update(id, patch, options?)`.

## Delete one row

```ts title="delete.ts"
await engine.delete("acme_roadmap", "task-1");
// db.delete("task-1") on the client
```

## upsert: append-based, opt-in

`upsert` writes a new version of a row instead of issuing an `UPDATE`. An `UPDATE` on a DuckLake table has to locate the existing rows and mark them deleted before it can write the new values. Appending skips that step entirely, since the read path already reduces by primary key on the way out (see the [performance numbers](/concepts/performance)).

That speed has two costs, which is why `update()` remains the default and `upsert` is opt-in:

- Rows must be **complete**: `upsert` replaces a row, it does not patch one.
- Superseded versions accumulate in the table until `compact()` reclaims them.
- The winner across concurrent writers is chosen by version stamp, so it's last-writer-wins to whatever precision the writers' clocks agree on.

```ts title="upsert.ts"
await engine.upsert("acme_roadmap", {
  id: "task-1",
  state: "active",
  title: "Ship the thing",
  status: "in_review",
});

// Later, once churn has built up superseded versions:
await engine.compact("acme_roadmap");
```

`upsert` and `compact` are engine-only. The wire contract does not expose them over HTTP: they're data-shape and maintenance decisions made next to the engine, not a client call.

## Batch writes: the Many forms

Each singular verb has a batch counterpart that takes an array and lands it as one write: `insertMany`, `updateMany`, `deleteMany` on the client, and those three plus `upsertMany` on the engine. A singular call is sugar over its Many form with a one-element array.

### insertMany

**Engine**

```ts title="insert-many.ts"
await engine.insertMany("acme_roadmap", [
  { id: "task-1", state: "active", title: "Ship the thing", priority: 3 },
  { id: "task-2", state: "active", title: "Fix the bug", priority: 1 },
]);
```

**HTTP client**

```ts title="insert-many.ts"
await db.insertMany([
  { id: "task-1", state: "active", title: "Ship the thing", priority: 3 },
  { id: "task-2", state: "active", title: "Fix the bug", priority: 1 },
]);
```

### updateMany: many patches, one write

`updateMany` applies many single-row patches as one atomic write: the shape an OLTP loop actually produces (patch row after row of one board) without paying a WAL append or lake commit per row. Patches apply in array order, so two patches to the same `id` stack exactly as two separate `update()` calls would.

**Engine**

```ts title="update-many.ts"
await engine.updateMany("acme_roadmap", [
  { id: "task-1", patch: { status: "in_review" } },
  { id: "task-2", patch: { status: "done" } },
]);
```

**HTTP client**

```ts title="update-many.ts"
await db.updateMany([
  { id: "task-1", patch: { status: "in_review" } },
  { id: "task-2", patch: { status: "done" } },
]);
```

On the wire this is `mutate.update {updates}`.

### deleteMany

```ts title="delete-many.ts"
await engine.deleteMany("acme_roadmap", ["task-1", "task-2"]);
// db.deleteMany(["task-1", "task-2"]) on the client
```

On the wire this is `mutate.delete {ids}`.

### upsertMany

```ts title="upsert-many.ts"
await engine.upsertMany("acme_roadmap", [
  { id: "task-1", state: "active", title: "Ship the thing", status: "in_review" },
  { id: "task-2", state: "active", title: "Fix the bug", status: "done" },
]);
```

Engine-only, same as `upsert`.

## Options every write accepts

```ts
interface WriteOptions {
  readonly idempotencyKey?: string;
  readonly actor?: string;
}
```

`actor` is recorded on the WAL records a write produces (audit identity for the change feed) under `writePath: "wal"` only. The lake write path keeps only current state, so there is nothing to attach it to there. `idempotencyKey` makes a mutation safely retryable: see the [idempotency guide](/guides/idempotency) for the full contract.

On the client, the same knob is `MutateOptions`, passed as a second argument:

```ts
import type { MutateOptions } from "lakefront/client";

await db.insertMany(rows, { idempotencyKey: crypto.randomUUID() } satisfies MutateOptions);
```

## Handling the result

Every write returns `Result<number, EngineError>` on the engine and `Result<number, WireError>` on the client: the `number` is the watermark. The happy path unwraps:

```ts
const watermark = (await engine.insertMany("acme_roadmap", rows)).unwrap();
```

A refusal is a typed value, not a throw. Branch on `error._tag`:

```ts
const result = await engine.insertMany("acme_roadmap", rows);
if (result.isErr()) {
  switch (result.error._tag) {
    case "ReservedColumnName":
      // a column name started with "_": see /guides/schema
      break;
    case "ColumnTypeConflict":
      // a value didn't fit the column's inferred type
      break;
    default:
      console.error(result.error.message);
  }
}
```

The full tag catalog is in the [error reference](/reference/errors).
