Source: https://docs.any.org/database/upsert.html
# Upsert
`POST /v1/spaces/:spaceId/upsert` ingests a batch of records into a [runtime dataset](https://docs.any.org/database/runtime-datasets.md.txt) declared with `idRule: user`. Each record is keyed by the id you supply: absent ids are created, present ids are diffed field by field, and identical records are skipped. Re-running an identical batch is a no-op.
This is the surface for imports, syncs from an external system, and any job that runs on a schedule and must not duplicate what it wrote last time.
## Request
```sh
curl -X POST http://127.0.0.1:7001/v1/spaces/$SPACE/upsert \
-H 'Content-Type: application/json' -d '{
"objectId": "'$OBJ'", "dataset": "'$TYPE'_articles",
"records": [
{ "id": "a1", "fields": { "title": "Hello", "body": "First draft" } },
{ "id": "a2", "fields": { "title": "World", "body": "…" } }
],
"pageSize": 500,
"traceIds": ["import-42"] }'
any upsert $SPACE $OBJ --dataset "${TYPE}_articles" --records @batch.json --page-size 500 --trace-id import-42
```
| Field | Meaning |
| --- | --- |
| `objectId` | The object hosting the dataset — it must carry the dataset's owning type. |
| `dataset` | The runtime dataset's storage collection, `_` — the `collection` its declaration returned. |
| `records[]` | `{id, fields}` — `id` must match the dataset's `idPattern` / `idMaxLen`. |
| `pageSize` | Records per CRDT change, default 500. |
| `traceIds` | Optional trace ids stamped on every page's change. |
## What happens per record
| Stored state | Action |
| --- | --- |
| id absent | Created in one multi-field set. |
| id present, some declared-mutable fields differ | Each changed field lands as a single-path set. Write-once fields are compared but never rewritten. |
| id present, identical | Skipped — no change, no DAG growth. |
| id tombstoned | Rejected `upsert.record_deleted` — ids never reuse. |
One CRDT change is written per page, so a 2 000-record batch at the default page size is four changes, each returned in `pages`.
## Response
The call answers `200` even when some records were rejected — the same partial-success stance as `/modify`:
```json
{ "pages": [ { "versionId": "…", "changeId": "…", "recordIds": [] } ],
"created": 1, "updated": 1, "skipped": 0,
"rejections": [
{ "index": 3, "id": "a4", "code": "upsert.immutable_field", "reason": "…" } ] }
```
| Rejection code | Meaning |
| --- | --- |
| `upsert.immutable_field` | The payload would change a write-once field. |
| `upsert.not_author` | Author-mutable field on a record another identity created. |
| `upsert.record_deleted` | The stored record is a tombstone. |
| `upsert.rejected` | Creation screening failed — missing required field, id pattern or length violation, undeclared field on a non-dynamic dataset, write to a stamped field. `reason` carries the specific cause. |
Whole-call errors: `400 upsert.requires_user_ids` when the dataset is not declared `idRule: user`, `400 dataset.unknown` when the space serves no records storage collection of that name (a module's own, such as `chat_messages`, is never upsertable), and `400 dataset.not_declared` when the object's type does not declare it.
## Concurrency
Upsert is not transactional against concurrent writers. The intended deployment is a single ingest writer per dataset — a scheduled [program](https://docs.any.org/programs/index.md.txt) or one importer process. Concurrent creates of the same id by *different members* are outside the convergence contract; the same member re-running its own batch is exactly the supported case.
> **Why it matters.** A hosted backend gives you idempotency through a server that dedupes on your behalf. Here the dedupe is a local diff against the replica you already hold, so an importer that crashes halfway and restarts, or a cron job that runs twice, converges on the same data without a coordination service — and the changes it skips never cost the network anything.
> **Note.** Only fields declared `mutableBy: author` or `mutableBy: any` are ever updated on an existing record. If your source system changes a field you declared write-once, the upsert reports `upsert.immutable_field` for that record rather than silently ignoring the difference.