OriginChain docs
query shapes · sql

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.

1

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.

2

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 orderSELECT 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.

3

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"
  }'

# 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.

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.

4

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]
  }'
  • 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.

5

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"
  }'

# 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.
  • 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.

6

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 requires the SQL Pro add-on - see section 9.
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 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.

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.

7

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]
  }'

# 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.status
UPDATE and DELETE
# 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 }

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.
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(...).

8

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'\''"}'

# { "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.

9

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.

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.

10

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
decimal is a string, and its arithmetic is exact

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.

large integers lose precision in TypeScript and Go

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.

dates and times come back as text

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.

11

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.

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.