SQL reference
Twill DB speaks a deliberately small, hand-parsed SQL subset: DDL, row DML, single-table queries with aggregates, 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 |
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. Each column may carry a type name and the constraints PRIMARY KEY and/or NOT NULL. IF NOT EXISTS makes creation idempotent.
CREATE TABLE notes (
id INTEGER PRIMARY KEY,
title TEXT NOT NULL,
body TEXT,
score REAL,
data BLOB
);
CREATE TABLE IF NOT EXISTS notes (id INTEGER PRIMARY KEY, title TEXT);
- MUST declare at least one column; an empty column list is a parse error.
- MAY omit a column's type name — an untyped column defaults to
TEXTaffinity. - MAY write a parenthesised size such as
VARCHAR(64)orDECIMAL(10,2)— the size is parsed and ignored; only the storage class matters. - MAY mark a column
UNIQUEor explicitlyNULL— these are accepted as constraint markers (uniqueness is enforced for the primary key).
PRIMARY KEY implies NOT NULL
Declaring a column PRIMARY KEY also makes it NOT NULL.
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. OR REPLACE / OR IGNORE are accepted after INSERT and ignored.
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);
-- with parameters
INSERT INTO notes (id, title) VALUES (?, ?);
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().
UPDATE notes SET title = 'renamed', score = score + 1 WHERE id = 1;
UPDATE notes SET body = NULL;
DELETE
Remove rows matching an optional WHERE predicate. Without WHERE, every row is deleted.
DELETE FROM notes WHERE score IS NULL;
DELETE FROM notes;
Queries (SELECT)
A query reads from a single table (or no table, for bare expressions) under snapshot isolation. The full shape is:
SELECT items
FROM table
WHERE predicate
ORDER BY expr [ASC | DESC], ...
LIMIT n
Clauses are optional and parsed in this order. SELECT ALL is accepted (it is the default); SELECT DISTINCT is rejected.
Select items
| Item | Meaning |
|---|---|
* | All columns of the table, in declared order |
expr | Any expression — a column, literal, or computed value |
expr AS alias | Name the output column (the AS keyword is optional) |
COUNT / SUM / MIN / MAX / AVG | Whole-table aggregate over * or an expression |
SELECT * FROM notes;
SELECT id, title AS heading FROM notes WHERE score >= 5 ORDER BY score DESC LIMIT 10;
SELECT COUNT(*), AVG(score), MAX(score) FROM notes;
SELECT title, score * 2 AS doubled FROM notes ORDER BY id;
Aggregates are whole-table only
COUNT, SUM, MIN, MAX, and AVG compute over the entire (filtered) table. There is no GROUP BY, so you cannot mix a bare aggregate with per-row columns to mean grouped output.
ORDER BY and LIMIT
- MAY sort by one or more expressions, each independently
ASC(default) orDESC. - MUST give
LIMITan integer literal — a non-integer (including a parameter) 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 (table qualifier accepted, only the column part is used) |
| Parameter | ? (positional, bound 1-based) |
| Parenthesised | (score + 1) * 2 |
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 | |
| Pattern | LIKE, NOT LIKE | Pattern match against a string expression |
| Vector distance | <->, <=>, <#> | L2 / cosine / inner-product distance — see Vector search |
| 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.
Parameters
Use a positional ? placeholder anywhere an expression is allowed, then bind by 1-based index. Binding goes through the prepared-statement path of the C ABI.
INSERT INTO notes (id, title) VALUES (?, ?);
SELECT * FROM notes WHERE id = ? OR title LIKE ?;
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 |
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.
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
JOIN— queries read from a single table. - MUST NOT use
GROUP BY/HAVING— aggregates are whole-table only. - MUST NOT use subqueries.
- MUST NOT use
SELECT DISTINCT. - MUST NOT use views or
ALTER TABLE. The onlyCREATE INDEXsupported is the HNSW vector index (USING hnsw); general secondary/B-tree indexes are not exposed as SQL.
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.