SQL reference
Twill DB speaks a focused, hand-parsed SQL surface covering the common OLTP majority of PostgreSQL and SQLite: DDL, row DML (with upsert and RETURNING), multi-table queries with joins, aggregation, subqueries and set operations, a curated function library, and transaction control. Everything on this page is what the parser actually accepts — anything outside it is rejected with ENGINE_ERR_SQL rather than silently mis-parsed.
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.
| Statement | Purpose | In a transaction? |
|---|---|---|
CREATE TABLE | Define a table | Autocommit only — ENGINE_ERR_TXN inside BEGIN |
ALTER TABLE | Add/drop/rename a column, rename a table | Autocommit only — ENGINE_ERR_TXN inside BEGIN |
DROP TABLE | Remove a table | Autocommit only — ENGINE_ERR_TXN inside BEGIN |
CREATE INDEX … USING hnsw | Build a vector index | Autocommit only — see Vector search |
DROP INDEX | Remove a vector index | Autocommit only |
INSERT | Add rows | Fully transactional |
UPDATE | Modify rows | Fully transactional |
DELETE | Remove rows | Fully transactional |
SELECT | Read rows | Reads a snapshot; never blocks writers |
BEGIN / COMMIT / ROLLBACK | Transaction 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)
);
| Constraint | Behaviour |
|---|---|
PRIMARY KEY (single or composite) | Unique + implicitly NOT NULL; the upsert conflict target |
NOT NULL / NULL | Rejects / allows NULLs |
UNIQUE (column or UNIQUE (cols)) | Enforced on insert/update; multiple NULLs allowed (NULLs are distinct) |
DEFAULT expr | Fills an omitted column or a DEFAULT value cell |
CHECK (expr) (column or table) | Row rejected when the predicate is false (NULL passes) |
AUTOINCREMENT / SERIAL | Fills an omitted integer column from a monotonic per-table counter |
REFERENCES / FOREIGN KEY | Tracked as metadata (reflected to clients); not enforced this phase |
- MAY omit a column's type name — an untyped column defaults to
TEXTaffinity. - 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.
| Join | Meaning |
|---|---|
[INNER] JOIN … ON / USING (cols) | Matching pairs only |
LEFT JOIN | All left rows; unmatched right side is NULL |
RIGHT JOIN / FULL JOIN | Symmetric / both-side null-extension |
CROSS JOIN, or comma FROM a, b | Cartesian 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
| Item | Meaning |
|---|---|
* / alias.* | All columns (of all sources, or of one source) |
expr | Any expression — a column, alias.col, literal, function call, or computed value |
expr AS alias | Name the output column (the AS keyword is optional) |
COUNT/SUM/MIN/MAX/AVG, COUNT(DISTINCT x), group_concat/string_agg, JSON_AGG | Aggregate 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) orDESC, with optionalNULLS FIRST/NULLS LAST; anORDER BYkey may name a select-list alias. - MUST give
LIMIT/OFFSETan integer literal or a parameter (LIMIT ALLmeans 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:
| Form | Examples |
|---|---|
| Integer literal | 42, -7 |
| Real literal | 3.5, 1e3, 2.0 |
| String literal | 'hello' (use '' for a literal quote) |
NULL | NULL |
| Boolean keyword | TRUE → 1, FALSE → 0 |
| Column reference | title, 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 call | lower(title), coalesce(x, 0) |
| Type cast | x::int, '42'::integer, CAST(x AS int), count(*)::int |
CASE | CASE WHEN c > 0 THEN 'pos' ELSE 'neg' END, simple CASE x WHEN 1 THEN … END |
| Membership / range | x 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.
| Group | Functions |
|---|---|
| Null / conditional | coalesce(...), nullif(a, b), ifnull(a, b), iif(c, a, b), greatest(...), least(...) |
| String | lower, upper, trim/btrim/ltrim/rtrim, length/char_length, concat, substr/substring, replace, instr/strpos, repeat, reverse, left/right, lpad/rpad |
| Numeric | abs, round(x[, n]), ceil/floor, sqrt, power/pow, mod, sign, trunc, exp, ln, log, pi |
| Date / time | now/current_timestamp, current_date, current_time, date, time, datetime, date_trunc(unit, ts), extract(field FROM ts), strftime(fmt, ts), unixepoch |
| UUID / misc | gen_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.
| Category | Operators | Notes |
|---|---|---|
| Logical OR | OR | Three-valued logic (a NULL operand yields unknown) |
| Logical AND | AND | |
| Logical NOT | NOT | Prefix |
| Comparison | =, <>, !=, <, <=, >, >= | <> and != are both inequality |
| Null test | IS NULL, IS NOT NULL | |
| Membership / range | IN (…), NOT IN (…), BETWEEN … AND …, NOT BETWEEN … | Three-valued logic over a value list / range |
| Pattern | LIKE, NOT LIKE, ILIKE, … ESCAPE c | LIKE 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 class | Holds | Declared types that map to it |
|---|---|---|
NULL | The absence of a value | — |
INTEGER | 64-bit signed integer | Any type name containing INT |
REAL | 64-bit floating point | REAL, FLOA…, DOUB…, NUMERIC, DEC… |
TEXT | UTF-8 string | CHAR…, TEXT, CLOB, STRING; also the default for an unknown or omitted type |
BLOB | Raw bytes | BLOB, BYTEA |
VECTOR | Fixed-length f32 array | VECTOR(N) — dimension N required and validated on insert |
- MAY rely on light affinity: an
INTEGERinserted into aREALcolumn becomes a real; a whole-numberREALin anINTEGERcolumn becomes an integer. - MUST read all values back as text across the C ABI — a
BLOBrenders as standard padded base64, and a SQLNULLcomes back as a null pointer.
Vector search
Vector search is built into the engine. A VECTOR(N) column stores a fixed-length array of f32, an HNSW index answers top-k nearest-neighbour queries, and three distance operators are available in projections, WHERE, and ORDER BY. Because the index rides the same WAL/replay path as the rows, it branches and scales-to-zero with the database.
The vector type and literals
Declare the dimension at column time; it is validated on insert. Vector literals are written [1, 2, 3] (or the pgvector-style text form '[1,2,3]').
CREATE TABLE memories (
id INTEGER PRIMARY KEY,
note TEXT,
embedding VECTOR(3)
);
INSERT INTO memories VALUES
(1, 'apples', [1, 0, 0]),
(2, 'oranges', [0, 1, 0]);
HNSW index
Create an HNSW access method over a vector column. Like CREATE TABLE, index DDL runs in autocommit only. WITH options tune the graph and the search.
CREATE INDEX mem_e ON memories USING hnsw (embedding)
WITH (metric = 'cosine'); -- 'cosine' | 'l2' | 'inner_product'
-- also: m, ef_construction, ef_search
DROP INDEX IF EXISTS mem_e;
Distance operators & nearest-neighbour queries
The three operators compute a distance between two vectors: <-> (L2), <=> (cosine), and <#> (inner product). Pass the query vector as a parameter and order by distance:
SELECT note
FROM memories
ORDER BY embedding <=> ? -- bind the query vector, e.g. [0.9, 0.1, 0]
LIMIT 1; -- → 'apples'
Index or brute force — same answers
An ORDER BY <col> <dist-op> <query> ASC LIMIT k query is answered by the HNSW index when one exists, and falls back to a brute-force scan-and-sort when it does not — the results match. HNSW is approximate by design (recall governed by ef_search); results are MVCC-filtered against your snapshot and any WHERE clause.
Tuning recall per session
ef_search (the HNSW search width) is fixed when the index is created but can be overridden per connection at runtime. Raise it for more recall, lower it for less latency:
SET twill.vector_ef_search = 128; -- widen ANN search for this connection
SHOW twill.vector_ef_search; -- inspect the current value
RESET twill.vector_ef_search; -- restore each index's configured default
The override is per-session state only — it changes recall/latency for your queries, never the stored index — so it is safe to set differently on different connections. See the measured recall/latency curve in the capabilities spec. Heavily-churned indexes (many deletes) are compacted automatically; queries stay correct throughout with no VACUUM step.
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.
| Tag | Type | Example |
|---|---|---|
i | Integer | i42 |
f | Float | f3.5 |
s | Text | shello |
b | Bytes (base64) | b<base64> |
n | NULL | n |
v | Vector | v[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.
| Statement | Effect |
|---|---|
BEGIN | Start an explicit transaction (BEGIN TRANSACTION / START TRANSACTION also accepted) |
COMMIT | Publish the transaction — blocks until the WAL is durable |
ROLLBACK | Discard the transaction's pending changes |
SAVEPOINT / RELEASE / ROLLBACK TO | Mark 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
BEGINreturnsENGINE_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) andEXPLAIN [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/EXISTSsubqueries 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 INDEXsupported is the HNSW vector index (USING hnsw); a scalarUNIQUEis 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.