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.
What works
Section titled “What works”Everything you would expect, including the parts people assume a hosted SQL API drops:
- DDL —
CREATE TABLE,ALTER TABLE, indexes, views, triggers,STRICTtables,WITHOUT ROWID. - Queries — joins, subqueries, CTEs (
WITH, includingWITH RECURSIVE), window functions,GROUP BY,HAVING, set operations. - Writes —
INSERT,UPDATE,DELETE,UPSERT(ON CONFLICT),RETURNING,INSERT … SELECT. - Types and functions — the full built-in function set,
JSONfunctions,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.
- Introspection —
sqlite_schema(and its old namesqlite_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.
What is refused
Section titled “What is refused”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. |
Allowed PRAGMAs
Section titled “Allowed PRAGMAs”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_listindex_info index_list index_xinfo page_countpage_size pragma_list table_info table_listtable_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.
Multiple statements
Section titled “Multiple statements”{ "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.
Explicit transactions
Section titled “Explicit transactions”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" } ]}Statement classification and scopes
Section titled “Statement classification and scopes”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.
Errors from SQLite itself
Section titled “Errors from SQLite itself”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.
Limits
Section titled “Limits”| Limit | Value |
|---|---|
| SQL text per statement | 1 MiB |
Statements per /query |
1 |
Statements per /batch |
100 |
| Request body | 8 MiB |
One performance caveat
Section titled “One performance caveat”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.