SQL reference
OriginChainDB runs SQL against the same instance that holds your vectors, full-text indexes, and graph relationships. The panel below separates what runs today from what does not, and every shape has a copy-pasteable example in cURL, Python, TypeScript and Go.
All examples below assume you have a client set up. If you haven't yet, see Quickstart.
At a glance.
- SELECT — projection,
*,DISTINCT - WHERE —
=!=<<=>>=BETWEENINIS NULLLIKE, combined withAND/OR/NOT - ORDER BY (multi-column, ASC/DESC),
LIMIT,OFFSET - GROUP BY + COUNT, SUM, AVG, MIN, MAX,
HAVING,COUNT(DISTINCT) - JOIN — INNER, LEFT, RIGHT, FULL OUTER (up to 32 tables), and JOIN + GROUP BY
- Window functions — ROW_NUMBER, RANK, DENSE_RANK, LAG, LEAD, SUM/AVG
OVER (PARTITION BY), withROWS/RANGEframe clauses - Subqueries —
IN/EXISTS/ scalar, correlated and uncorrelated (across tables) - CTEs —
WITH … ASover real tables, with filters, joins and aggregates, plus a singleWITH RECURSIVE(UNION ALLonly) - Set operations —
UNION,UNION ALL,INTERSECT,EXCEPT, chainable across three or more SELECTs - CASE expressions —
CASE WHEN … THEN … ELSE … ENDin the SELECT list - INSERT, UPDATE, DELETE — set-based, matched by a
WHEREacross many rows - Transactions — BEGIN / COMMIT / ROLLBACK, atomic even across nodes — see Transactions
- DDL —
CREATE/DROPTABLE & SCHEMA (IF NOT EXISTS),PRIMARY KEY,DEFAULT,FOREIGN KEY(inline + table),SERIAL/IDENTITY,ALTER … ADD CONSTRAINT,CREATE INDEX/VIEW,EXPLAIN - Parameters — positional
$1bound from aparamsarray - Catalog & session —
pg_catalog/information_schemaintrospection,SET/SHOW(search_path, GUCs),current_schema() - Time-bucketing —
date_truncover a timestamp column inGROUP BY(the dashboard shape)
- Scalar subquery in the SELECT list —
SELECT (SELECT …) AS x; in aWHEREcomparison it ships - Qualified projection off a derived table —
SELECT d.c FROM (…) d;SELECT candSELECT *over the same derived table work - nextval() as a column
DEFAULTorINSERTvalue — supply an epoch literal or a bound param
Try anything in the "not yet" list and you get a 400 with a clear reason. Nothing is silently re-interpreted.
The endpoint.
POST /v1/tenants/:tenant/sql
Authorization: Bearer $OC_TOKEN
Content-Type: application/json
{ "sql": "SELECT ..." }
The response shape depends on the statement kind. Every response carries a "kind" field so your code can switch on it:
| kind | When | What's in the body |
|---|---|---|
| "select" | SELECT | rows: [{...}, ...] - one object per row. |
| "explain" | EXPLAIN | plan - the pretty-printed plan tree, plus stats on EXPLAIN ANALYZE. EXPLAIN does not carry kind "select". |
| "insert" | INSERT | schema, rows - the inserted rows. INSERT executes and enforces foreign keys. See Writes below. |
| "update" | UPDATE | rows_affected - UPDATE executes for every row the WHERE clause matches. A predicate on the primary key takes a fast path; any other supported predicate lowers to a scan. |
| "delete" | DELETE | schema, pk, rows_affected - DELETE executes for every row the WHERE clause matches and is durable when the response returns; a transaction is not required. See Writes. |
Column types.
You declare a column's type when you register a schema (see schemas) or with CREATE TABLE. The engine validates every value against it. These are the types it stores:
Type SQL aliases Holds i64 / u64 INT, BIGINT, SMALLINT Signed / unsigned integers. f64 FLOAT, REAL, DOUBLE Floating-point numbers. decimal DECIMAL(p,s), NUMERIC Exact fixed-point (money). str / text TEXT, VARCHAR, CHAR UTF-8 strings. bool BOOL, BOOLEAN True / false. timestamp / date TIMESTAMP, DATETIME, DATE Instants / calendar dates. uuid UUID Format-validated UUIDs. enum ENUM(...) One of a fixed set of strings. json JSON, JSONB Arbitrary JSON values. list ARRAY Ordered lists. bytes BLOB, BYTEA, BINARY Raw byte strings. inet / interval / point INET, INTERVAL, POINT IP addresses, durations, geo points.
1. SELECT basics.
what this does
Read rows back from a table. Pick which columns you want, filter with WHERE, cap the result count with LIMIT.
POST /v1/tenants/:t/sql curl -X POST "https://$OC_HOST/v1/tenants/$OC_TENANT/sql" \
-H "Authorization: Bearer $OC_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"sql": "SELECT id, name, price_cents FROM shop.products WHERE category = '\''electronics'\'' LIMIT 50"
}'
result = db.sql("""
SELECT id, name, price_cents
FROM shop.products
WHERE category = 'electronics'
LIMIT 50
""")
# result is a SqlSelect when the statement was a SELECT.
for row in result.rows:
print(row["id"], row["name"], row["price_cents"])
const result = await db.sql(`
SELECT id, name, price_cents
FROM shop.products
WHERE category = 'electronics'
LIMIT 50
`);
if (result.kind === "select") {
for (const row of result.rows) {
console.log(row.id, row.name, row.price_cents);
}
}
result, err := db.SQL(ctx, `
SELECT id, name, price_cents
FROM shop.products
WHERE category = 'electronics'
LIMIT 50
`)
if err != nil { /* handle */ }
if result.Kind == "select" {
for _, row := range result.Rows {
fmt.Println(row["id"], row["name"], row["price_cents"])
}
}
common mistakes - ORDER BY runs server-side. Sort by one or more columns with
ASC/DESC, and page through results with LIMIT + OFFSET. (Window frame clauses like ROWS BETWEEN are supported; GROUPS mode and EXCLUDE are not.) - Missing LIMIT. A SELECT without LIMIT returns every matching row. For large tables this can be slow. Always include a LIMIT during development.
- Schema vs. table name. Use the full
schema.table form (here, shop.products). The bare table name without the schema doesn't resolve.
2. WHERE filters.
what this does
Narrow down which rows come back. Combine any number of conditions with AND, OR and NOT.
POST /v1/tenants/:t/sql curl -X POST "https://$OC_HOST/v1/tenants/$OC_TENANT/sql" \
-H "Authorization: Bearer $OC_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"sql": "SELECT id, name, price_cents FROM shop.products WHERE category = '\''electronics'\'' AND price_cents > 10000 AND price_cents < 50000"
}'
# Multiple AND conditions. WHERE supports = != < <= > >=, BETWEEN,
# IN, IS NULL, IS NOT NULL, and LIKE. Combine with AND, OR and NOT.
result = db.sql("""
SELECT id, name, price_cents
FROM shop.products
WHERE category = 'electronics'
AND price_cents > 10000
AND price_cents < 50000
""")
// Multiple AND conditions. WHERE supports = != < <= > >=, BETWEEN,
// IN, IS NULL, IS NOT NULL, and LIKE. Combine with AND, OR and NOT.
const result = await db.sql(`
SELECT id, name, price_cents
FROM shop.products
WHERE category = 'electronics'
AND price_cents > 10000
AND price_cents < 50000
`);
// Multiple AND conditions. WHERE supports = != < <= > >=, BETWEEN,
// IN, IS NULL, IS NOT NULL, and LIKE. Combine with AND, OR and NOT.
result, _ := db.SQL(ctx, `
SELECT id, name, price_cents
FROM shop.products
WHERE category = 'electronics'
AND price_cents > 10000
AND price_cents < 50000
`)
operators you can use Operator Example = != < <= > >= price_cents > 1000 BETWEEN price_cents BETWEEN 1000 AND 20000 IN (list) category IN ('electronics', 'books') IS NULL / IS NOT NULL description IS NOT NULL LIKE name LIKE 'Wireless%'
common mistakes - AND, OR, and NOT all work. Combine them freely — e.g.
WHERE (category = 'books' OR category = 'toys') AND price_cents < 5000. IN (...) is still the tidiest way to match a set of values. - No bind parameters. Inline literal values -
$1 and ? placeholders are not supported yet. If you build SQL from user input, escape strings carefully. - String quoting in cURL. Single quotes inside a JSON string need to be escaped as
'\\''. Easier to use a Python / TS / Go SDK for any non-trivial query.
3. GROUP BY + aggregates.
what this does
Roll rows up by one or more columns and compute aggregates (counts, sums, averages, min/max). Useful for any "how many X per Y" question.
POST /v1/tenants/:t/sql curl -X POST "https://$OC_HOST/v1/tenants/$OC_TENANT/sql" \
-H "Authorization: Bearer $OC_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"sql": "SELECT category, COUNT(*) AS n, SUM(price_cents) AS total, AVG(price_cents) AS avg_price FROM shop.products GROUP BY category"
}'
result = db.sql("""
SELECT category,
COUNT(*) AS n,
SUM(price_cents) AS total,
AVG(price_cents) AS avg_price
FROM shop.products
GROUP BY category
""")
for row in result.rows:
print(row["category"], row["n"], row["total"], row["avg_price"])
const result = await db.sql(`
SELECT category,
COUNT(*) AS n,
SUM(price_cents) AS total,
AVG(price_cents) AS avg_price
FROM shop.products
GROUP BY category
`);
if (result.kind === "select") {
for (const row of result.rows) {
console.log(row.category, row.n, row.total, row.avg_price);
}
}
result, _ := db.SQL(ctx, `
SELECT category,
COUNT(*) AS n,
SUM(price_cents) AS total,
AVG(price_cents) AS avg_price
FROM shop.products
GROUP BY category
`)
if result.Kind == "select" {
for _, row := range result.Rows {
fmt.Println(row["category"], row["n"], row["total"], row["avg_price"])
}
}
supported aggregate functions Function What it returns COUNT(*) Number of rows in the group. COUNT(col) Number of non-null values of col. SUM(col) Sum of values. AVG(col) Arithmetic mean of values. MIN(col), MAX(col) Smallest / largest value.
common mistakes - Use HAVING to filter groups.
WHERE filters rows before grouping; HAVING filters groups after — e.g. SELECT category, COUNT(*) AS n … GROUP BY category HAVING COUNT(*) > 3. The aggregate you filter on has to appear in the SELECT list too. Both run server-side. - DISTINCT aggregates work.
COUNT(DISTINCT col), SUM(DISTINCT col), and AVG(DISTINCT col) all execute. (COUNT(DISTINCT *) is the one form that isn't allowed.) - Every selected column needs to be in GROUP BY or an aggregate. Standard SQL rule - if you select a column you didn't group by, you'll see
400.
4. JOIN tables.
what this does
Combine rows from two or more tables on a matching column. INNER, LEFT, RIGHT, and FULL OUTER joins are supported. Up to 32 tables in one query.
POST /v1/tenants/:t/sql curl -X POST "https://$OC_HOST/v1/tenants/$OC_TENANT/sql" \
-H "Authorization: Bearer $OC_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"sql": "SELECT o.id, o.qty, p.name FROM shop.orders o INNER JOIN shop.products p ON o.product_id = p.id WHERE o.status = '\''paid'\'' LIMIT 100"
}'
result = db.sql("""
SELECT o.id, o.qty, p.name
FROM shop.orders o
INNER JOIN shop.products p ON o.product_id = p.id
WHERE o.status = 'paid'
LIMIT 100
""")
for row in result.rows:
print(row["o.id"], row["o.qty"], row["p.name"])
const result = await db.sql(`
SELECT o.id, o.qty, p.name
FROM shop.orders o
INNER JOIN shop.products p ON o.product_id = p.id
WHERE o.status = 'paid'
LIMIT 100
`);
if (result.kind === "select") {
for (const row of result.rows) {
console.log(row["o.id"], row["o.qty"], row["p.name"]);
}
}
result, _ := db.SQL(ctx, `
SELECT o.id, o.qty, p.name
FROM shop.orders o
INNER JOIN shop.products p ON o.product_id = p.id
WHERE o.status = 'paid'
LIMIT 100
`)
if result.Kind == "select" {
for _, row := range result.Rows {
fmt.Println(row["o.id"], row["o.qty"], row["p.name"])
}
}
join types Type What you get INNER JOIN Only rows that have a match on both sides. LEFT JOIN Every row from the left side, plus matches from the right (null if no match). RIGHT JOIN Mirror of LEFT - every row from the right side, plus matches from the left. FULL OUTER JOIN Every row from both sides; nulls fill the gaps.
common mistakes - CROSS JOIN is narrow. Only the two-table
SELECT * FROM a CROSS JOIN b form works, optionally with a LIMIT. A third table, a mix with INNER / OUTER joins, or a WHERE, ORDER BY, OFFSET, DISTINCT or explicit projection on one returns 400 — use an explicit ON condition instead. - Ambiguous column names. When two tables have the same column name, qualify with the alias (
o.id, p.id) - otherwise the parser refuses. - 33+ tables. The cap is 32 tables per query. Larger joins return 400. (You almost never want more than 5 in practice.)
5. Writes via SQL (preview).
INSERT and UPDATE execute against the engine. INSERT writes the rows and enforces foreign keys; UPDATE changes every row its WHERE clause matches, taking a fast path when that predicate is the primary key and returns rows_affected. For high-volume ingest, the dedicated row endpoints are still the fastest path.
POST /v1/tenants/:t/sql - INSERT curl -X POST "https://$OC_HOST/v1/tenants/$OC_TENANT/sql" \
-H "Authorization: Bearer $OC_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"sql": "INSERT INTO shop.products (id, name, category, price_cents) VALUES ('\''p-x-1'\'', '\''New Product'\'', '\''electronics'\'', 5555)"
}'
response {
"kind": "insert",
"schema": "shop.products",
"rows": [
{ "id": "p-x-1", "name": "New Product", "category": "electronics", "price_cents": 5555 }
]
}
deletes
You can run row deletes inside a transaction — BEGIN; DELETE FROM shop.products WHERE id = 'p-x-1'; COMMIT; — which buffers the delete and applies it on commit. A bare DELETE outside a transaction executes immediately and is durable when the response returns. Both UPDATE and DELETE are set-based — they act on every row the WHERE clause matches. See Transactions for the full lifecycle, error handling, and retry pattern.
6. EXPLAIN.
what this does
Prefix any SELECT with EXPLAIN to see the query plan the engine would run, without executing it. Useful for checking whether your indexes are being used.
POST /v1/tenants/:t/sql curl -X POST "https://$OC_HOST/v1/tenants/$OC_TENANT/sql" \
-H "Authorization: Bearer $OC_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"sql": "EXPLAIN SELECT id, name FROM shop.products WHERE category = '\''electronics'\''"
}'
result = db.sql("EXPLAIN SELECT id, name FROM shop.products WHERE category = 'electronics'")
print(result.rows[0]) # → the plan tree as JSON
const result = await db.sql(
`EXPLAIN SELECT id, name FROM shop.products WHERE category = 'electronics'`
);
if (result.kind === "select") console.log(result.rows[0]);
result, _ := db.SQL(ctx,
"EXPLAIN SELECT id, name FROM shop.products WHERE category = 'electronics'")
if result.Kind == "select" {
fmt.Println(result.Rows[0])
}
The plan tree includes operator names like Scan, Filter, IndexScan, HashJoin, Aggregate. If you see Scan where you expected IndexScan, you likely need an [[indexes]] declaration on the schema.
Examples.
SQL is the general-purpose surface: reach for it whenever the question is which rows rather than this row — filtering, sorting, counting, summing, joining — and for the writes the row endpoints do not cover, like a partial update or a delete. One endpoint takes a statement, executes it, and returns JSON.
It is a large, real SQL subset rather than a full dialect, and this page is written to be exact about the boundary. Everything listed as supported was read off the translator and the executor; everything refused is refused with an error, not silently ignored.
7 The endpoint contract.
POST /v1/tenants/:tenant/sql. The body has exactly two fields — sql (required) and params (optional). There is no namespace field, no limit field and no result-format field; the statement carries all of that.
One statement per request. Two statements separated by a semicolon are refused.
Every success carries a kind discriminator naming what ran — select, insert, update, delete, explain, tx, or one of the schema-change kinds. Branch on it; do not assume a rows array is there.
row keys are alphabetical, not projection order
Each row is a JSON object, and its keys serialize in alphabetical order — SELECT id, customer comes back with customer first. That is why a columns array is included on the response: it carries the real projection order. Anything that renders a table or writes a CSV should read columns, not the key order of the first row.
columns is omitted for SELECT *
The array is only present when the plan declares a static projection order. SELECT *, a join wildcard, and a set operation each declare none, so the field is left off the response entirely — not sent as an empty array. If column order matters to you, list the columns explicitly.
8 SELECT.
projection with WHERE, ORDER BY and LIMIT curl -X POST "https://$OC_HOST/v1/tenants/$OC_TENANT/sql" \
-H "Authorization: Bearer $OC_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"sql": "SELECT id, customer, amount_cents FROM shop.orders WHERE status = '\''paid'\'' ORDER BY placed_ms DESC LIMIT 3"
}'
result = db.sql.query("""
SELECT id, customer, amount_cents
FROM shop.orders
WHERE status = 'paid'
ORDER BY placed_ms DESC
LIMIT 3
""")
print(result.columns) # -> ['id', 'customer', 'amount_cents']
for row in result.rows:
print(row["id"], row["amount_cents"])
const res = await db.sql(`
SELECT id, customer, amount_cents
FROM shop.orders
WHERE status = 'paid'
ORDER BY placed_ms DESC
LIMIT 3
`);
if (res.kind === "select") {
for (const row of res.rows as Record<string, unknown>[]) {
console.log(row.id, row.amount_cents);
}
}
res, err := db.SQL(ctx, `
SELECT id, customer, amount_cents
FROM shop.orders
WHERE status = 'paid'
ORDER BY placed_ms DESC
LIMIT 3
`)
if err != nil { /* handle */ }
if res.Kind == "select" {
for _, row := range res.Rows {
fmt.Println(row["id"], row["amount_cents"])
}
}
response200{
"kind": "select",
"columns": ["id", "customer", "amount_cents"],
"rows": [
{ "amount_cents": 8900, "customer": "cus-12", "id": "ord-2002" },
{ "amount_cents": 4200, "customer": "cus-77", "id": "ord-1001" },
{ "amount_cents": 1500, "customer": "cus-12", "id": "ord-2001" }
]
}
Note the keys inside each row are ALPHABETICAL, not projection order. "columns" is the projection order - use it if order matters.
Clause by clause
Clause Runs Boundaries SELECT * / column list / expressions / AS aliases yes Mixing * with expression projections is refused — list the columns. SELECT DISTINCT yes DISTINCT ON (…) is refused. DISTINCT with GROUP BY or a window function is refused. WHERE yes See the operator table above. ORDER BY col [ASC|DESC], … yes Bare column names or a projection position only. Functions and expressions are refused, and there is no NULLS FIRST / NULLS LAST. LIMIT n [OFFSET m] yes OFFSET without LIMIT works. FETCH FIRST … ROWS ONLY is refused — use LIMIT. GROUP BY … / HAVING … yes Included from the Thunder configuration up. ROLLUP / CUBE / GROUPING SETS are refused. JOIN (INNER / LEFT / RIGHT / FULL / CROSS) yes Included from the Thunder configuration up. See the join rules below. Subqueries in WHERE yes IN, EXISTS and scalar =, correlated or not. One level of nesting. A scalar subquery in the SELECT list is refused. Over a derived table (FROM (SELECT …)) an unqualified SELECT c or SELECT * works; a qualified SELECT d.c is refused. WITH … AS (…) · WITH RECURSIVE yes Recursive form is base UNION ALL recursive, depth-capped at 100. Nested WITH and column-list renaming are refused. UNION · UNION ALL · INTERSECT · EXCEPT yes Outer ORDER BY / LIMIT / OFFSET wrap the combined result. Window functions OVER (…) yes ROW_NUMBER, RANK, DENSE_RANK, LAG, LEAD, FIRST_VALUE, LAST_VALUE, NTILE, and SUM / AVG / MIN / MAX / COUNT. Single-table SELECT list only — refused alongside JOIN, GROUP BY or DISTINCT. CASE WHEN … THEN … END yes Both the searched and the simple form. CAST(x AS t) · x::t yes TRY_CAST and SAFE_CAST are refused. SELECT with no FROM partly A constant expression like SELECT 1 + 2 AS three works. A bare SELECT 1 that a driver sends as a liveness probe does not — every SELECT that names a column must name a table.
WHERE operators
Operator Notes = != <> < <= > >= Against a literal, another column, or an expression. AND · OR · NOT · ( ) Arbitrary boolean trees. IN (…) · NOT IN (…) Literal lists and subqueries, correlated or not. Three-valued null logic. BETWEEN a AND b Closed interval, and the form that gets index range pushdown. NOT BETWEEN is refused. IS NULL · IS NOT NULL LIKE · NOT LIKE · ILIKE · NOT ILIKE The ESCAPE clause is refused. EXISTS · NOT EXISTS Correlated and uncorrelated. = (SELECT …) Scalar subquery. Only with =; the ordering comparators are refused. Subqueries of every form are refused outright on a tenant with read-side RBAC configured, or an identity-less READ posture of deny - the subquery executor cannot enforce per-user reads, so it fails closed.
Scalar functions usable in a projection or a predicate:
NOW() / CURRENT_TIMESTAMP · LOWER · UPPER · LENGTH / CHAR_LENGTH · COALESCE · NULLIF · ABS · ROUND · FLOOR · CEIL / CEILING · MOD · POWER / POW · SQRT · CONCAT · SUBSTRING / SUBSTR · TRIM / BTRIM / LTRIM / RTRIM · REPLACE · POSITION / STRPOS
Anything outside that list is refused with a message enumerating what is available. Note two absences people reach for: the || string-concatenation operator is not available in a row expression — use CONCAT(a, b) — and there are no date-part extraction functions.
9 Bind parameters.
Placeholders are PostgreSQL-style and positional: $1, $2, and so on, filled from the params array in order. Values are substituted into the parsed statement as literals — never spliced into the text — so a parameter can change what a query matches but can never change what the statement does. Use them for anything that came from a user.
parameterised SELECT # Placeholders are PostgreSQL-style and positional: $1, $2, ...
# params[0] fills $1. JDBC-style "?" is refused.
curl -X POST "https://$OC_HOST/v1/tenants/$OC_TENANT/sql" \
-H "Authorization: Bearer $OC_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"sql": "SELECT id, amount_cents FROM shop.orders WHERE status = $1 AND amount_cents > $2 LIMIT $3",
"params": ["paid", 1000, 20]
}'
import os, requests
BASE = f"https://{os.environ['OC_HOST']}/v1/tenants/{os.environ['OC_TENANT']}"
H = {"Authorization": f"Bearer {os.environ['OC_TOKEN']}"}
# The Python client's params= argument sends a NAMED mapping, which this
# engine does not accept - it binds positional $1, $2 from a JSON array.
# Until the client is updated, bind through the HTTP API directly:
res = requests.post(f"{BASE}/sql", headers=H, json={
"sql": "SELECT id, amount_cents FROM shop.orders "
"WHERE status = $1 AND amount_cents > $2 LIMIT $3",
"params": ["paid", 1000, 20],
})
res.raise_for_status()
for row in res.json()["rows"]:
print(row["id"], row["amount_cents"])
// The TypeScript client takes a positional array and sends it as-is.
const res = await db.sql(
`SELECT id, amount_cents FROM shop.orders
WHERE status = $1 AND amount_cents > $2 LIMIT $3`,
["paid", 1000, 20],
);
if (res.kind === "select") console.log(res.rows.length);
// The Go client's variadic params are sent as a positional JSON array.
// (Its doc comment predates parameter support in the engine.)
res, err := db.SQL(ctx,
`SELECT id, amount_cents FROM shop.orders
WHERE status = $1 AND amount_cents > $2 LIMIT $3`,
"paid", 1000, 20,
)
if err != nil { /* handle */ }
fmt.Println(len(res.Rows))
- Scalars only. Strings, numbers, booleans and null. A JSON array or object as a parameter is refused — you cannot bind a list to
IN ($1); generate IN ($1, $2, $3) instead. - The count must match exactly, both ways. A supplied value the statement never references is an error, and so is a
$3 with only two values. Referencing the same placeholder twice is fine. ? is not accepted. The error says so explicitly. Numbering starts at $1 — $0 is out of range. - They work in
LIMIT too, and in INSERT … VALUES and UPDATE … SET. - Not on transaction verbs. Sending
params alongside BEGIN, COMMIT or ROLLBACK is a 400.
client support is uneven right now
The TypeScript and Go clients both send a positional array, which is what the engine binds. The Python client's params= argument sends a named mapping against :name placeholders — a shape this engine does not accept, so the request is rejected before the statement is parsed. From Python, bind through the HTTP API as shown above until the client is updated.
10 Aggregates & GROUP BY.
There are exactly five aggregate functions: COUNT, SUM, AVG, MIN and MAX. Anything else — standard deviation, string aggregation, percentiles — is refused with that list in the message.
GROUP BY with HAVING curl -X POST "https://$OC_HOST/v1/tenants/$OC_TENANT/sql" \
-H "Authorization: Bearer $OC_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"sql": "SELECT customer, COUNT(*) AS orders, SUM(amount_cents) AS total FROM shop.orders WHERE status = '\''paid'\'' GROUP BY customer HAVING SUM(amount_cents) > 5000 ORDER BY total DESC LIMIT 10"
}'
result = db.sql.query("""
SELECT customer, COUNT(*) AS orders, SUM(amount_cents) AS total
FROM shop.orders
WHERE status = 'paid'
GROUP BY customer
HAVING SUM(amount_cents) > 5000
ORDER BY total DESC
LIMIT 10
""")
for row in result.rows:
print(row["customer"], row["orders"], row["total"])
const res = await db.sql(`
SELECT customer, COUNT(*) AS orders, SUM(amount_cents) AS total
FROM shop.orders
WHERE status = 'paid'
GROUP BY customer
HAVING SUM(amount_cents) > 5000
ORDER BY total DESC
LIMIT 10
`);
if (res.kind === "select") console.table(res.rows);
res, err := db.SQL(ctx, `
SELECT customer, COUNT(*) AS orders, SUM(amount_cents) AS total
FROM shop.orders
WHERE status = 'paid'
GROUP BY customer
HAVING SUM(amount_cents) > 5000
ORDER BY total DESC
LIMIT 10
`)
if err != nil { /* handle */ }
for _, row := range res.Rows {
fmt.Println(row["customer"], row["orders"], row["total"])
}
response200{
"kind": "select",
"columns": ["customer", "orders", "total"],
"rows": [
{ "customer": "cus-12", "orders": 2, "total": 10400 }
]
}
GROUP BY and HAVING are included from the Thunder configuration up - see section 14.
COUNT(*) is the only wildcard form. COUNT(DISTINCT x), SUM(DISTINCT x) and AVG(DISTINCT x) all work; DISTINCT on MIN or MAX is accepted but has no effect (it cannot). - Expression arguments work —
SUM(amount_cents + shipping_cents), COUNT(DISTINCT LOWER(customer)). - Always give an aggregate an
AS alias. Without one the output column is auto-named from the expression — sum(amount_cents), count(*) — which is awkward to index in every client language. HAVING compares an aggregate or a grouped column against a literal, combined with AND / OR. Comparing one aggregate against another is refused, and HAVING without GROUP BY is refused. SUM and AVG over a date, time or timestamp column are refused — the same way PostgreSQL refuses them.
aggregates are the one shape that streams
An aggregate directly over a scan or a filtered scan is computed as rows arrive, so SELECT COUNT(*) or SUM(...) over a very large table works without buffering it. That is not true of a projection — see limits.
11 Joins.
INNER, LEFT, RIGHT and FULL OUTER all work, plus a two-table CROSS JOIN. A comma join with a single equality in the WHERE is promoted to an inner join for you.
INNER JOIN # One equality per ON clause, both sides qualified: alias.column.
curl -X POST "https://$OC_HOST/v1/tenants/$OC_TENANT/sql" \
-H "Authorization: Bearer $OC_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"sql": "SELECT o.id, c.name, o.amount_cents FROM shop.orders o INNER JOIN shop.customers c ON o.customer = c.id WHERE o.status = '\''paid'\'' LIMIT 10"
}'
# Projected columns keep their alias prefix: the result columns are
# literally "o.id", "c.name", "o.amount_cents".
#
# JOIN is included from the Thunder configuration up - see section 14.
result = db.sql.query("""
SELECT o.id, c.name, o.amount_cents
FROM shop.orders o
INNER JOIN shop.customers c ON o.customer = c.id
WHERE o.status = 'paid'
LIMIT 10
""")
for row in result.rows:
print(row["o.id"], row["c.name"]) # note the alias-qualified keys
const res = await db.sql(`
SELECT o.id, c.name, o.amount_cents
FROM shop.orders o
INNER JOIN shop.customers c ON o.customer = c.id
WHERE o.status = 'paid'
LIMIT 10
`);
if (res.kind === "select") {
for (const row of res.rows as Record<string, unknown>[]) {
console.log(row["o.id"], row["c.name"]); // alias-qualified keys
}
}
res, err := db.SQL(ctx, `
SELECT o.id, c.name, o.amount_cents
FROM shop.orders o
INNER JOIN shop.customers c ON o.customer = c.id
WHERE o.status = 'paid'
LIMIT 10
`)
if err != nil { /* handle */ }
for _, row := range res.Rows {
fmt.Println(row["o.id"], row["c.name"]) // alias-qualified keys
}
one equality per ON clause — no AND, no inequality
An ON clause must be exactly alias.column = alias.column. A composite join condition — ON a.x = b.x AND a.y = b.y — is refused, and so is any non-equality such as ON a.t > b.t. If you need a composite key, join on one column and filter the rest in the WHERE. The same limit means JOIN … USING (a, b) with more than one column is refused, though a single-column USING works.
- Projected columns keep their alias.
SELECT o.id gives you a column literally named o.id. Rename in your client if you need something else. - Up to 32 tables in one
FROM; join chains are evaluated left to right. - A
JOIN with no ON is refused, and an alias cannot be reused across joins. GROUP BY, DISTINCT, ORDER BY and OFFSET all compose over a join. Window functions do not — that combination is refused.
a typo on the right-hand table returns zero rows, not an error
Column names are validated against the left-most table's manifest only. A misspelled column on a joined-in table passes validation and never matches, so the query succeeds with an empty result. If a join returns nothing and you expected rows, check the spelling on the right-hand side first.
avoid NATURAL JOIN
It is accepted, but it infers the join keys from the left table alone and trusts that the right table has them. When that assumption is wrong you get zero rows rather than an error. Write the ON clause out.
12 Writes and schema changes.
Write statements execute — they are not translated into something you then have to re-issue. A successful INSERT, UPDATE or DELETE is durable when the response returns.
INSERT with RETURNING # INSERT executes and writes durably. The column list is MANDATORY.
curl -X POST "https://$OC_HOST/v1/tenants/$OC_TENANT/sql" \
-H "Authorization: Bearer $OC_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"sql": "INSERT INTO shop.orders (id, customer, amount_cents, status, notes, placed_ms) VALUES ($1, $2, $3, $4, $5, $6) RETURNING id, status",
"params": ["ord-3001", "cus-90", 2500, "pending", "", 1714478400000]
}'
# Explicit upsert instead:
# INSERT INTO shop.orders (id, status) VALUES ('ord-3001', 'shipped')
# ON CONFLICT (id) DO UPDATE SET status = EXCLUDED.status
res = db.sql.execute("""
INSERT INTO shop.orders (id, customer, amount_cents, status, notes, placed_ms)
VALUES ('ord-3001', 'cus-90', 2500, 'pending', '', 1714478400000)
""")
print(res.kind) # -> insert
# Bulk loading? Use the row batch endpoint instead - it is far faster.
db.rows.put_batch("shop.orders", rows)
const res = await db.sql(`
INSERT INTO shop.orders (id, customer, amount_cents, status, notes, placed_ms)
VALUES ('ord-3001', 'cus-90', 2500, 'pending', '', 1714478400000)
`);
if (res.kind === "insert") {
console.log(res.schema, res.rows);
}
res, err := db.SQL(ctx, `
INSERT INTO shop.orders (id, customer, amount_cents, status, notes, placed_ms)
VALUES ('ord-3001', 'cus-90', 2500, 'pending', '', 1714478400000)
`)
if err != nil { /* handle */ }
fmt.Println(res.Kind, res.Schema) // -> insert shop.orders
response200{
"kind": "insert",
"schema": "shop.orders",
"inserted": 1,
"returning": ["id", "status"],
"rows": [ { "id": "ord-3001", "status": "pending" } ]
}
response409{ "error": "constraint_violation", "detail": "..." }
A duplicate primary key is a hard error - the existing row survives:
UPDATE and DELETE # UPDATE executes. A WHERE clause is MANDATORY, and you cannot SET a
# primary-key column. RETURNING works on UPDATE.
curl -X POST "https://$OC_HOST/v1/tenants/$OC_TENANT/sql" \
-H "Authorization: Bearer $OC_TOKEN" -H "Content-Type: application/json" \
-d '{"sql":"UPDATE shop.orders SET status = $1 WHERE customer = $2",
"params":["shipped","cus-12"]}'
# DELETE executes too, and DOES support RETURNING.
curl -X POST "https://$OC_HOST/v1/tenants/$OC_TENANT/sql" \
-H "Authorization: Bearer $OC_TOKEN" -H "Content-Type: application/json" \
-d '{"sql":"DELETE FROM shop.orders WHERE status = $1 RETURNING id",
"params":["cancelled"]}'
res = db.sql.execute(
"UPDATE shop.orders SET status = 'shipped' WHERE customer = 'cus-12'"
)
print(res.kind) # -> update
# NOTE: res.rows_affected on this client is a placeholder, not the real
# count. Read the raw response if you need it.
db.sql.execute("DELETE FROM shop.orders WHERE status = 'cancelled'")
await db.sql("UPDATE shop.orders SET status = 'shipped' WHERE customer = 'cus-12'");
const del = await db.sql("DELETE FROM shop.orders WHERE status = 'cancelled'");
if (del.kind === "delete") console.log(del.schema, del.pk);
if _, err := db.SQL(ctx,
"UPDATE shop.orders SET status = 'shipped' WHERE customer = 'cus-12'",
); err != nil { /* handle */ }
del, err := db.SQL(ctx, "DELETE FROM shop.orders WHERE status = 'cancelled'")
if err != nil { /* handle */ }
fmt.Println(del.Kind, del.Schema)
response{ "kind": "update", "schema": "shop.orders", "rows_affected": 2 }
response{ "kind": "delete", "schema": "shop.orders", "returning": ["id"],
"rows": [ {"id": "ord-1900"} ], "rows_affected": 1 }
Every write statement, and what it does
Statement Status Notes INSERT … VALUES (…) executes Multi-row VALUES supported. The column list is mandatory. A duplicate primary key is a 409, not an overwrite. INSERT … SELECT … executes Full source-scan authorization applies. INSERT … ON CONFLICT executes DO NOTHING and DO UPDATE SET col = literal | EXCLUDED.col. A conflict target is required; primary-key columns cannot be SET. INSERT … RETURNING executes Returns the written rows projected to the listed columns. UPDATE … SET … WHERE … executes WHERE is mandatory. Primary-key columns cannot be SET. UPDATE … FROM and joined UPDATE are refused. UPDATE … RETURNING executes Returns the updated rows with their NEW (post-SET) values. DELETE FROM … WHERE … executes Both the primary-key fast path and an arbitrary predicate. DELETE … RETURNING executes Returns the deleted rows. DELETE FROM t (no WHERE) executes Deletes every row. Accepted only on this endpoint — every other surface refuses a bare DELETE. CREATE TABLE · CREATE INDEX · CREATE VIEW · CREATE SEQUENCE · CREATE SCHEMA executes CREATE INDEX backfills existing rows before it returns. ALTER TABLE ADD / DROP / RENAME COLUMN · ADD / DROP CONSTRAINT executes Driven to completion synchronously — the change is live when the response returns. DROP TABLE · DROP VIEW · DROP SEQUENCE · DROP SCHEMA executes DROP TABLE is a full destructive purge: rows, indexes, relations and the registration. DROP SCHEMA is RESTRICT by default — it refuses while the namespace still owns tables; CASCADE drops every table in it. CREATE / DROP PROCEDURE · FUNCTION · CALL executes Preview scope — a single statement body for procedures, a scalar expression for functions. BEGIN · COMMIT · ROLLBACK executes Buffers writes into a session transaction. See the transactions page. Anything else refused Returns 400 listing the accepted statement verbs.
a bare DELETE deletes everything, and this endpoint allows it DELETE FROM shop.orders with no WHERE is accepted here and removes every row. Every other surface refuses it. UPDATE is the opposite — it requires a WHERE as a safety check. Do not rely on the asymmetry; put a predicate on both.
SQL INSERT rejects duplicates — the row endpoint does not
An INSERT whose primary key already exists returns 409 and writes nothing; the existing row survives. That check covers both committed rows and duplicates inside the same VALUES list. The row endpoint is an upsert and overwrites instead — a real difference between the two write paths, worth knowing before you pick one.
use the row batch endpoint for bulk loading
Multi-row INSERT … VALUES works, but the batch row endpoint is the fast path by a wide margin, and it has a streaming form with no size limit. Reserve SQL INSERT for writes where you want the strict duplicate check or a RETURNING clause.
Stored procedures and functions
Preview scope. A procedure wraps one write statement whose parameters bind as $1..$N; run it with CALL. A function returns one value from a scalar expression and is usable inside a query. CREATE OR REPLACE is not supported - drop and recreate to change a definition.
-- A procedure: one statement, parameters bound as $1..$N.
CREATE PROCEDURE add_customer(id TEXT, email TEXT) AS BEGIN
INSERT INTO shop.customers (id, email) VALUES ($1, $2)
END;
-- Invoke it with literal arguments.
CALL add_customer('c_501', 'ada@example.com');
-- A scalar function returns one value from an expression.
CREATE FUNCTION shout(s TEXT) RETURNS TEXT RETURN UPPER(s);
-- Use it inside a query like any built-in.
SELECT id, shout(email) FROM shop.customers;
-- Change one by dropping and recreating (no CREATE OR REPLACE).
DROP PROCEDURE add_customer;
DROP FUNCTION shout;
The procedure body is a single SELECT / INSERT / UPDATE / DELETE / CALL. Multi-statement bodies, procedural control flow, and the USING / DETERMINISTIC / REMOTE clauses are refused in preview. A function can also return a set with RETURNS TABLE(...).
13 EXPLAIN.
Prefix any SELECT with EXPLAIN to get the plan back instead of the rows. The one thing to look for is the leaf: an index scan or index range scan means your predicate is using an index; a plain scan under a filter means it is reading the whole table.
EXPLAIN and EXPLAIN ANALYZE # Check whether an index is actually being used.
curl -X POST "https://$OC_HOST/v1/tenants/$OC_TENANT/sql" \
-H "Authorization: Bearer $OC_TOKEN" -H "Content-Type: application/json" \
-d '{"sql":"EXPLAIN SELECT id FROM shop.orders WHERE status = '\''paid'\''"}'
# EXPLAIN ANALYZE runs the query and adds per-operator timings.
curl -X POST "https://$OC_HOST/v1/tenants/$OC_TENANT/sql" \
-H "Authorization: Bearer $OC_TOKEN" -H "Content-Type: application/json" \
-d '{"sql":"EXPLAIN ANALYZE SELECT id FROM shop.orders WHERE status = '\''paid'\''"}'
response{ "kind": "explain", "plan": "IndexScan shop.orders by_status ..." }
response{ "kind": "explain", "plan": "...", "stats": { ... } }
Predicate pushdown follows a fixed order: an equality on a single-column indexed column becomes an index scan; a >, < or BETWEEN on one becomes an index range scan; anything else becomes a full scan with a row-by-row filter. Predicate terms the index cannot serve are re-applied above it.
14 Where GROUP BY, JOIN and HAVING run.
Projections, filters, sorting, limits, subqueries, set operations and plain aggregates all run on every configuration. GROUP BY, any JOIN and HAVING are bundled into every paid configuration from Thunder up, so there is no separate add-on to buy.
On a configuration that does not include it, the request comes back 402 with a structured body naming what to enable:
{
"error": "addon_required",
"addon": "sql-pro",
"name": "SQL Pro",
"purchase_url": "https://app.originchain.ai/billing/addons?enable=sql-pro",
"msg": "This endpoint requires the SQL Pro add-on. Enable it at
/app/billing/addons or have an admin do so."
}
Note that SELECT COUNT(*) FROM shop.orders is not gated — it is an aggregate without a GROUP BY. If you hit this on a configuration that should include it, check Billing → Add-ons in the console.
the gate reads the statement text, not the parse tree
It is a whole-word, case-insensitive scan for GROUP, JOIN, HAVING, LEFT, RIGHT, FULL and OUTER, and it runs before the statement is parsed — so it does not know a string literal from a keyword. WHERE name = 'GROUP' triggers it, and so does a column or table called left, outer or join. If you get an unexpected 402 on a query with no join in it, that is why: bind the literal as a parameter, or rename the column.
15 Limits & gotchas.
the big one — a projection over a large table can fail with 413
Results are capped per query, by row count and by size. Past the cap the request fails outright:
413
{ "error": "result_too_large", "unit": "rows", "observed": 240000,
"cap": 200000,
"msg": "The query's result set exceeds the engine's per-query memory
cap. Add a LIMIT, narrow the filter, or page the ..." }
The cap scales with the instance's memory — a few hundred thousand rows or a few hundred megabytes, whichever binds first. It is enforced during execution, so an unfiltered scan aborts partway rather than running to completion and then failing. Always put a LIMIT on an exploratory projection, and page large exports with OFFSET.
ORDER BY … LIMIT is not a top-N stream
Filters, projections and limits over a plain scan stream row by row, and so do aggregates. A sort does not — it materialises its whole input first, and so do joins, set operations and window functions. That means SELECT … ORDER BY x LIMIT 10 over a very large table buffers the entire table before it takes ten rows, and can hit the cap above even though you asked for ten rows. Narrow it with a WHERE on an indexed column first.
Limit Value Notes Request body 8 MiB A statement larger than this is a 413. Bind parameters rather than inlining a large literal list. Statements per request 1 Semicolon-separated batches are refused. Tables per FROM 32 Over the cap the statement is refused with a message naming the limit. Subquery nesting 1 level A subquery inside a subquery is refused. Recursive CTE depth 100 The iteration cap on WITH RECURSIVE. Concurrent heavy queries scales with memory Over the limit a query waits briefly, then sheds with 429 and a Retry-After. Cross-shard result gather 1,000,000 rows On a sharded instance only. Over it, the query is refused with 501 — narrow it.
Smaller edges worth knowing
SELECT 1 fails. Every statement that names a column must name a table. Drivers and connection pools that probe liveness with a bare SELECT 1 will get an error — point them at a real table, or use the health endpoint. A constant-only expression such as SELECT 1 + 2 AS three does work. - No
NULLS FIRST / NULLS LAST. Missing values sort as JSON null. If null placement matters, add a CASE expression to the projection and sort on that. ORDER BY takes bare column names. Not ORDER BY LOWER(name), not ORDER BY a + b. Project the expression with an alias and order by that alias. A projection position works too, except over SELECT *. - No derived tables.
FROM (SELECT …) t is refused — use a WITH clause, which is supported. NOT BETWEEN is refused. Write col < a OR col > b — the error message on this one contains stale advice claiming OR is unavailable; it is available. - A CTE's columns are not validated. Referring to a column the CTE does not actually produce is accepted and yields nulls rather than an error.
OFFSET without LIMIT disables limit pushdown into an index range scan. Pair them when you can. - Inside a session transaction, statements do not see the buffer. A
SELECT after a buffered INSERT will not find the new row. See transactions.