Skip to main content
Version: 0.3.36

UPDATE Statement

UPDATE changes existing nodes in a workspace.

Syntax

UPDATE 'workspace'
SET column = expression [, ...]
WHERE condition
[ RETURNING expression [ AS alias ] [, ...] ]

The table name is the workspace. A WHERE clause is required; UPDATE 'blog' SET ... without one is rejected with UPDATE requires a WHERE clause. The result is one row with affected_rows, unless RETURNING is given.

Updating properties

All user data lives in the properties JSONB column, so most updates assign to it. Three patterns cover nearly every case.

Merge fields

properties || '{...}' keeps every existing field and adds or overwrites the ones you name:

UPDATE 'blog'
SET properties = properties || '{"views": 12, "reviewed": true}'
WHERE path = '/hello';
{"columns":["affected_rows"],"rows":[{"affected_rows":1}],"row_count":1,"execution_time_ms":4}

Set one field, including nested ones

JSONB_SET(properties, '{key,subkey}', value) writes a single path and creates intermediate objects as needed. The value is JSON text: quote a string as '"text"', a number as '11'.

UPDATE 'blog' SET properties = JSONB_SET(properties, '{views}', '11') WHERE path = '/hello';
UPDATE 'blog' SET properties = JSONB_SET(properties, '{seo,title}', '"Hello again"') WHERE path = '/hello';

Remove a field

UPDATE 'blog' SET properties = properties - 'reviewed' WHERE path = '/hello';

Replace everything

Assigning a literal replaces the whole object. The new value must still satisfy the NodeType's required properties.

UPDATE 'blog'
SET properties = '{"title": "Hello", "views": 0}'::jsonb
WHERE path = '/hello';

A JSON literal on its own is TEXT; cast it with ::jsonb. The || and JSONB_SET forms take the literal without a cast because their signatures already expect JSON.

Updating other columns

ColumnEffect
nameRenames the node.
pathChanges this node's own path. Its children keep their old paths, so use MOVE to relocate a subtree.
archetypeAssigns an archetype; the name must exist, otherwise Archetype not found.
node_typeRejected: Cannot change node_type after creation.
id, created_at, created_by, versionServer-managed; not assignable.
UPDATE 'blog' SET name = 'hello-world' WHERE path = '/hello';
UPDATE 'blog' SET path = '/hello-world' WHERE path = '/hello';

Each update stamps updated_at and updated_by.

How the WHERE clause is executed

The shape of the WHERE clause decides whether the statement runs inline or as a job.

Fast path. WHERE id = '...' or WHERE path = '...' with a literal (or a bound parameter) is a single point write and completes in the request:

UPDATE 'blog' SET properties = properties || '{"views": 8}'
WHERE id = 'b4363972-fe50-4fe7-8574-c29dd3fc6dfa';

If no node matches, the statement fails with Node at path '/x' not found or Node with id '...' not found.

Bulk path. Any other predicate (a property comparison, DESCENDANT_OF, CHILD_OF, LIKE, a combination with AND) is planned as a bulk operation and handed to the job queue. The response comes back immediately with a job id:

UPDATE 'blog'
SET properties = properties || '{"seen": true}'
WHERE properties->>'published' = 'true';
{"columns":["job_id","status","message"],"rows":[{"job_id":"ZvzWqGaT9NJuiaM5MbbEX","status":"accepted","message":"Bulk operation started. Poll /api/jobs/{job_id} for status."}],"row_count":1,"execution_time_ms":0}

Poll the job with GET /management/jobs/{job_id}:

{"success":true,"data":"Completed","error":null}

EXPLAIN UPDATE ... shows which path a statement will take without running it:

=== UPDATE Plan ===
Target workspace: blog
Strategy: fast path
PathIndexLookup: path='/x' (O(1) point write)

Bulk updates run in the background, so a SELECT issued straight after may still see the old values. Combine a point predicate with a property check only when you can accept the job round-trip: WHERE path = '/news/first' AND properties->>'published'::String = 'false' is a bulk operation, not a fast one.

Examples

-- Publish one page
UPDATE 'blog'
SET properties = properties || '{"published": true, "published_on": "2026-09-06"}'
WHERE path = '/news/first';

-- Tag every page under a folder (bulk job)
UPDATE 'blog'
SET properties = properties || '{"section": "news"}'
WHERE DESCENDANT_OF('/news');

-- Bound parameters
-- {"sql": "UPDATE 'blog' SET properties = properties || $1 WHERE path = $2",
-- "params": [{"views": 100}, "/hello"]}

Targeting a branch

Add __branch = '...' to the WHERE clause to update a node on another branch; the predicate is removed from the filter and selects the branch. Inside BEGIN ... COMMIT the branch is fixed at BEGIN.

UPDATE 'blog' SET properties = properties || '{"title": "Draft 2"}'
WHERE __branch = 'staging' AND path = '/draft';

RETURNING

RETURNING reports the rows the statement wrote instead of a count, one row per updated node:

UPDATE 'blog'
SET properties = properties || '{"views": 5}'::jsonb
WHERE path = '/draft'
RETURNING path, properties->>'views' AS views;
{"columns":["path","views"],"rows":[{"path":"/draft","views":"5"}],"row_count":1,"execution_time_ms":1}

The list is projected from the node as the statement leaves it, before the server stamps its own columns, so updated_at, updated_by and __revision read as NULL here. Select the node afterwards if you need them. Aggregates are rejected (aggregate functions are not allowed in RETURNING).