this.ctx.storage.sql. It sits beside the key/value store
under the same this.ctx.storage, but it is a separate engine: one SQLite database per
actor instance, queried with real SQL.
Reach for it when a single entity’s state is relational or needs queries the key/value
store can’t answer without a scan — aggregates, GROUP BY, secondary indexes, joins
within the one actor. It is not a shared database: each instance sees only its own data,
and there are no cross-actor queries or joins.
The database is private, strongly consistent, and transactional: one actor instance is
the only reader and writer of its database, its methods run one at a time
(serialized turns), and a
write that returns successfully is durable at turn granularity — it survives the actor being evicted, relocated,
or its pod replaced. Reads always reflect your prior writes.
Stateful Actors are in Beta.
ctx.storage.sql requires @telnyx/edge-runtime 0.6.0 or
later; the transactionSync() type ships in 0.8.0, so use the latest SDK.The Surface
exec is synchronous — it does not return a Promise. This is deliberate and unlike
every other member of ctx.storage: the database is a local file in the actor’s own
process, so there is no network hop to await.
ctx.storage.sql gets a
database, created on first use. Create your tables with CREATE TABLE IF NOT EXISTS (a
cheap no-op once they exist) at the top of the methods that need them.
Binding Parameters
Use positional? placeholders and pass the values as trailing arguments. Always bind
user input — never build SQL by string concatenation. (Binding here is SQL’s own term for
attaching values to placeholders — no relation to the telnyx.toml bindings your function
receives on env.)
null, number, string, or ArrayBuffer
(for binary). Result columns come back as those same four types:
Reading Results: the Cursor
exec returns a SqlCursor — a single-pass iterator over the result rows. Drain it
once, either by iterating or with toArray():
toArray() on that same cursor returns []; calling toArray() a second time
also returns []. If you iterate partway and then call toArray(), you get the rest of
the rows. Run exec again for a fresh pass.
exec is generic in the row type — exec<Order>(...) types the returned rows — but the
type is a compile-time assertion only, not validated at runtime, so make sure your query
returns those columns.
Multiple Statements in One exec
A query string may contain several ;-separated statements — useful for pasting a schema
or a small migration. Every statement executes, in order; the bound parameters apply to
the last statement, and the returned cursor is over the last statement’s rows:
exec throws, but the statements before it
have already been applied. Wrap the batch in a transactionSync callback
when it must be all-or-nothing.
Multi-statement splitting is a best-effort TypeScript tokenizer, not exact SQLite
parsing. It handles all realistic SQL, but unquoted keyword-identifiers (a column named
end, begin, or case in a deep position) can confuse the splitter. Quote
reserved-word identifiers ("end") to avoid edge cases. Exact parity is tracked in
COMPUTE-470.Transactions
Wrap related writes so they commit together — or roll back together — withtransactionSync. It runs your callback synchronously, commits when it returns, and rolls
back every write if it throws:
transactionSync passes it through, so you can read
back a result computed inside the transaction.
Two rules, both enforced by the runtime:
transactionSyncis the only transaction API. Raw transaction-control statements —BEGIN,COMMIT,ROLLBACK,SAVEPOINT— are rejected byexec()withSqlTransactionControlError, anywhere in a statement batch. (An open transaction left dangling across method calls would have no owner; the callback shape makes that impossible.)- The callback must be synchronous. Passing an
asyncfunction throwsSqlAsyncTransactionErrorand commits nothing — the transaction commits when the callback returns, so statements after anawaitwould run outside it. Do async work before or after, not inside.
Bulk Writes
Each standaloneexec write commits and flushes to disk on its own, which is slow in a
loop. Wrap a batch in one transactionSync and it commits (and flushes) once:
Indexes
The database holds one entity’s working set, but scans still cost time and — as it grows — memory. Add an index on any column you filter or order by frequently:Limits
- 1 GB per actor database. Enforced by the engine — writes past it fail.
- 2 MiB per bound value. A bind larger than 2 MiB (2,097,152 bytes) is rejected
with
SqlParameterTooLargeErrorbefore the statement runs. There is no cap on output row size. - Up to 32,766 bound parameters in a single statement (SQLite’s variable limit).
- Integers come back as JavaScript
number. SQLite stores 64-bit integers, butexecreturns integer columns as JS numbers — the same design as Cloudflare’s SQLite storage and D1, neither of which returnsBigIntfrom the query API. A value beyondNumber.MAX_SAFE_INTEGER(2^53 − 1, i.e.9007199254740991) can be written and compared in SQL, but reading that column throwsRangeError: Value is too large to be represented as a JavaScript number— a loud failure rather than a silently less-precise number. Store large ids, bitmasks, or nanosecond timestamps asTEXT(orBLOB), or keep integers within the safe range. - Foreign keys are ON — a
REFERENCESviolation raisesFOREIGN KEY constraint failed. - Failed statements (syntax errors, constraint violations) throw from
exec; the actor keeps running, so catch and handle them like any other error.
SQL or Key/Value?
Both live underthis.ctx.storage, are durable and strongly consistent, and can be used
together in the same actor.
- Key/value
(
get/put/list): simple keyed state, prefix listing, atomic multi-key updates — reach for it by default when a lookup by key is all you need. - SQL (
ctx.storage.sql): relational shape, aggregates,GROUP BY, secondary indexes, and joins within one actor. Reach for it when a keyed lookup isn’t enough and you’d otherwise scan.
Related
- Key-Value storage — the
get/put/listsurface - Actor Storage reference — the full
ctx.storagecontract - Execution model — why
execneeds no lock