A Database Is a Standalone Resource
Create a database with the CLI or the REST API and it comes back immediately with statuspending; it reaches provision_ok a few seconds later — typically 2 to 11, since provisioning is picked up by a poll loop rather than done inline. A query issued while it is still pending returns HTTP 409 Database is not ready. In a script, poll get until the status is provision_ok rather than sleeping a fixed interval.
The UUID is the only durable handle. A telnyx.toml binding takes an id, never a name:
^[a-z0-9-]+$. It exists so humans can find a database in a list; the CLI resolves a name to an id locally as a convenience, while REST calls and manifests take the id. There is no rename, and deleting a database frees its name for a new, empty database with a different id.
Nothing is deployed per database, and nothing about a database is derived from a function. Shipping, rolling back, or deleting a function does not touch the data.
The engine is stock SQLite — 3.51.3 in production today — so the dialect and the built-in functions are SQLite’s, not a subset invented for the edge. Foreign keys are on and enforced, which is worth knowing if you are arriving from a SQLite build that leaves them off: a REFERENCES violation fails the statement.
One Primary, Serialized Writes
Today every read and every write for a given database funnels through a single primary, so a caller never contends with a second writer: not another region, not a replica, not a second connection opened by a different caller. That is the current topology rather than a promise about future ones; what you can design against is that statements against one database are serialized, and that every access path sees the same data. Two consequences, both worth designing around:- Writes are serialized. Concurrent writers queue behind one another instead of contending for the database. Lost-update anomalies are still possible at the application level — two callers that each read, compute, and write can still clobber each other — which is what
batch()exists for. - Statements run one at a time. The database is single-threaded. A slow statement occupies it and other callers wait behind it. That is the cost of the property above.
SELECT 1 round-trips in about 10 ms warm and a 5,000-row read in about 160 ms, so the cost that matters is the one a slow statement imposes on everything queued behind it. Long analytical scans belong in a warehouse, not here — see Limits.
Indexes matter for the same reason they matter in any SQLite deployment, and more so because the cost is paid by every other caller:
Two Access Paths, One Database
A database is reachable from inside a function through its binding, and from outside through REST or the CLI. These are not two systems kept in sync — both run against the same primary.
Visibility between them is immediate and needs no coordination. A row inserted by a function is returned by the very next
sqldb execute; a table created by sqldb execute is visible to the next request that runs. That is what makes schema management work before any code exists: create the database, apply migrations from a terminal, then ship a function that binds it.
Inside a function the binding is read off the
env imported from @telnyx/edge-runtime, not off the second argument to fetch(request, env). That argument does not carry storage bindings, so env.DB read from it is undefined at runtime even though it type-checks. Requires @telnyx/edge-runtime 0.9.0 or later.Sharing Across Functions
A database is not owned by the function that first wrote to it. Any number of functions can declare a binding to the same id, and each one sees the same tables and the same rows. The binding name is a local alias, declared per function. One function can call the databaseDB and another can call the same id SHARED; both statements land in the same place.
batch() where a sequence has to be atomic.
The id is validated when a function ships. An id that does not exist, belongs to another organization, or is not yet provision_ok fails the deploy rather than producing a function with a dead binding — though the failure currently surfaces as a generic API error (HTTP 500) that never names the binding, so if a ship starts failing right after a [storage.sqldb] edit, re-check the id and the database’s status before believing the retry-later advice. The check reads the database record rather than opening a connection, so it is not a promise that the first query will succeed — only that the id resolves to a database this organization owns.
Isolation
Isolation runs along two lines, both enforced by the platform rather than by convention.- Database to database. Two bindings in the same function are two separate databases.
env.DBcannot seeenv.DB2’s tables andenv.DB2cannot seeenv.DB’s — each direction fails withno such table. There is no cross-database query: each statement addresses exactly one id, and one database cannot be joined to another. - Organization to organization. A database belongs to the organization that created it. An id from another organization returns
404— the same response as an id that never existed, so existence cannot be probed. A function can only ever reach its own organization’s databases.
Consistency and Durability
Because there is one primary and no replicas, consistency is the simple kind:- A write that resolves has committed. When
all(),run(),batch(), orexec()resolves without throwing, the transaction has committed on the primary and every later reader sees it. - Reads reflect prior writes. Both access paths reach the same primary, so there is no replica lag to design around and no eventual-consistency window between the function path and the REST path.
- Data outlives functions. Rows written before a deploy are there after it — across redeploys of the functions that bind the database, across CLI and REST sessions, and across the creation of other databases in the same organization.
status: deleting — queries return 409 and the name is still held — and then the id turns 404 and the name frees. Re-creating the same name after that produces a new, empty database under a new id, so poll get until the 404 before re-creating. Keep the schema in version-controlled migration files.
Choosing Between SQL, KV, and Per-Actor SQLite
Three storage surfaces, three different scopes. They can be used together in one application.
The distinction that catches people is the last column. Per-actor SQLite is the same engine but a private database per instance — it cannot be queried from the CLI, cannot be joined across instances, and has no REST path. A SQL database is the opposite: queryable from anywhere, but no caller ever holds it exclusively.
Related Resources
- Quick Start — Create a database, apply a schema, bind it, query it
- Runtime API — The
SqlDatabasebinding surface method by method - Type Conversion — What
bind()accepts and what queries hand back - Migrations — Versioned schema changes
- Limits — Size and duration ceilings, and known gaps
- Bindings — How
envbindings resolve - Stateful Actor SQL — The private, per-instance SQLite database