Skip to main content
Version: 0.3.36

DELETE Statement

DELETE removes nodes from a workspace. Deleting a node also removes everything under it.

Syntax

DELETE FROM 'workspace'
WHERE condition
[ RETURNING expression [ AS alias ] [, ...] ]

The table name is the workspace. A WHERE clause is required; DELETE FROM 'blog' on its own is rejected with DELETE requires a WHERE clause. There is no PURGE or USING.

Delete one node

DELETE FROM 'blog' WHERE path = '/t2';
{"columns":["affected_rows"],"rows":[{"affected_rows":1}],"row_count":1,"execution_time_ms":2}

By id:

DELETE FROM 'blog' WHERE id = '11111111-2222-4333-8444-555555555555';

Deleting a node that does not exist is not an error; affected_rows is 0.

Subtrees

A node's descendants go with it. Deleting the /news folder that holds four pages removes five nodes:

DELETE FROM 'blog' WHERE path = '/news';
{"columns":["affected_rows"],"rows":[{"affected_rows":5}],"row_count":1,"execution_time_ms":8}

To remove only the children and keep the folder, select them with a hierarchy predicate (this runs as a job, see below):

DELETE FROM 'blog' WHERE CHILD_OF('/news');

How the WHERE clause is executed

WHERE id = '...' and WHERE path = '...' (a literal or a bound parameter) are point deletes and complete in the request. EXPLAIN confirms it:

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

Any other predicate is planned as a bulk operation and queued as a job. The statement returns at once with the job id:

DELETE FROM 'blog' WHERE PATH_STARTS_WITH(path, '/t');
{"columns":["job_id","status","message"],"rows":[{"job_id":"QMrZF1vwojmJlqWdkXVDj","status":"accepted","message":"Bulk operation started. Poll /api/jobs/{job_id} for status."}],"row_count":1,"execution_time_ms":0}

Poll GET /management/jobs/{job_id} until data is Completed. A bulk delete whose predicate matches nothing is still accepted as a job and completes with no changes.

Predicates that work in the bulk path are the ones SELECT accepts: property comparisons, LIKE, IN (...), DESCENDANT_OF, CHILD_OF, PATH_STARTS_WITH, DEPTH(path), node_type = ..., timestamps compared against a cast literal, and AND / OR combinations of them. A subquery in WHERE id = (SELECT ...) is not accepted. Run the same predicate as a SELECT first when you want to see what a bulk delete will remove.

Examples

-- Drafts older than a date (bulk job)
DELETE FROM 'blog'
WHERE properties->>'status' = 'draft'
AND created_at < '2025-01-01T00:00:00Z'::TIMESTAMPTZ;

-- Everything flagged temporary (bulk job)
DELETE FROM 'blog' WHERE properties @> '{"temporary": true}';

-- Deep nodes of one type (bulk job)
DELETE FROM 'blog' WHERE DEPTH(path) > 3 AND node_type = 'raisin:Asset';

-- Bound parameter (fast path)
-- {"sql": "DELETE FROM 'blog' WHERE path = $1", "params": ["/news/old"]}

Targeting a branch

WHERE __branch = 'staging' AND path = '/draft' deletes on another branch; the __branch predicate selects the branch and is not part of the filter.

After a delete

A deleted node no longer resolves by path or id, and RESTORE NODE path='...' TO REVISION HEAD~1 answers not found for it: RESTORE rewinds a live node to an earlier revision, it does not undelete. To keep a safety net, do the deletion on a branch and merge it, or copy the subtree first with COPY.

RETURNING

RETURNING reports the rows that were removed instead of a count, which is the way to record what a delete took with it:

DELETE FROM 'blog' WHERE path = '/draft' RETURNING path, name;
{"columns":["path","name"],"rows":[{"path":"/draft","name":"draft"}],"row_count":1,"execution_time_ms":2}

Aggregates are rejected (aggregate functions are not allowed in RETURNING).