Skip to content

SQL support

This is real SQLite — the same library, the same query planner, the same documentation. If sqlite.org says a construct works, it works here, with the specific exceptions below.

Everything you would expect, including the parts people assume a hosted SQL API drops:

  • DDLCREATE TABLE, ALTER TABLE, indexes, views, triggers, STRICT tables, WITHOUT ROWID.
  • Queries — joins, subqueries, CTEs (WITH, including WITH RECURSIVE), window functions, GROUP BY, HAVING, set operations.
  • WritesINSERT, UPDATE, DELETE, UPSERT (ON CONFLICT), RETURNING, INSERT … SELECT.
  • Types and functions — the full built-in function set, JSON functions, generate_series, full-text search (FTS5), and R-Tree.
  • Constraints — primary keys, unique, check, and foreign keys, which are enabled. A violated reference is an error, not a slowly rotting row.
  • Introspectionsqlite_schema (and its old name sqlite_master), EXPLAIN, EXPLAIN QUERY PLAN, and the introspection PRAGMAs listed below.

EXPLAIN QUERY PLAN is worth knowing about here specifically: a missing index costs you a full scan on every request, and there is no slow-query log yet to tell you about it.

Each of these is a 400 sql-denied whose detail names the construct. None of them is an oversight — they either escape the single-file model or reconfigure the engine the durability design depends on.

Refused Why
ATTACH, DETACH Would open a second database file, escaping the isolation boundary.
VACUUM, VACUUM INTO Rewrites the whole file; VACUUM INTO writes outside it.
PRAGMA <name> = <value> The setting form. journal_mode, synchronous, and friends are ours to own — the durability guarantees are stated in terms of them.
load_extension() Arbitrary native code. Compiled out, disabled in the binding, and refused here — three layers, deliberately.
readfile(), writefile(), fsdir(), edit() Filesystem access from SQL.
Two or more statements in one sql See below.
BEGIN, COMMIT, ROLLBACK, SAVEPOINT, RELEASE See below.

The reading form only, and only these fourteen — enough for an ORM’s migration bootstrap to diff a schema, which is what a blanket denial would break:

collation_list foreign_key_check foreign_key_list function_list
index_info index_list index_xinfo page_count
page_size pragma_list table_info table_list
table_xinfo user_version
{ "sql": "PRAGMA table_info('users')" }

PRAGMA database_list is absent deliberately: it would disclose the local file path. PRAGMA user_version can be read but not assigned, so if you are using it as a migration counter, keep the counter in a table of your own instead.

{ "sql": "INSERT INTO t VALUES (1); INSERT INTO t VALUES (2)" }
{ "status": 400, "detail": "send one statement per request; use a batch for several" }

This is a refusal, not a parser limitation. Accepting ;-separated statements is what turns a SQL-injection bug into a second statement — and if you genuinely want several statements you should be saying so explicitly and getting atomicity for it. Use batch.

BEGIN, COMMIT, ROLLBACK, SAVEPOINT, and RELEASE are not available. A transaction held open across HTTP requests would mean one client’s uncommitted transaction blocking the single writer for every other client — for as long as that client felt like taking, including forever if it crashed.

A batch is the transaction. It is atomic, durable as a unit, and holds the writer only for as long as the statements actually take. For the read-decide-write pattern, use a conditional update and check changes():

{
"statements": [
{ "sql": "UPDATE seats SET taken = 1 WHERE id = ? AND taken = 0", "params": [12] },
{ "sql": "SELECT changes() AS claimed" }
]
}

The scope a statement needs is derived from the SQL itself. SELECT, WITH, EXPLAIN, and VALUES with no write keyword anywhere are reads and need db:read; everything else needs db:write.

When the classifier cannot tell, it says write. So an exotic statement may demand db:write even though it only reads. That is the safe direction — the other default would let a read-only key mutate data — and if it blocks something legitimate, tell us.

A statement that is valid SQL but wrong about your data or schema comes back as 400 sql-error with SQLite’s own message:

{ "type": "https://errors.sqlited.dev/sql-error", "status": 400, "detail": "no such table: orders" }
{
"type": "https://errors.sqlited.dev/sql-error",
"status": 400,
"detail": "UNIQUE constraint failed: users.email"
}

These messages are built from your statement and your schema and never contain a bound parameter value, so they are safe to log and safe to show a developer. Note the distinction from sql-denied: sql-error means SQLite refused it, sql-denied means we did.

Limit Value
SQL text per statement 1 MiB
Statements per /query 1
Statements per /batch 100
Request body 8 MiB

A long-running statement blocks the worker. There is no statement timeout and no way to cancel a query in flight, because the SQLite binding in use cannot interrupt one. A cartesian join over two large tables will occupy the worker until it finishes.

Today a worker serves one tenant, so the only person you can hurt is yourself — but keep it in mind, test your queries with EXPLAIN QUERY PLAN, and put a LIMIT on anything exploratory.