Skip to content

Query

POST /v1/databases/{id}/query

One statement per request. For several, use batch — which is also how you get atomicity.

{
"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.

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.

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.

Because duplicate column names are legal SQL:

SELECT * FROM users JOIN orders ON orders.user_id = users.id

Both 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.

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.

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-requestsql 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.