SQL Script
SQL Script (sqlscript) is ArcadeDB’s server-side scripting engine.
A script is a sequence of SQL statements — including the regular DML/DDL commands plus a small set of script-only constructs (transactions, control flow, variables, locks) — that the server parses and runs as a single unit.
Why use SQL Script
Single-statement SQL is fine for ad-hoc queries, but most real workloads (especially graph workloads) need to do several things together: look up a vertex, create another, connect them with an edge, then return the result. Doing that as separate client calls has two problems: each call pays a network round-trip, and there is no transaction across them.
SQL Script solves both:
-
One round-trip instead of many. The whole script is sent to the server once. A graph mutation that takes 5 commands no longer pays 5x the latency — the server runs them locally and sends back one response.
-
Implicit transaction. A script is automatically wrapped in a transaction (unless you open one yourself with
BEGIN), so it is all-or-nothing: any failure rolls back the entire script. -
Automatic retry on conflict. Wrap the body in
BEGIN … COMMIT RETRY <n>and ArcadeDB will retry the transaction onNeedRetryException(typically a concurrent-modification or transient lock-timeout). This is what lets you run the same script in parallel from many clients without writing your own retry loop — the server resolves the contention. -
Server-side variables and control flow.
LET,IF,WHILE,FOREACH,RETURN, etc. let you express small pieces of imperative logic without leaving SQL.
A typical use case is a graph upsert: find or create a vertex, then attach an edge to it, possibly conditionally. With SQL Script that is one network call and one transaction; the server handles concurrency.
|
A script is a transaction. The atomicity guarantee — "all-or-nothing" — applies to every script: if any statement fails (and is not caught by a retry/else clause), every change made by the script is rolled back. |
Differences between sql and sqlscript
| Feature | sql (single command) |
sqlscript (batch) |
|---|---|---|
Number of statements |
Exactly one |
One or more, separated by |
Transaction |
Not transactional by itself |
Implicit transaction (or explicit |
Variables |
Only inside |
|
Control flow |
None |
|
Explicit transaction control |
Not allowed |
|
Retry on conflict |
No |
|
Locks |
No |
|
To pick the engine, pass "sqlscript" as the language parameter (Java driver, HTTP language field, console LANGUAGE setting, etc.).
Syntax reference
A SQL Script supports every SQL command plus the following script-only statements.
| Statement | Purpose |
|---|---|
Open an explicit transaction ( |
|
Commit the transaction; optionally retry on conflict and run a fallback block. |
|
|
Abort the current transaction. |
Take an explicit type-level or bucket-level lock for the rest of the transaction. |
|
Bind the result of a statement or expression to a script variable. Reference it as |
|
Set a database-scoped variable that survives across scripts and queries until the database is closed. |
|
Conditional execution. |
|
Loop over the items of a collection or query result. |
|
Loop while a boolean condition holds. |
|
Exit the innermost |
|
Stop the script and return a value (literal, variable, query, array or map). |
|
Pause the script for the given number of milliseconds. |
|
Write a message to the server log or stdout/stderr (server-side only). |
|
Statements are separated by |
BEGIN — start an explicit transaction
BEGIN [ISOLATION <READ_COMMITTED | REPEATABLE_READ>]
By default a script runs inside an implicit READ_COMMITTED transaction. Use BEGIN only when you want to set a different isolation level, or to mark the start of a BEGIN … COMMIT RETRY block.
-
READ_COMMITTED(default): eachSELECTsees the latest committed data. -
REPEATABLE_READ: subsequent reads of the same record return the same snapshot for the lifetime of the transaction.
BEGIN ISOLATION REPEATABLE_READ;
LET v1 = SELECT FROM Account WHERE id = 1;
-- ... other work ...
LET v2 = SELECT FROM Account WHERE id = 1; -- guaranteed to match v1
COMMIT;
COMMIT — commit, retry, fallback
COMMIT [RETRY <n> [ELSE { <statements> } [AND] (FAIL | CONTINUE)]]
-
Without arguments,
COMMITsimply commits the current transaction. -
RETRY <n>makes the server re-run the wholeBEGIN … COMMITblock on a retryable failure, up tontimes. Retryable failures includeNeedRetryException(concurrent modification, MVCC conflicts) and transient transaction-lock timeouts. Between attempts the server waits a random delay capped byarcadedb.txRetryDelay(default 100 ms). -
ELSE { … }is the fallback block executed when retries are exhausted. -
AND FAIL(the default whenELSEis omitted) re-throws the original exception after the fallback runs. -
AND CONTINUEswallows the exception and lets the script keep going.
This is the building block that makes server-side scripts safe to run from many concurrent clients: each conflict is resolved on the server, and the client only sees the final result.
Examples:
-- retry up to 100 times, fail if still conflicting
BEGIN;
LET account = CREATE VERTEX Account SET name = 'Luke';
LET city = SELECT FROM City WHERE name = 'London';
LET e = CREATE EDGE Lives FROM $account TO $city;
COMMIT RETRY 100;
RETURN $e;
-- retry, otherwise log to a dead-letter type and continue
BEGIN;
UPDATE Account SET balance = balance - 100 WHERE id = 1;
UPDATE Account SET balance = balance + 100 WHERE id = 2;
COMMIT RETRY 10 ELSE {
INSERT INTO TransferDLQ SET fromId = 1, toId = 2, amount = 100, ts = sysdate();
} AND CONTINUE;
LOCK — explicit type or bucket lock
LOCK TYPE <type-name> [, <type-name>]*
LOCK BUCKET <bucket-name>[, <bucket-name>]*
LOCK takes an exclusive lock on one or more types or buckets for the rest of the current transaction. The lock is released automatically on COMMIT or ROLLBACK.
Use it when an MVCC retry would not be enough — typically for a "read-modify-write" sequence that must see a consistent snapshot of an entire type, or for serializing work on a hot bucket.
BEGIN;
LOCK TYPE Account; -- nobody else can modify Account until we commit
LET total = SELECT sum(balance) FROM Account;
INSERT INTO Audit SET kind = 'snapshot', total = $total[0].sum, ts = sysdate();
COMMIT;
BEGIN;
LOCK BUCKET orders_2026_q2; -- serialize writes on a hot bucket
INSERT INTO Order BUCKET orders_2026_q2 SET ...;
COMMIT;
LET — script variables
LET <var> = <expression>
LET <var> = <SQL statement>
LET <var1> = <expr1>, <var2> = <expr2>, ...
LET evaluates the right-hand side and binds the result to a variable. Reference it later with $var. The right-hand side can be:
-
an expression (literal, arithmetic, function call):
LET total = 0; -
a SQL command:
LET city = SELECT FROM City WHERE name = 'London'; -
multiple bindings in one statement, separated by commas.
A LET whose value is a SELECT acts as an ephemeral view — the result is materialized into a list and can be referenced repeatedly without re-running the query.
LET variables have dynamic scope: they are visible to every statement that runs after them in the same script (and inside nested blocks).
LET hubs = SELECT FROM City WHERE category = 'hub';
LET count = $hubs.size();
INSERT INTO Stat SET name = 'hub-count', value = $count;
RETURN $count;
Dynamic typing. Type names in SELECT, INSERT, CREATE, etc. can come from a variable — but only when the statement is itself the right-hand side of a LET:
LET mytype = 'vec';
LET r = SELECT FROM $mytype WHERE num > 0;
RETURN $r;
LET myedge = 'Lives';
LET r = CREATE EDGE $myedge FROM #1:0 TO #1:1;
RETURN $r;
SET GLOBAL — database-scoped variables
SET GLOBAL <var> = <BOOLEAN | STRING | NUMBER | LIST | MAP>
SET GLOBAL stores a transient variable on the database itself. It is visible to every subsequent SQL or Cypher query running against that database, until the database is closed or the variable is set to NULL. Use it for small bits of session-like state (a feature flag, a tenant id, a timestamp).
SET GLOBAL tenant = 'acme';
-- later, in any query on this database:
SELECT FROM Account WHERE tenantId = $tenant;
RETURN — end the script with a value
RETURN [<expression>]
RETURN ends script execution immediately. The argument can be:
-
a literal:
RETURN 3; -
a variable:
RETURN $a; -
a query:
RETURN (SELECT FROM Foo); -
an array (HTTP only):
RETURN [ $a, $b ]; -
a map (HTTP only):
RETURN { 'first': $a, 'second': $b }; -
nothing — exits the script with an empty result.
|
To return arrays or maps in a way that works on every protocol and driver (Java, Node.js, etc.), wrap the result in a
To return an empty result use |
IF — conditional execution
IF (<sql-predicate>) {
<statement>;
<statement>;
...
} [ELSE {
<statement>;
...
}]
<sql-predicate> is any boolean expression that would be valid in a WHERE clause.
LET existing = SELECT FROM User WHERE email = '[email protected]';
IF ($existing.size() = 0) {
INSERT INTO User SET email = '[email protected]', createdAt = sysdate();
} ELSE {
UPDATE User SET lastSeen = sysdate() WHERE email = '[email protected]';
}
IF ($a.size() > 0) {
ROLLBACK;
}
FOREACH — iterate a collection
FOREACH (<variable> IN <expression>) {
<statement>;
<statement>;
...
}
The expression must evaluate to a collection: a literal array, a variable, or the result of a sub-query.
FOREACH ($i IN [1, 2, 3]) {
INSERT INTO Foo SET value = $i;
}
-- bulk-create edges from a query result
LET people = SELECT FROM Person WHERE city = 'London';
FOREACH ($p IN $people) {
CREATE EDGE LivesIn FROM $p TO (SELECT FROM City WHERE name = 'London');
}
WHILE — loop on a condition
WHILE (<condition>) {
<statement>;
<statement>;
...
}
LET $i = 0;
WHILE ($i < 10) {
INSERT INTO Foo SET value = $i;
LET $i = $i + 1;
}
BREAK — exit the current loop
BREAK exits the innermost FOREACH or WHILE.
FOREACH ($i IN [1, 2, 3, 4, 5]) {
IF ($i > 2) {
BREAK;
}
CONSOLE.log $i;
}
-- sum positive amounts until you hit a negative one
LET total = 0;
FOREACH ($record IN (SELECT FROM CustomerAccount)) {
IF ($record.amount < 0) {
BREAK;
}
LET total = $total + $record.amount;
}
RETURN $total;
SLEEP — pause execution
SLEEP <milliseconds>
Pauses the script for the given number of milliseconds. Mostly useful for tests, throttling, or staggering retries.
FOREACH ($url IN $urls) {
INSERT INTO FetchJob SET url = $url, status = 'queued';
SLEEP 50; -- be polite to a downstream system
}
CONSOLE — log from a script
CONSOLE.log <expression>
CONSOLE.output <expression>
CONSOLE.error <expression>
CONSOLE.warn <expression>
CONSOLE.debug <expression>
Writes a message from the server side. log/warn/debug go to the server log at the matching level, output to stdout, error to stderr (and the log at SEVERE level). Useful for debugging long-running or scheduled scripts.
LET count = SELECT count(*) AS c FROM Order WHERE status = 'pending';
IF ($count[0].c > 1000) {
CONSOLE.warn 'Pending order backlog is ' + $count[0].c;
}
End-to-end example: a graph upsert with retry
This is the canonical use case for SQL Script: a single script that upserts two vertices (insert if missing, update if present), idempotently connects them with an edge, retries on conflict, and returns the result. Many clients can run this in parallel against the same server — each conflict triggers a server-side retry, no client-side bookkeeping required.
The script uses the UPDATE … UPSERT clause for the vertices. UPSERT is atomic only when the WHERE condition matches a UNIQUE index, so the schema needs those indexes first (one-time setup):
CREATE VERTEX TYPE City IF NOT EXISTS;
CREATE VERTEX TYPE Account IF NOT EXISTS;
CREATE EDGE TYPE Lives IF NOT EXISTS;
CREATE INDEX IF NOT EXISTS ON City (name) UNIQUE;
CREATE INDEX IF NOT EXISTS ON Account (email) UNIQUE;
The upsert script itself:
BEGIN;
-- 1. Upsert the city. UNIQUE index on City.name makes this atomic.
-- `RETURN AFTER` returns the upserted row directly, so we capture
-- the vertex (and its RID) in $city without a second roundtrip.
LET city = UPDATE City SET name = 'London', country = 'UK'
UPSERT RETURN AFTER WHERE name = 'London';
-- 2. Upsert the person. UNIQUE index on Account.email makes this atomic.
LET account = UPDATE Account SET name = 'Luke', email = '[email protected]',
updatedAt = sysdate()
UPSERT RETURN AFTER WHERE email = '[email protected]';
-- 3. Idempotently connect them. CREATE EDGE has no UPSERT clause,
-- so check for an existing edge first.
LET existing = SELECT FROM Lives
WHERE @out = $account[0] AND @in = $city[0];
IF ($existing.size() = 0) {
CREATE EDGE Lives FROM $account TO $city SET since = sysdate();
}
COMMIT RETRY 100;
RETURN (SELECT FROM Lives
WHERE @out = $account[0] AND @in = $city[0]);
What makes this a real upsert:
-
Vertices —
UPDATE … UPSERT WHEREeither updates the existing row matched by the unique key or inserts a new one. TheRETURN AFTERclause makes the statement emit the upserted row (with@rid) regardless of which branch was taken, so we capture it into aLETvariable in one statement instead of issuing a separateSELECTlookup. Run the script twice with the sameemail/nameand you still end up with exactly oneAccountand oneCity. -
Edge —
CREATE EDGEdoes not have its ownUPSERTclause, so the script does the equivalent by hand: aSELECTagainst the edge’s@out/@inendpoints, thenCREATE EDGEonly when the lookup is empty. Re-running the script does not produce duplicate edges. -
Concurrency — the whole
BEGIN … COMMIT RETRY 100block is the unit that gets retried. If two clients run this script at the same time and one of them hits aNeedRetryException(typically from the unique-index conflict), the server transparently rolls back and re-executes the block up to 100 times.
The result of the final SELECT (the existing or newly-created Lives edge) is what the client receives.
Running scripts
You can run a SQL script from any of the supported entry points:
-
Java driver:
database.command("sqlscript", "BEGIN; … COMMIT;"). -
HTTP API:
POST /api/v1/command/<db>with{"language": "sqlscript", "command": "…"}. -
Console: switch the language with
set language sqlscript;then paste the script. -
Studio: pick "SQL Script" in the language selector before running.
In an HA cluster, scripts are routed to the leader so that the implicit transaction can be applied with quorum.