Query
POST /v1/databases/{id}/queryOne statement per request. For several, use batch — which is also how you get atomicity.
Request
Section titled “Request”{ "sql": "SELECT id, email FROM users WHERE created_at > ? ORDER BY id LIMIT ?", "params": ["2026-01-01", 50]}| Field | Type | |
|---|---|---|
sql |
string, required | Exactly one statement. Up to 1 MiB. |
params |
array or object | Optional. Array for ?, object for :name. |
Two or more statements in one sql string is a 400 sql-denied:
{ "type": "https://errors.sqlited.dev/sql-denied", "status": 400, "detail": "send one statement per request; use a batch for several"}That is a deliberate refusal rather than a parser limitation. Accepting ;-separated statements is what
makes a SQL-injection bug into a second statement, and if you want several statements you should be
saying so explicitly and getting atomicity for it.
Parameters
Section titled “Parameters”Always bind values. Never interpolate them into the SQL. Beyond the injection risk, an interpolated literal makes every call a distinct statement to SQLite’s planner.
Positional, with ?:
{ "sql": "INSERT INTO users (email, active) VALUES (?, ?)", "params": ["a@example.com", true] }Named, with :name — pass an object, and omit the colon from the keys:
{ "sql": "INSERT INTO users (email, active) VALUES (:email, :active)", "params": { "email": "a@example.com", "active": true }}JSON types map to SQLite as you would expect, with two extensions for what JSON cannot hold. Booleans
become 1 and 0, integers beyond 2^53 use {"$int": "…"}, and bytes use {"$blob": "…"}. The details
are on Value encoding — read it before you store binary data or 64-bit ids.
Response
Section titled “Response”A read:
{ "columns": ["id", "email"], "rows": [ [1, "a@example.com"], [2, "b@example.com"] ], "rowsAffected": 0, "lastInsertRowid": null, "epoch": 3, "txid": null, "durability": "read", "waitedMs": 0}A write:
{ "columns": [], "rows": [], "rowsAffected": 1, "lastInsertRowid": 42, "epoch": 3, "txid": 412, "durability": "durable", "waitedMs": 118}| Field | |
|---|---|
columns |
Column names in declaration order. Empty for a statement returning no rows. |
rows |
Row-major arrays, positionally matching columns. |
rowsAffected |
Rows inserted, updated, or deleted. 0 for a read. |
lastInsertRowid |
The rowid of the last insert, or null for a statement that inserted nothing. May be {"$int": …}. |
epoch |
The ownership generation that served this request. |
txid |
The durable high-water mark, or null for a read. |
durability |
"durable" or "read". See Durability. |
waitedMs |
Time spent waiting on object storage. Around 0 for reads, around 120 for writes. |
Why rows are arrays
Section titled “Why rows are arrays”Because duplicate column names are legal SQL:
SELECT * FROM users JOIN orders ON orders.user_id = users.idBoth tables have an id, so columns is ["id", "email", "id", "user_id", …]. An object keyed by name
could only hold one of them, and the one it dropped would disappear with no error anywhere — a wrong
answer rather than a missing feature. Arrays keep both.
If you want objects, zip them yourself and accept that duplicates collapse:
const objects = body.rows.map((row) => Object.fromEntries(body.columns.map((c, i) => [c, row[i]])));Every row is exactly as wide as columns, so indexing by position needs no bounds check.
RETURNING works
Section titled “RETURNING works”A write that returns rows gives you both, in one round trip and one durable commit:
{ "sql": "INSERT INTO users (email) VALUES (?) RETURNING id, created_at", "params": ["a@example.com"]}{ "columns": ["id", "created_at"], "rows": [[42, "2026-08-16T09:14:02Z"]], "rowsAffected": 1, "lastInsertRowid": 42, "durability": "durable", "txid": 413, "epoch": 3, "waitedMs": 121}Prefer this over lastInsertRowid when you need more than the rowid, and over a follow-up SELECT
always — the follow-up is a second request and, under a concurrent writer, a different point in time.
Errors
Section titled “Errors”| Status | |
|---|---|
400 |
sql-error — SQLite refused the statement. detail is SQLite’s message: no such table: orders. |
400 |
sql-denied — valid SQLite this platform does not allow. See SQL support. |
400 |
bad-request — sql missing or not a string, params not an array or object, unparseable JSON. |
403 |
forbidden — the key lacks db:read (for a read) or db:write (for a write). |
404 |
not-found — no such database, or not yours. |
413 |
too-large — body over 8 MiB. |
503 |
Retryable. durability-uncertain is the one to handle specially — see Errors. |
A constraint violation is a 400 sql-error, not a 409. It is your statement being wrong about the
data, and repeating it unchanged will fail identically:
{ "type": "https://errors.sqlited.dev/sql-error", "status": 400, "detail": "UNIQUE constraint failed: users.email"}detail names the constraint and the column but never the value you bound, so it is safe to log.