Skip to main content

Writing data

This page covers the native write operations: creating, updating, replacing, retyping and deleting records; removing a property everywhere; upserts; bulk deletes by query; and creating, removing and retyping relationships. It also explains which writes are atomic and how to retry writes safely.

All writes are mutate operations sent to POST /protocol/v1/execute, except document ingest (ingest) and multi-step transactions (transaction). Writes need a token with write permission (see Authentication).

Operations at a glance​

mutation.kindWhat it doesResponseAtomicity
putRecord (mode: "create")Create a record, or upsert when options.mergeBy/mergeStrategy is setrecordOne commit
putRecord (mode: "merge")Update the given properties of a record by id; create it if missing and label is givenrecordOne commit
putRecord (mode: "replace")Replace all properties of a record by idrecordOne commit (see Replace)
deleteRecordDelete one record and its relationshipsmutationOne commit
deleteRecordsDelete records matching a querymutationOne commit per 500 records
retypeRecordChange a record's label, keeping id, properties and relationshipsrecordOne commit
deletePropertyRemove a property from every record and relationshipmutationOne commit
linkRecordsByIdCreate relationships between records named by id (cross product)linkedOne commit
linkRecordsPairsCreate relationships for explicit source/target pairslinkedOne commit
linkRecordsCreate relationships between records matched by label, property and predicatelinkedOne commit
unlinkRecordsByIdDelete relationships between records named by idmutationOne commit
unlinkRecordsDelete relationships between records matched by selectorsmutationOne commit
retypeRelationsRename the type of relationships between two labelsmutationOne commit
statementA write in SQL, GQL, Cypher, Gremlin or MongoDB syntaxstatementMutationOne commit per statement

The mutation response is { "kind": "mutation", "affected": <n> }, where affected is the number of records, relationships, or property occurrences the operation changed. The link operations answer { "kind": "linked", "created": <n>, "skippedExisting": <m> }: relationships created, and requested pairs that were already linked by the same type.

Writes are rejected with unavailable while the server is in maintenance mode, on a read-only replica, or when free disk space is below the configured threshold (HTTP 503), on every transport.

Create a record​

curl -s http://127.0.0.1:3000/protocol/v1/execute \
-H "Authorization: Bearer $RUSHDB_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"protocolVersion": 1,
"operation": {
"kind": "mutate",
"mutation": {
"kind": "putRecord",
"mode": "create",
"label": "Person",
"properties": { "name": "Ada", "born": "1815-12-10", "tags": ["math", "poetry"] },
"options": {}
}
}
}'
{
"protocolVersion": 1,
"response": {
"kind": "record",
"record": {
"id": "0192f1d2-6c1e-7a40-9d1b-0d6f2c1e4a77",
"label": "Person",
"properties": { "name": "Ada", "born": "1815-12-10", "tags": ["math", "poetry"] },
"propertyTypes": { "name": "string", "born": "datetime", "tags": "string" }
}
}
}
FieldRequiredMeaning
modeyescreate, merge, or replace.
labelfor createThe record's label.
idnoAn explicit id (dashed UUID or 32 hex digits). A malformed id is invalid_request; an id that already exists is conflict. Without it, RushDB assigns a time-ordered id.
propertiesyesA JSON object of property values. Nested objects are not properties; use nested import to turn them into related records.
vectorsnoInline vectors to store with the record, see Vector search.
optionsnoWrite options, below.

Write options​

OptionDefaultEffect
suggestTypestrueInfer types from string values: ISO dates and timestamps become datetimes (see Datetimes).
convertNumericValuesToNumbersfalseWith suggestTypes, numeric strings such as "42" become numbers.
skipEmptyValuesfalseDrop empty strings, and arrays that contain only empty strings.
mergeByabsentUpsert: the properties that identify an existing record. See Upserts.
mergeStrategyabsentUpsert strategy: append or rewrite.
mergefalseShorthand for mergeBy: [] with mergeStrategy: "append".

Known options with the wrong JSON type are rejected (for example a string suggestTypes). Arrays whose elements are not all of the same type are stored as arrays of strings. An empty array is stored as [], and null is stored as an explicit null (see Missing values and null).

Upserts​

Set options.mergeBy on a create to update an existing record of the same label instead of creating a duplicate. A record matches when it has the same values for every mergeBy property:

{
"kind": "putRecord",
"mode": "create",
"label": "Customer",
"properties": { "email": "ada@example.com", "plan": "pro" },
"options": { "mergeBy": ["email"], "mergeStrategy": "append" }
}
mergeBymergeStrategyBehavior
absentabsentAlways create.
["email"]append or absentMatch on email; update the given properties and keep the others. Create when nothing matches.
["email"]rewriteMatch on email; replace all properties of the matched record with the given ones.
[] (or merge: true)appendMatch on all given properties.
absentappend / rewriteMatch on all given properties.

The same options apply per record to document ingest, which is the recommended path for bulk upserts.

Merge a record by id​

mode: "merge" updates only the properties you send and leaves the others unchanged. Sending null stores an explicit null; it does not remove the property.

{
"kind": "putRecord",
"mode": "merge",
"id": "0192f1d2-6c1e-7a40-9d1b-0d6f2c1e4a77",
"label": "Person",
"properties": { "city": "London" }
}

If no record has that id, merge creates one with that id under label. Without label, a missing record is an error. This makes merge with an explicit id an idempotent create-or-update.

Replace a record​

mode: "replace" removes every property of the record and writes the given set. The label and relationships are kept; id is required.

{
"kind": "putRecord",
"mode": "replace",
"id": "0192f1d2-6c1e-7a40-9d1b-0d6f2c1e4a77",
"properties": { "name": "Ada Lovelace" }
}

Replace writes the new properties and removes every other property in one commit: a reader sees the old record or the new one, and a refused replace (for example one over max-expanded-commit-bytes) leaves the record as it was. Inline vectors are bound in a second commit after it; if that binding fails, the properties are already replaced and the request can be retried.

Delete a record​

{ "kind": "deleteRecord", "id": "0192f1d2-6c1e-7a40-9d1b-0d6f2c1e4a77" }

The record's relationships are deleted with it, in the same commit. A missing id returns not_found.

Delete records by query​

deleteRecords takes a search query and deletes its matches:

curl -s http://127.0.0.1:3000/protocol/v1/execute \
-H "Authorization: Bearer $RUSHDB_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"protocolVersion": 1,
"operation": {
"kind": "mutate",
"mutation": {
"kind": "deleteRecords",
"query": { "labels": ["Session"], "predicate": { "expiresAt": { "$lt": "2026-01-01T00:00:00Z" } } }
}
}
}'
{ "kind": "mutation", "affected": 1832 }
  • offset matches are left in place, and limit caps how many records are deleted; without limit, every match is deleted. With order, the selection follows that order, so { "order": { "createdAt": "asc" }, "limit": 100 } deletes the 100 oldest.
  • The match set is determined once, at the start. Records inserted afterwards are not deleted; records that disappear before their turn are skipped.
  • Records are deleted in pages of 500, each page one atomic commit. The operation as a whole is not one transaction: a concurrent reader can see a partially completed delete, and a cancelled or timed-out request leaves the already committed pages deleted.

Warning: { "predicate": {} } with no labels matches every record of the tenant. To erase a whole tenant, use purgeTenant (see Tenants).

Retype a record​

retypeRecord changes a record's label atomically. The id, properties, vectors and relationships are kept. fromLabel must equal the current label; otherwise the request fails with conflict, which protects against overwriting a concurrent reclassification.

{ "kind": "retypeRecord", "id": "0192f1d2-6c1e-7a40-9d1b-0d6f2c1e4a77", "fromLabel": "Lead", "toLabel": "Customer" }

Delete a property everywhere​

deleteProperty removes a property from every record and relationship of the tenant, together with its schema entry, in one commit. name is matched after normalization. affected is the number of values removed.

{ "kind": "deleteProperty", "name": "legacyScore" }

Relationships​

Relationships are typed and directed. The operations below take a direction: out (default) creates source → target; in creates target → source. Linking operations never create a second relationship of the same type between the same two records in the same direction; existing ones are skipped and not counted in affected.

Linking by id​

linkRecordsById links every source to every target:

{
"kind": "linkRecordsById",
"command": {
"relationType": "EMPLOYS",
"direction": "out",
"sourceIds": ["0192f1d2-6c1e-7a40-9d1b-0d6f2c1e4a77"],
"targetIds": ["0192f1d2-6c20-7c02-a1b3-77e1f0d2c3b4", "0192f1d2-6c21-7e4d-b5c6-d7e8f9a0b1c2"],
"properties": { "since": 2021 }
}
}

linkRecordsPairs creates one relationship per explicit pair, and a pair may override the relationship type:

{
"kind": "linkRecordsPairs",
"command": {
"relationType": "KNOWS",
"pairs": [
{ "sourceId": "0192f1d2-6c1e-7a40-9d1b-0d6f2c1e4a77", "targetId": "0192f1d2-6c20-7c02-a1b3-77e1f0d2c3b4" },
{ "sourceId": "0192f1d2-6c1e-7a40-9d1b-0d6f2c1e4a77", "targetId": "0192f1d2-6c21-7e4d-b5c6-d7e8f9a0b1c2", "relationType": "MENTORS" }
]
}
}

Both operations resolve every id first: if any id does not exist, the call fails with not_found and nothing is written. All new relationships are then committed in one atomic commit. For large batches, send chunks of about 500 ids or pairs per call.

Linking by matching properties​

linkRecords selects the source and target records with a label, an optional predicate and an optional join property:

{
"kind": "linkRecords",
"command": {
"relationType": "PLACED_BY",
"source": { "label": "Order", "property": "customerEmail", "predicate": {} },
"target": { "label": "Customer", "property": "email", "predicate": {} },
"direction": "out",
"properties": {}
}
}
FieldMeaning
source, targetlabel (required), property (join key), predicate (Filtering syntax).
property on both sidesA source and a target are linked when any value of the source property equals any value of the target property (type-strict; arrays compared element by element). Property names are matched exactly as stored.
allowCartesian: true without join propertiesLink every selected source to every selected target. Both predicates must then be non-empty, and a record is never linked to itself.
propertiesProperties stored on each new relationship.

Without join properties and without allowCartesian, the request is rejected. Every new relationship is written in one commit, so the call creates all of them or none; a call whose relationships together exceed max-expanded-commit-bytes is refused whole (narrow the selectors and repeat it; existing relationships are skipped). Use joinPairs to count the pairs before linking.

Unlinking​

unlinkRecords takes the same shape as linkRecords and deletes relationships of relationType between each selected source and target, in the same direction as linking: out (default) deletes source → target edges, in deletes target → source edges.

unlinkRecordsById names the endpoints by id. Its relationTypes lists the types to delete (omitted: every type), and its direction is out or in (omitted: both directions); see Remove relationships.

Every deletion is in one commit: all of them happen, or none.

Retype relationships​

retypeRelations renames the type of every relationship whose endpoints are a sourceLabel record and a targetLabel record:

{
"kind": "retypeRelations",
"command": { "sourceLabel": "Order", "targetLabel": "Customer", "sourceType": "__RUSHDB__RELATION__DEFAULT__", "targetType": "PLACED_BY", "direction": "out" }
}

sourceType defaults to __RUSHDB__RELATION__DEFAULT__, the type nested import uses when no relationship type is configured. Each matching relationship is re-created with the new type, endpoints and properties preserved, and the old one is removed. affected is the number of relationships retyped.

The whole retype is one commit: a reader sees every relationship under its old type or every one under its new type, and a refused retype changes nothing.

Bulk import​

For loading many records, and nested JSON documents that become records linked by relationships, use ingest:

{
"protocolVersion": 1,
"operation": {
"kind": "ingest",
"ingest": { "kind": "documents", "label": "Person", "documents": [{ "name": "Ada" }, { "name": "Alan" }], "options": {} }
}
}

The response is { "kind": "ingested", "count": …, "relationships": …, "records": […] }; records is filled only when options.returnResult is true. Document shapes, relationship-type options, upserts, and chunk sizing are described in Nested import.

CSV import​

ingest.csv imports CSV text: every data row becomes one record of label, and the rows then go through the same path as document ingest, so the same options apply.

{
"protocolVersion": 1,
"operation": {
"kind": "ingest",
"ingest": {
"kind": "csv",
"label": "Book",
"data": "title,pages\nDune,412\n",
"parseConfig": { "dynamicTyping": true },
"options": {}
}
}
}

The response is the same ingested response. parseConfig is optional; every field has a default:

FieldDefaultMeaning
delimiter","Field separator; any non-empty string without a line break or the quote character.
headertrueThe first row names the fields. Without it, fields are named column_1, column_2, ...
skipEmptyLinestruetrue skips lines with no characters, "greedy" also lines of whitespace-only fields, false keeps every line.
dynamicTypingoptions.suggestTypesTurn true/TRUE/false/FALSE, numbers below 2^53 in magnitude, and ISO datetimes with a time zone into typed values; an empty cell is then left out.
quoteChar"One character opening and closing a quoted field.
escapeCharthe quote characterOne character that, before the quote character, makes it literal.
newlinedetected\n, \r or \r\n; any other value is ignored and the line ending is detected.

Header names become property names: null bytes are removed, the name is trimmed, whitespace runs become _, a name that does not start with an ASCII letter or _ gets a _ prefix, a blank header cell's column is not imported, and a repeated name gets a suffix (a, a_1, ...). A row whose field count differs from the header's, or a malformed quoted field, fails the whole import with invalidRequest and a CSV parse error: ... message; nothing is written.

vectors (optional) carries inline vectors per row: entry i becomes data row i's $vectors, as in a nested import. It may not be longer than the number of data rows. A header with no data rows imports nothing (count: 0).

Decoding reproduces Papa Parse 5.4 followed by the header clean-up above, which is what CSV imports meant in RushDB 2.x, before CSV moved into the engine. The differences: quoteChar and escapeChar must be one character, and an unusable delimiter is rejected rather than guessed.

Writes in other languages​

SQL INSERT/UPDATE/DELETE, Cypher and GQL CREATE/MERGE/SET/DELETE, Gremlin mutation steps, and MongoDB insert/update/delete commands are sent as mutate.statement; each statement commits atomically and the response reports affected and the commitIndex it landed at. See Query languages.

Commit order and retries​

Every successful write is assigned a position in the tenant's commit sequence, and writes to a tenant are applied one at a time in that order. Transactions and statement writes report this position as commitIndex. Because commits are serialized per tenant, throughput comes from putting more work in each call (ingest batches, linkRecordsById chunks), not from many concurrent single-record writers.

The native mutate operations do not carry a client-supplied idempotency key, so a blind retry of a write whose response was lost can apply it twice. Make retries safe by construction:

GoalSafe pattern
Create exactly oncecreate with an explicit id: a retry after success returns conflict, which you can treat as success. Or merge with an explicit id and label.
Upsert by a business keycreate with options.mergeBy.
Link exactly oncelinkRecordsById / linkRecordsPairs: existing relationships are skipped.
Several writes as one unit, with retry discoveryA transaction: committing again with the same transaction id returns the recorded outcome instead of executing twice.

Retry unavailable and resource_exhausted errors (HTTP 503 and 429) with jittered backoff. A response that is too large to send after a write has committed is never reported as retryable: a single created record falls back to { "kind": "mutation", "affected": 1 }, and other committed writes fail with a non-retryable resource_exhausted.

Errors​

ErrorTypical cause
invalid_requestMalformed envelope, missing label on create, missing id on merge/replace, malformed id.
not_foundThe record or an endpoint id does not exist.
conflictCreate with an id that exists; retypeRecord with a stale fromLabel.
unauthorizedThe token cannot write, or a data-access policy denies the label.
resource_exhaustedThe write exceeds a server limit (request body, commit size) or the server is shedding load.
unavailableMaintenance mode, read-only replica, low disk space, or capacity limits; retry later or against the leader.

See Error codes for the full taxonomy.

See also​