SQL.
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.
Before you start.
Every example uses the shop.orders table from the quickstart, plus a shop.customers table that orders.customer points at. Table names are always fully qualified: namespace.table, never a bare table name.
namespace = "shop"
table = "orders"
primary_key = ["id"]
[[columns]]
name = "id"
ty = "str"
required = true
[[columns]]
name = "customer"
ty = "str"
[[columns]]
name = "amount_cents"
ty = "i64"
[[columns]]
name = "status"
ty = "str"
[[columns]]
name = "notes"
ty = "str"
[[columns]]
name = "placed_ms"
ty = "u64"
# Turns WHERE status = '...' into a sub-linear lookup.
[[indexes]]
name = "by_status"
columns = ["status"]
The [[indexes]] block is what turns WHERE status = … from a full scan into a lookup, and it is the difference between a query that stays fast at a million rows and one that does not. EXPLAIN tells you which one you got.
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.
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.
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.
SELECT.
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"
}'
# 200
# {
# "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.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"])
}
} 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 | Needs the SQL Pro add-on. ROLLUP / CUBE / GROUPING SETS are refused. |
| JOIN (INNER / LEFT / RIGHT / FULL / CROSS) | yes | Needs the SQL Pro add-on. See the join rules below. |
| Subqueries in WHERE | yes | IN, EXISTS and scalar =, correlated or not. One level of nesting. Subqueries in the SELECT list and derived tables (FROM (SELECT …)) are 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. |
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.
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.
# 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); generateIN ($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
$3with only two values. Referencing the same placeholder twice is fine. ?is not accepted. The error says so explicitly. Numbering starts at$1—$0is out of range.- They work in
LIMITtoo, and inINSERT … VALUESandUPDATE … SET. - Not on transaction verbs. Sending
paramsalongsideBEGIN,COMMITorROLLBACKis a 400.
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.
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.
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"
}'
# 200
# {
# "kind": "select",
# "columns": ["customer", "orders", "total"],
# "rows": [
# { "customer": "cus-12", "orders": 2, "total": 10400 }
# ]
# }
#
# GROUP BY and HAVING require the SQL Pro add-on - see section 9.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"])
} COUNT(*)is the only wildcard form.COUNT(DISTINCT x),SUM(DISTINCT x)andAVG(DISTINCT x)all work;DISTINCTonMINorMAXis accepted but has no effect (it cannot).- Expression arguments work —
SUM(amount_cents + shipping_cents),COUNT(DISTINCT LOWER(customer)). - Always give an aggregate an
ASalias. Without one the output column is auto-named from the expression —sum(amount_cents),count(*)— which is awkward to index in every client language. HAVINGcompares an aggregate or a grouped column against a literal, combined withAND/OR. Comparing one aggregate against another is refused, andHAVINGwithoutGROUP BYis refused.SUMandAVGover a date, time or timestamp column are refused — the same way PostgreSQL refuses them.
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.
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.
# 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 requires the SQL Pro add-on - see section 9.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 keysconst 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
}
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.idgives you a column literally namedo.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
JOINwith noONis refused, and an alias cannot be reused across joins. GROUP BY,DISTINCT,ORDER BYandOFFSETall compose over a join. Window functions do not — that combination is refused.
Column names are validated against the left-most table's manifest only. A misspelled column on a joined-in table passes validation and simply 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.
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.
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 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]
}'
# 200
# {
# "kind": "insert",
# "schema": "shop.orders",
# "inserted": 1,
# "returning": ["id", "status"],
# "rows": [ { "id": "ord-3001", "status": "pending" } ]
# }
# A duplicate primary key is a hard error - the existing row survives:
# 409 { "error": "constraint_violation", "detail": "..." }
# Explicit upsert instead:
# INSERT INTO shop.orders (id, status) VALUES ('ord-3001', 'shipped')
# ON CONFLICT (id) DO UPDATE SET status = EXCLUDED.statusres = 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 # UPDATE executes. A WHERE clause is MANDATORY, and you cannot SET a
# primary-key column. RETURNING is not supported 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"]}'
# { "kind": "update", "schema": "shop.orders", "rows_affected": 2 }
# 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"]}'
# { "kind": "delete", "schema": "shop.orders", "returning": ["id"],
# "rows": [ {"id": "ord-1900"} ], "rows_affected": 1 }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) 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 | refused | Re-SELECT the rows after the UPDATE. |
| 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 | executes | CREATE INDEX backfills existing rows before it returns. |
| ALTER TABLE ADD / DROP / RENAME COLUMN | executes | Driven to completion synchronously — the change is live when the response returns. |
| DROP TABLE · DROP VIEW · DROP SEQUENCE | executes | DROP TABLE is a full destructive purge: rows, indexes, relations and the registration. |
| 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. |
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.
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.
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(...).
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.
# 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'\''"}'
# { "kind": "explain", "plan": "IndexScan shop.orders by_status ..." }
# 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'\''"}'
# { "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.
What needs the SQL Pro add-on.
Projections, filters, sorting, limits, subqueries, set operations and plain aggregates all run on every configuration. GROUP BY, any JOIN and HAVING need the SQL Pro add-on enabled on the instance.
Without 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. Enable the add-on from Billing → Add-ons in the console.
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.
Type mapping.
What a column declared with each ty looks like coming out of a SELECT, and what you get after decoding in each client. All three clients decode rows into a generic map, so the language column is what JSON decoding yields there.
| ty | JSON on the wire | TypeScript | Python | Go |
|---|---|---|---|---|
| i64 / u64 | number | number | int | float64 ⚠ |
| f64 | number | number | float | float64 |
| bool | boolean | boolean | bool | bool |
| str / text | string | string | str | string |
| decimal | string — "19.99" | string | str | string |
| uuid | string | string | str | string |
| enum | string | string | str | string |
| inet | string | string | str | string |
| bytes | hex string — "\\x4f43" | string | str | string |
| date | text — "2026-07-25" | string | str | string |
| timestamp | text — "2026-07-25 09:14:22" | string | str | string |
| time | text — "09:14:22.500000" | string | str | string |
| interval | number — milliseconds | number | int | float64 |
| json | object or array | unknown | dict / list | map[string]any / []any |
| list | array | unknown[] | list | []any |
| point | { lat, lng } object | object | dict | map[string]any |
A decimal column travels as a JSON string — "19.99" — deliberately, so no binary-float rounding is ever introduced into money. On write you may send either a string or a bare JSON number; both are canonicalised to the same stored form. SUM, AVG, arithmetic, ORDER BY and range comparisons over a decimal column are all exact and numeric, not lexical, and the result comes back in the same canonical string form. A result past the exact-decimal range is an error, never a silently wrong number.
An i64 or u64 travels as a JSON number. JavaScript decodes it to a double, and Go decodes into map[string]any as float64 — so values beyond about 9×10¹⁵ are silently rounded in both. Python is unaffected. If you store identifiers or counters that big, declare the column as str, or decode the response yourself with a big-integer-aware parser.
date, time and timestamp columns are stored as integers but rendered into familiar text on the way out — you read "2026-07-25", not an epoch count. That reformatting applies only to output columns the plan can prove are a straight pass-through of a temporal column, so a computed expression over one still comes back as a number. On write, both spellings are accepted and address the same row.
Limits & gotchas.
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.
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 1fails. Every statement that names a column must name a table. Drivers and connection pools that probe liveness with a bareSELECT 1will get an error — point them at a real table, or use the health endpoint. A constant-only expression such asSELECT 1 + 2 AS threedoes work.- No
NULLS FIRST/NULLS LAST. Missing values sort as JSON null. If null placement matters, add aCASEexpression to the projection and sort on that. ORDER BYtakes bare column names. NotORDER BY LOWER(name), notORDER BY a + b. Project the expression with an alias and order by that alias. A projection position works too, except overSELECT *.- No derived tables.
FROM (SELECT …) tis refused — use aWITHclause, which is supported. NOT BETWEENis refused. Writecol < a OR col > b— the error message on this one contains stale advice claimingORis 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.
OFFSETwithoutLIMITdisables limit pushdown into an index range scan. Pair them when you can.- Inside a session transaction, statements do not see the buffer. A
SELECTafter a bufferedINSERTwill not find the new row. See transactions.
Related.
- Row CRUD — point reads and the bulk-write path, and how its upsert differs from SQL
INSERT. - Transactions — making several statements land together, and what isolation you actually get.
- Schema reference — column types, indexes, foreign keys and CHECK constraints.
- Queries in the dashboard — running the same statements from the console workbench.
- Error reference — the shared error envelope every endpoint returns.