Statements at a glance

Every supported statement, with its transactional behaviour. Keywords are case-insensitive; a single statement per call, with an optional trailing semicolon.

StatementPurposeIn a transaction?
CREATE TABLEDefine a tableAutocommit only — ENGINE_ERR_TXN inside BEGIN
ALTER TABLEAdd/drop/rename a column, rename a tableAutocommit only — ENGINE_ERR_TXN inside BEGIN
DROP TABLERemove a tableAutocommit only — ENGINE_ERR_TXN inside BEGIN
CREATE INDEX … USING hnswBuild a vector indexAutocommit only — see Vector search
DROP INDEXRemove a vector indexAutocommit only
INSERTAdd rowsFully transactional
UPDATEModify rowsFully transactional
DELETERemove rowsFully transactional
SELECTRead rowsReads a snapshot; never blocks writers
BEGIN / COMMIT / ROLLBACKTransaction control

DDL is autocommit-only by design

Schema changes (CREATE TABLE, DROP TABLE) run only in autocommit. Issuing one inside an explicit transaction returns ENGINE_ERR_TXN. Row DML — INSERT, UPDATE, DELETE — is fully transactional and commits durably.

Data definition (DDL)

CREATE TABLE

Define a table with one or more columns and constraints. IF NOT EXISTS makes creation idempotent.

CREATE TABLE notes (
  id      INTEGER PRIMARY KEY AUTOINCREMENT,
  title   TEXT NOT NULL,
  email   TEXT UNIQUE,
  status  TEXT DEFAULT 'draft',
  score   REAL CHECK (score >= 0),
  data    BLOB
);

-- table-level constraints (composite PRIMARY KEY / UNIQUE / CHECK)
CREATE TABLE membership (
  org  INTEGER,
  usr  INTEGER,
  role TEXT DEFAULT 'member',
  PRIMARY KEY (org, usr),
  UNIQUE (usr, role)
);
ConstraintBehaviour
PRIMARY KEY (single or composite)Unique + implicitly NOT NULL; the upsert conflict target
NOT NULL / NULLRejects / allows NULLs
UNIQUE (column or UNIQUE (cols))Enforced on insert/update; multiple NULLs allowed (NULLs are distinct)
DEFAULT exprFills an omitted column or a DEFAULT value cell
CHECK (expr) (column or table)Row rejected when the predicate is false (NULL passes)
AUTOINCREMENT / SERIALFills an omitted integer column from a monotonic per-table counter
REFERENCES / FOREIGN KEYTracked as metadata (reflected to clients); not enforced this phase
  • MAY omit a column's type name — an untyped column defaults to TEXT affinity.
  • MAY write a parenthesised size such as VARCHAR(64) — parsed and ignored; only the storage class matters.

ALTER TABLE

Migrate a table's shape (autocommit only). Adding a column backfills existing rows with its DEFAULT (or NULL).

ALTER TABLE notes ADD COLUMN pinned INTEGER DEFAULT 0;
ALTER TABLE notes DROP COLUMN data;
ALTER TABLE notes RENAME COLUMN title TO heading;
ALTER TABLE notes RENAME TO memos;

DROP TABLE

Remove a table. IF EXISTS suppresses the error when the table is absent.

DROP TABLE notes;
DROP TABLE IF EXISTS notes;

Data manipulation (DML)

INSERT

Insert one or more rows. The column list is optional; when given, values bind to those columns by position. Multiple parenthesised tuples insert several rows in one statement, and the row source may be a query (INSERT … SELECT). A clashing primary key is resolved by an explicit ON CONFLICT action or by SQLite's OR IGNORE / OR REPLACE. RETURNING projects the inserted rows back as a result set.

INSERT INTO notes (id, title, body) VALUES (1, 'first', 'hello');

INSERT INTO notes (id, title) VALUES (2, 'two'), (3, 'three');

INSERT INTO notes VALUES (4, 'four', NULL, 9.5, NULL);

-- copy rows from a query
INSERT INTO archive (id, title) SELECT id, title FROM notes WHERE score IS NULL;

-- upsert: do nothing, or update using the proposed row via `excluded`
INSERT INTO notes (id, title) VALUES (1, 'dup') ON CONFLICT (id) DO NOTHING;
INSERT INTO notes (id, title) VALUES (1, 'new')
  ON CONFLICT (id) DO UPDATE SET title = excluded.title;

-- SQLite resolutions
INSERT OR IGNORE  INTO notes (id, title) VALUES (1, 'skip');
INSERT OR REPLACE INTO notes (id, title) VALUES (1, 'replace whole row');

-- return the inserted rows
INSERT INTO notes (id, title) VALUES (?, ?) RETURNING id, title;

UPDATE

Assign one or more columns, optionally filtered by a WHERE predicate. Without WHERE, every row is updated. The number of rows changed is reported by engine_changes(); RETURNING projects the new row versions.

UPDATE notes SET title = 'renamed', score = score + 1 WHERE id = 1;
UPDATE notes SET body = NULL;
UPDATE notes SET score = score + 1 WHERE id = 1 RETURNING id, score;

DELETE

Remove rows matching an optional WHERE predicate. Without WHERE, every row is deleted. RETURNING projects the deleted rows.

DELETE FROM notes WHERE score IS NULL;
DELETE FROM notes;
DELETE FROM notes WHERE id = 9 RETURNING id, title;

Queries (SELECT)

A query reads under snapshot isolation. The full shape is:

[WITH cte AS (query), ...]
SELECT [DISTINCT] items
  FROM source [JOIN source ON ... | USING (...)] ...
 WHERE predicate
 GROUP BY expr, ...
HAVING predicate
 [UNION | INTERSECT | EXCEPT [ALL] SELECT ...]
 ORDER BY expr [ASC | DESC] [NULLS FIRST | LAST], ...
 LIMIT n OFFSET m

Clauses are optional. SELECT ALL is the default; SELECT DISTINCT de-duplicates output rows.

Sources and joins

A FROM source is a table (optionally aliased t a / t AS a), a derived table (SELECT …) AS a, or a join. Qualified references alias.col resolve against the active sources; an ambiguous or unknown name is a parse-time ENGINE_ERR_SQL. Joins are nested-loop and evaluated over the MVCC snapshot.

JoinMeaning
[INNER] JOIN … ON / USING (cols)Matching pairs only
LEFT JOINAll left rows; unmatched right side is NULL
RIGHT JOIN / FULL JOINSymmetric / both-side null-extension
CROSS JOIN, or comma FROM a, bCartesian product (filter with WHERE)
SELECT a.name, b.title
  FROM authors a JOIN books b ON a.id = b.author_id
 ORDER BY a.name;

SELECT a.name, count(*) AS n
  FROM authors a LEFT JOIN books b ON a.id = b.author_id
 GROUP BY a.name;

Select items

ItemMeaning
* / alias.*All columns (of all sources, or of one source)
exprAny expression — a column, alias.col, literal, function call, or computed value
expr AS aliasName the output column (the AS keyword is optional)
COUNT/SUM/MIN/MAX/AVG, COUNT(DISTINCT x), group_concat/string_agg, JSON_AGGAggregate over * or an expression (whole-table, or per group with GROUP BY)

Aggregates and GROUP BY

Aggregates compute over the filtered rows. Without GROUP BY they fold the whole (filtered) result into one row; with GROUP BY they fold each group, and you may project the grouped expressions alongside the aggregates. HAVING filters whole groups. COUNT(DISTINCT x) counts distinct non-NULL values; group_concat(x[, sep]) / string_agg(x, sep) join a group's values. Aggregates nest inside scalar expressions, so shapes like coalesce(json_agg(title), '[]') work.

CTEs, subqueries and set operations

A leading WITH name AS (query), … defines non-recursive common table expressions usable as FROM sources. A parenthesized subquery may appear as a derived table in FROM, as a scalar value (SELECT …), or in x IN (SELECT …) / EXISTS (SELECT …) — these expression subqueries must be non-correlated (they may not reference the outer query's columns). UNION/INTERSECT/EXCEPT (with optional ALL) combine two result sets of matching arity; a trailing ORDER BY/LIMIT applies to the combined result and references output columns.

WITH counts AS (SELECT author_id, count(*) AS n FROM books GROUP BY author_id)
SELECT a.name, c.n FROM authors a JOIN counts c ON a.id = c.author_id;

SELECT name FROM authors WHERE id IN (SELECT author_id FROM books);

SELECT n FROM a UNION SELECT n FROM b ORDER BY n;

ORDER BY, LIMIT and OFFSET

  • MAY sort by one or more expressions, each independently ASC (default) or DESC, with optional NULLS FIRST / NULLS LAST; an ORDER BY key may name a select-list alias.
  • MUST give LIMIT / OFFSET an integer literal or a parameter (LIMIT ALL means no limit) — a non-integer is a parse error.

Expressions

Expressions appear in WHERE, ORDER BY, select items, UPDATE assignments, and INSERT values. The operand forms are:

FormExamples
Integer literal42, -7
Real literal3.5, 1e3, 2.0
String literal'hello' (use '' for a literal quote)
NULLNULL
Boolean keywordTRUE1, FALSE0
Column referencetitle, notes.title / alias.title (qualifier resolves against the active sources)
Parameter? positional, $1 numbered, or :name named (bound 1-based)
Parenthesised(score + 1) * 2
Function calllower(title), coalesce(x, 0)
Type castx::int, '42'::integer, CAST(x AS int), count(*)::int
CASECASE WHEN c > 0 THEN 'pos' ELSE 'neg' END, simple CASE x WHEN 1 THEN … END
Membership / rangex IN (1, 2, 3), x NOT IN (…), x BETWEEN 1 AND 10

Functions

A focused set of standard scalar functions, plus the JSON builders client tooling (PostgREST, Bun.sql) leans on. An unknown function is a parse-time ENGINE_ERR_SQL, never a silent NULL.

GroupFunctions
Null / conditionalcoalesce(...), nullif(a, b), ifnull(a, b), iif(c, a, b), greatest(...), least(...)
Stringlower, upper, trim/btrim/ltrim/rtrim, length/char_length, concat, substr/substring, replace, instr/strpos, repeat, reverse, left/right, lpad/rpad
Numericabs, round(x[, n]), ceil/floor, sqrt, power/pow, mod, sign, trunc, exp, ln, log, pi
Date / timenow/current_timestamp, current_date, current_time, date, time, datetime, date_trunc(unit, ts), extract(field FROM ts), strftime(fmt, ts), unixepoch
UUID / miscgen_random_uuid, random, typeof, hex
JSON->, ->>, json_extract(json, '$.path'), json_array, to_json, json_build_object(k, v, ...), and the json_agg aggregate

JSON and time are text

The engine has no native json or temporal type. JSON functions parse and produce JSON-encoded text (the ->/->> accessors navigate it); timestamps are ISO-8601 Text or epoch Integer, all UTC. Non-deterministic functions (now(), random(), gen_random_uuid()) are evaluated once at execution, so the concrete value stored is exactly what replay reproduces.

Operators

Listed from lowest to highest binding precedence.

CategoryOperatorsNotes
Logical ORORThree-valued logic (a NULL operand yields unknown)
Logical ANDAND
Logical NOTNOTPrefix
Comparison=, <>, !=, <, <=, >, >=<> and != are both inequality
Null testIS NULL, IS NOT NULL
Membership / rangeIN (…), NOT IN (…), BETWEEN … AND …, NOT BETWEEN …Three-valued logic over a value list / range
PatternLIKE, NOT LIKE, ILIKE, … ESCAPE cLIKE is case-sensitive, ILIKE folds ASCII case; ESCAPE sets the escape char
Vector distance<->, <=>, <#>L2 / cosine / inner-product distance — see Vector search
Concatenation||String concat (NULL-propagating); binds looser than +
Additive+, -
Multiplicative*, /, %Multiply, divide, modulo
Unary-, +Negation / identity prefix
SELECT * FROM notes
 WHERE (score > 5 OR title LIKE 'a%')
   AND body IS NOT NULL
 ORDER BY score DESC;

NULL comparisons are unknown

Comparing anything to NULL with = / <> yields unknown, not true or false — a row whose predicate is unknown is not returned. Use IS NULL / IS NOT NULL to test for null explicitly.

Types and storage classes

Every value belongs to one of six storage classes. A declared column type maps to a storage class by affinity (the same keyword rules SQLite uses), and values are lightly coerced toward the column's class where lossless.

Storage classHoldsDeclared types that map to it
NULLThe absence of a value
INTEGER64-bit signed integerAny type name containing INT
REAL64-bit floating pointREAL, FLOA…, DOUB…, NUMERIC, DEC…
TEXTUTF-8 stringCHAR…, TEXT, CLOB, STRING; also the default for an unknown or omitted type
BLOBRaw bytesBLOB, BYTEA
VECTORFixed-length f32 arrayVECTOR(N) — dimension N required and validated on insert
  • MAY rely on light affinity: an INTEGER inserted into a REAL column becomes a real; a whole-number REAL in an INTEGER column becomes an integer.
  • MUST read all values back as text across the C ABI — a BLOB renders as standard padded base64, and a SQL NULL comes back as a null pointer.

Parameters

Bind placeholders by 1-based index through the prepared-statement path. Three placeholder spellings are accepted: positional ? (SQLite), numbered $1 (PostgreSQL), and named :name (SQLite). A repeated $1 reuses one slot; distinct :names are assigned sequential slots in first-seen order.

INSERT INTO notes (id, title) VALUES (?, ?);
SELECT * FROM notes WHERE id = $1 OR title ILIKE $2;
SELECT * FROM notes WHERE author = :who;

Each bound value is a NUL-terminated typed literal: a one-character tag followed by the encoded value. This is how the string-only ABI carries a typed argument.

TagTypeExample
iIntegeri42
fFloatf3.5
sTextshello
bBytes (base64)b<base64>
nNULLn
vVectorv[1,2,3] or v1,2,3

The wrapper does the tagging for you

The @twilldb/bun client encodes these typed literals from native JS/TS values, so you bind ordinary numbers, strings, and Uint8Array. The raw tags matter only when calling the C ABI directly. See Connect from Bun.

Transactions

Row DML is fully transactional under snapshot isolation. A single writer serializes through a write lane; readers capture a snapshot LSN and never block.

StatementEffect
BEGINStart an explicit transaction (BEGIN TRANSACTION / START TRANSACTION also accepted)
COMMITPublish the transaction — blocks until the WAL is durable
ROLLBACKDiscard the transaction's pending changes
SAVEPOINT / RELEASE / ROLLBACK TOMark a point, release it, or undo changes back to it within a transaction
BEGIN;
INSERT INTO notes (id, title) VALUES (10, 'draft');
UPDATE notes SET score = 1 WHERE id = 10;
COMMIT;   -- returns only once the records are durable on the backend
  • MUST keep schema changes out of an explicit transaction — DDL inside BEGIN returns ENGINE_ERR_TXN.
  • SHOULD retry a transaction that fails with ENGINE_ERR_CONFLICT — a first-committer-wins check rejects conflicting concurrent writers.

Dialect & session compatibility

To let real PostgreSQL/SQLite clients and ORMs connect without surprises, the engine accepts a few dialect spellings and the session statements clients emit on connect.

  • MAY quote identifiers with double quotes "x" or backticks `x`. (Square brackets denote vector literals here, so [x] is not an identifier quote.)
  • MAY issue SET, SET TRANSACTION ISOLATION LEVEL …, PRAGMA, VACUUM, ANALYZE, RESET, DISCARD — accepted as no-ops (the engine runs one snapshot-isolation mode).
  • MAY issue SHOW name (returns a one-row result) and EXPLAIN [ANALYZE] … (returns a one-line plan; the engine has one strategy per shape).

Out of scope

These are deliberate boundaries, not bugs. Each parses to a clear ENGINE_ERR_SQL rather than a wrong result.

  • MUST NOT use correlated subqueries (an expression subquery referencing the outer query's columns); only non-correlated scalar/IN/EXISTS subqueries are supported.
  • MUST NOT use OLAP-shaped features — window functions (OVER), GROUPING SETS/ROLLUP/CUBE, LATERAL, or recursive CTEs (compose DuckDB).
  • MUST NOT use views or stored secondary/B-tree indexes. The only CREATE INDEX supported is the HNSW vector index (USING hnsw); a scalar UNIQUE is enforced by full scan, not a stored index.
  • MUST NOT rely on procedural/extensibility surface — CREATE FUNCTION/TRIGGER/EXTENSION, roles/GRANT, LISTEN/NOTIFY.

Unsupported syntax fails fast

Anything outside the surface on this page is rejected at parse time with ENGINE_ERR_SQL — the engine never silently mis-parses or partially applies an unsupported statement.

Related

Twill DB documentation · Licensed under BUSL-1.1. · Author