# Write and move rows

> Insert a row, update one row or many, edit a note’s text, upsert, guard against concurrent edits, delete and move rows.

The engine checks every write before it changes anything, records it in the journal, and lets you undo it with `undo_migration`. A write answers with the notes it created, changed, moved or deleted.

The first example adds a row to the film diary of [Tables](https://datanotes.dev/guides/database-objects/tables/); the others work on a small diary of five classic films.

When several clients write at once, the engine checks each write on the latest data. [Concurrent writes](https://datanotes.dev/develop/concurrency/) explains the rules and when to retry.

## Insert a row

`insert_row` writes a new note. The engine fills in the default (`status`) and an `id`, and names the note from `filename`. `body` sets the note’s text.

**`insert_row`**

```json
{
  "table": "film",
  "values": { "title": "Metropolis", "year": 1927, "minutes": 153 },
  "body": "Next on my list. Two and a half hours of silent film need a quiet evening.\n"
}
```

Answer:

```json
{
  "ok": true,
  "op": "insert_row",
  "files_created": 1,
  "planned_creates": ["datanotes/main/public/film/Metropolis.md"],
  "commits": [{ "table": "main.public.film", "version": 1, "operation": "WRITE" }],
  "migration_id": "20261012T165219-insert_row-insert-into-film"
}
```

`datanotes/main/public/film/Metropolis.md` now reads:

```markdown
---
table: main.public.film
id: 01a13107-85bc-7ff7-84d8-05f5c1334887
title: Metropolis
year: 1927
minutes: 153
status: to watch
---
Next on my list. Two and a half hours of silent film need a quiet evening.
```

## Pick a free file name for a new row

Without `path`, the table’s filename pattern names the new note. When another note has that name, the engine adds `-2`, `-3` and so on.

A `path` you pass must be free. Windows and macOS see `Nosferatu.md` and `nosferatu.md` as one file. So on every system, the engine refuses a name that another note has in another case, and names that note:

**`insert_row`**

```json
{
  "table": "film",
  "values": { "title": "The Kid", "year": 1921 },
  "path": "datanotes/main/public/film/nosferatu.md"
}
```

Refused:

```text
insert_row refused — nothing was written:
conflict datanotes/main/public/film/nosferatu.md: note already exists in another case: "datanotes/main/public/film/Nosferatu.md" (one file on Windows and macOS)
Hint: Another note has this file name, maybe in another case (Windows and macOS see one file): pass another "path", give the row values its table's filename pattern turns into a free name, or update that note with update_row. Then call insert_row again.
Docs: https://datanotes.dev/guides/read-and-write/write-rows.md#pick-a-free-file-name-for-a-new-row (offline: read_docs("guides/read-and-write/write-rows#pick-a-free-file-name-for-a-new-row"))
```

## Update one row

`update_row` changes exactly one row, which you address by `path` or by its `id`. `set` sets columns and `unset` removes them. The engine checks the whole row before it writes anything.

**`update_row`**

```json
{
  "table": "film",
  "path": "datanotes/main/public/film/His Girl Friday.md",
  "set": { "status": "watched", "rating": 5, "tags": ["screwball"] }
}
```

Answer:

```json
{
  "ok": true,
  "op": "update_row",
  "notes_changed": 1,
  "planned_edits": [
    {
      "path": "datanotes/main/public/film/His Girl Friday.md",
      "set": { "status": "watched", "rating": 5, "tags": ["screwball"] },
      "remove": []
    }
  ],
  "commits": [{ "table": "main.public.film", "version": 6, "operation": "UPDATE" }],
  "migration_id": "20261012T165223-update_row-update-film"
}
```

**`update_row`**

```json
{
  "table": "film",
  "path": "datanotes/main/public/film/His Girl Friday.md",
  "unset": ["tags"]
}
```

The engine refuses a path that isn’t a row, and creates nothing:

**`update_row`**

```json
{
  "table": "film",
  "path": "datanotes/main/public/film/Night of the Living Dead.md",
  "set": { "rating": 4 }
}
```

Refused:

```text
film has no row at datanotes/main/public/film/Night of the Living Dead.md
Hint: Name the row by its "path" (the vault path of its note, e.g. "Films/Metropolis.md") or its "id": select_rows with columns ["path", "id"] and a "where" finds them.
Docs: https://datanotes.dev/guides/read-and-write/write-rows.md#update-one-row (offline: read_docs("guides/read-and-write/write-rows#update-one-row"))
```

## Edit a note’s text

`update_row` edits the note’s text with these arguments:

* **`body_sections`**: replaces the text under one heading and leaves the rest of the note alone; the engine adds a missing heading at the end
* **`body_append`** and **`body_prepend`**: add text after or before the existing text
* **`body`**: replaces all of the text

**`update_row`**

```json
{
  "table": "film",
  "path": "datanotes/main/public/film/Nosferatu.md",
  "body_sections": {
    "Rewatch": "Watched it again in October. The shadow on the stairs still works a century later."
  },
  "body_append": "Next: Metropolis, for more German Expressionism.\n"
}
```

Answer:

```json
{
  "ok": true,
  "op": "update_row",
  "notes_changed": 1,
  "planned_edits": [
    {
      "path": "datanotes/main/public/film/Nosferatu.md",
      "set": {},
      "remove": [],
      "body": true
    }
  ],
  "commits": [{ "table": "main.public.film", "version": 8, "operation": "UPDATE" }],
  "migration_id": "20261012T165223-update_row-update-film-3",
  "ambiguous_mentions": [
    {
      "path": "datanotes/main/public/film/Nosferatu.md",
      "text": "Metropolis",
      "reason": "sentence_start",
      "candidates": ["datanotes/main/public/film/Metropolis.md"],
      "context": "Next: Metropolis, for more German Expressionism.",
      "hint": "\"Metropolis\" starts a sentence, a line or a label, so its capital does not make it a name: read the note and the text around it, then write the link with update_row and autolink: false, or leave the text as it is."
    }
  ]
}
```

`datanotes/main/public/film/Nosferatu.md` now reads:

```markdown
---
table: main.public.film
id: 01a13107-9666-7510-ae12-2ba7a197a17a
title: Nosferatu
year: 1922
status: watched
rating: 5
---
## Notes


Max Schreck doesn’t need a single word.


## Rewatch


Watched it again in October. The shadow on the stairs still works a century later.


Next: Metropolis, for more German Expressionism.
```

The engine links Metropolis, the note of another film, and lists the link in `links_added`. [Automatic links](https://datanotes.dev/guides/read-and-write/automatic-links/) shows which names it links.

## Guard a write against concurrent edits with `expect`

A person may edit a note between an agent’s read and its write. `expect` makes the write a compare-and-set: it goes through only if the row still has those values.

**`update_row`**

```json
{
  "table": "film",
  "path": "datanotes/main/public/film/Sherlock Jr..md",
  "expect": { "status": "to watch" },
  "set": { "status": "watched" }
}
```

Answer:

```json
{
  "ok": true,
  "op": "update_row",
  "notes_changed": 1,
  "planned_edits": [
    {
      "path": "datanotes/main/public/film/Sherlock Jr..md",
      "set": { "status": "watched" },
      "remove": []
    }
  ],
  "commits": [{ "table": "main.public.film", "version": 9, "operation": "UPDATE" }],
  "migration_id": "20261012T165223-update_row-update-film-4"
}
```

Run the same call again. `status` is no longer `to watch`, so the engine writes nothing.

**`update_row`**

```json
{
  "table": "film",
  "path": "datanotes/main/public/film/Sherlock Jr..md",
  "expect": { "status": "to watch" },
  "set": { "status": "watched" }
}
```

Refused:

```text
update_row refused — nothing was written:
conflict datanotes/main/public/film/Sherlock Jr..md: row changed: expected status = "to watch", found "watched" — read it again
Hint: The row changed since you read it: read it again (select_rows), then retry update_row with "expect" set to what you read.
Docs: https://datanotes.dev/guides/read-and-write/write-rows.md#guard-a-write-against-concurrent-edits-with-expect (offline: read_docs("guides/read-and-write/write-rows#guard-a-write-against-concurrent-edits-with-expect"))
```

## Update many rows

`update_rows` changes every row matching `where`. `expect_count` guards the write: the engine refuses it unless exactly that many rows match, so a filter that’s too broad changes nothing.

**`update_rows`**

```json
{
  "table": "film",
  "where": { "status": "watched" },
  "set": { "tags": ["seen-2026"] },
  "expect_count": 2
}
```

Refused:

```text
update_rows refused — nothing was written:
conflict datanotes/main/public/film: "where" matched 3 rows, expected 2: nothing was written
Hint: Check the rows with select_rows and the same "where", then narrow it or change expect_count.
Docs: https://datanotes.dev/guides/read-and-write/write-rows.md#update-many-rows (offline: read_docs("guides/read-and-write/write-rows#update-many-rows"))
```

**`update_rows`**

```json
{
  "table": "film",
  "where": { "status": "watched" },
  "set": { "tags": ["seen-2026"] },
  "expect_count": 3
}
```

Answer:

```json
{
  "ok": true,
  "op": "update_rows",
  "notes_changed": 3,
  "planned_edits": [
    {
      "path": "datanotes/main/public/film/His Girl Friday.md",
      "set": { "tags": ["seen-2026"] },
      "remove": []
    },
    {
      "path": "datanotes/main/public/film/Nosferatu.md",
      "set": { "tags": ["seen-2026"] },
      "remove": []
    },
    {
      "path": "datanotes/main/public/film/Sherlock Jr..md",
      "set": { "tags": ["seen-2026"] },
      "remove": []
    }
  ],
  "commits": [{ "table": "main.public.film", "version": 10, "operation": "UPDATE" }],
  "migration_id": "20261012T165223-update_rows-update-film"
}
```

## Insert or update rows with `merge_rows`

`merge_rows` is an upsert: it inserts rows or updates existing ones. A row whose `on` columns match an existing row updates that row, and the engine inserts the others. The whole merge is one operation with one undo.

**`merge_rows`**

```json
{
  "table": "film",
  "on": ["title"],
  "rows": [
    { "title": "Sherlock Jr.", "rating": 4 },
    { "title": "Battleship Potemkin", "year": 1925 }
  ]
}
```

Answer:

```json
{
  "ok": true,
  "op": "merge_rows",
  "notes_changed": 1,
  "files_created": 1,
  "planned_edits": [
    {
      "path": "datanotes/main/public/film/Sherlock Jr..md",
      "set": { "rating": 4 },
      "remove": []
    }
  ],
  "planned_creates": ["datanotes/main/public/film/Battleship Potemkin.md"],
  "commits": [{ "table": "main.public.film", "version": 11, "operation": "MERGE" }],
  "migration_id": "20261012T165223-merge_rows-merge-2-rows-into-film"
}
```

**`select_rows`**

```json
{
  "table": "film",
  "columns": ["title", "rating", "status"],
  "where": { "title": { "in": ["Sherlock Jr.", "Battleship Potemkin"] } }
}
```

Answer:

```json
{
  "table": "main.public.film",
  "total": 2,
  "rows": [
    {
      "path": "datanotes/main/public/film/Battleship Potemkin.md",
      "title": "Battleship Potemkin",
      "rating": null,
      "status": "to watch"
    },
    {
      "path": "datanotes/main/public/film/Sherlock Jr..md",
      "title": "Sherlock Jr.",
      "rating": 4,
      "status": "watched"
    }
  ]
}
```

## Delete rows into the archive

By default, `delete_rows` moves the matching rows to the table’s archive folder, so you lose nothing. `mode: "delete"` moves them to the trash instead, and the journal keeps their content for undo. Always pass `where`: without it, every row matches.

**`delete_rows`**

```json
{ "table": "film", "where": { "title": "Battleship Potemkin" } }
```

Answer:

```json
{
  "ok": true,
  "op": "delete_rows",
  "files_moved": 1,
  "planned_moves": [
    {
      "from": "datanotes/main/public/film/Battleship Potemkin.md",
      "to": "datanotes/main/public/film/_archive/Battleship Potemkin.md"
    }
  ],
  "commits": [{ "table": "main.public.film", "version": 12, "operation": "DELETE" }],
  "migration_id": "20261012T165223-delete_rows-archive-1-rows-of-film"
}
```

**`list_folder`**

```json
{ "folder": "datanotes/main/public/film/_archive" }
```

Answer:

```json
{
  "folder": "datanotes/main/public/film/_archive",
  "folders": [],
  "files": ["datanotes/main/public/film/_archive/Battleship Potemkin.md"]
}
```

## Move rows to another table

`move_rows` moves rows to another table. During the move, it can `rename` keys, `set` values and `drop` keys, and it checks every row against the destination first. The engine reports keys the destination doesn’t know and never drops them silently. `relocate: true` also moves the notes to the destination’s folder.

**`create_table`**

```json
{
  "name": "watchlist",
  "description": "Films I plan to watch soon",
  "columns": [
    {
      "name": "title",
      "type": "string",
      "required": true,
      "description": "The film's title"
    },
    { "name": "released", "type": "integer", "description": "Year the film came out" }
  ],
  "filename": "{title}"
}
```

**`move_rows`**

```json
{
  "table": "film",
  "to_table": "watchlist",
  "where": { "status": "to watch" },
  "relocate": true
}
```

Refused:

```text
move_rows refused — nothing was written:
invalid datanotes/main/public/film/Battleship Potemkin.md: year is not a column of table "watchlist"
invalid datanotes/main/public/film/Battleship Potemkin.md: status is not a column of table "watchlist"
invalid datanotes/main/public/film/Charade.md: year is not a column of table "watchlist"
invalid datanotes/main/public/film/Charade.md: status is not a column of table "watchlist"
invalid datanotes/main/public/film/Metropolis.md: year is not a column of table "watchlist"
invalid datanotes/main/public/film/Metropolis.md: status is not a column of table "watchlist"
invalid datanotes/main/public/film/The General.md: year is not a column of table "watchlist"
invalid datanotes/main/public/film/The General.md: status is not a column of table "watchlist"
Hint: Write only the table's columns: describe_table lists them. To keep a new front matter key, add_column first. Then call move_rows again (dry_run: true checks without writing).
Docs: https://datanotes.dev/guides/database-objects/tables.md#describe-a-table-before-writing-to-it (offline: read_docs("guides/database-objects/tables#describe-a-table-before-writing-to-it"))
```

The watchlist has no `status`, `tags` or `rating` column, so the call drops them, and `year` becomes `released`:

**`move_rows`**

```json
{
  "table": "film",
  "to_table": "watchlist",
  "where": { "status": "to watch" },
  "rename": { "year": "released" },
  "drop": ["status", "tags", "rating"],
  "relocate": true
}
```

Answer (51 lines)

```json
{
  "ok": true,
  "op": "move_rows",
  "notes_changed": 4,
  "files_moved": 4,
  "planned_edits": [
    {
      "path": "datanotes/main/public/film/Battleship Potemkin.md",
      "set": { "table": "main.public.watchlist", "released": 1925 },
      "remove": ["year", "status"]
    },
    {
      "path": "datanotes/main/public/film/Charade.md",
      "set": { "table": "main.public.watchlist", "released": 1963 },
      "remove": ["year", "status"]
    },
    {
      "path": "datanotes/main/public/film/Metropolis.md",
      "set": { "table": "main.public.watchlist", "released": 1927 },
      "remove": ["year", "status"]
    },
    {
      "path": "datanotes/main/public/film/The General.md",
      "set": { "table": "main.public.watchlist", "released": 1926 },
      "remove": ["year", "status"]
    }
  ],
  "planned_moves": [
    {
      "from": "datanotes/main/public/film/Battleship Potemkin.md",
      "to": "datanotes/main/public/watchlist/Battleship Potemkin.md"
    },
    {
      "from": "datanotes/main/public/film/Charade.md",
      "to": "datanotes/main/public/watchlist/Charade.md"
    },
    {
      "from": "datanotes/main/public/film/Metropolis.md",
      "to": "datanotes/main/public/watchlist/Metropolis.md"
    },
    {
      "from": "datanotes/main/public/film/The General.md",
      "to": "datanotes/main/public/watchlist/The General.md"
    }
  ],
  "commits": [
    { "table": "main.public.film", "version": 22, "operation": "MOVE ROWS" },
    { "table": "main.public.watchlist", "version": 1, "operation": "MOVE ROWS" }
  ],
  "migration_id": "20261012T165223-move_rows-move-4-rows-from-film-to-watch"
}
```
