Skip to main content

Telnyx Storage: SQL (Beta) — Full Documentation

Complete page content for SQL (Beta) (Storage section) of the Telnyx developer docs (https://developers.telnyx.com). Root index: https://developers.telnyx.com/llms.txt · Lightweight index for this subsection: https://developers.telnyx.com/development/llms/storage-sql-llms-txt.md

Get Started

SQL Databases

Source: https://developers.telnyx.com/docs/edge-compute/sqldb.md
A SQL database is a SQLite database that lives on its own, outside any function. Create it once, bind it by id, and query it as env.DB from as many functions as need it — or from the CLI and REST API without deploying anything at all. There is no server to size and nothing to deploy per database. Every path reaches the same data through one primary, so writes serialize and there is no replica lag to reason about.

Two Ways to Reach a Database

The same database is reachable two ways. Pick based on where the code runs. A row written by a function is visible to the next CLI query, and a table created from the CLI is visible to the next request. That is what lets schema exist before any code does: create the database, apply migrations, then ship a function that binds it.

SQL Databases vs Per-Actor SQL

Stateful Actors also have SQLite, at ctx.storage.sql. Both surfaces are SQLite, which is where the confusion starts. They solve different problems. A per-actor database cannot be queried from outside its actor and cannot join across instances; there is no CLI or REST path to it. A SQL database can be queried from anywhere, but no caller gets exclusive access to it — concurrent callers interleave, and correctness across statements comes from batch(), not from holding the database. Explicit BEGIN is rejected on both surfaces; the runtime owns transaction boundaries.

Next Steps

  • Quick Start — Create a database, apply a schema, bind it, query it
  • How SQL Databases Work — The single primary, sharing, and what durability means today
  • Migrations — Versioned schema changes with storage sqldb migrations
  • Runtime API — The env binding surface (SqlDatabase)
  • CLI Commands — Create, inspect, and query databases from the terminal
  • Limits — Size and duration ceilings, and known gaps
  • Bindings — How the env binding surface works
  • Stateful Actor SQL — The private, per-instance SQLite database
  • KV — Key-value storage for read-heavy lookups
  • CLI reference — The full telnyx-edge command surface

Quick Start

Source: https://developers.telnyx.com/docs/edge-compute/sqldb/quick-start.md

Prerequisites

  • The telnyx-edge CLI, authenticated (telnyx-edge auth api-key set <YOUR_API_KEY>), on a release that includes storage sqldb.
  • A Telnyx API key, if calling the REST API directly.
  • An Edge Compute function project with a telnyx.toml (or func.toml) manifest, and Node.js for npm.

1. Create a Database

A database is an isolated SQLite database with its own schema. Create one with the CLI or the API.
The response carries the database id — a UUID, and the only durable handle. Names are not resolution keys:
A name may contain lowercase letters, numbers, and hyphens, and must be unique within the organization — a duplicate returns 409, and the API rejects an empty or upper-case name with 422 (the CLI stops an empty name before any request is made). Hyphen placement is not constrained — a leading hyphen is accepted — but avoid one: that name then reads as a flag when passed to CLI commands, so you are left addressing the database by id. Keep names short: past 255 characters the create fails with a 500 instead of a validation error. A new database starts at status: "pending". SQL sent to it from outside a function — execute, migrations, or the REST query endpoint — returns 409 ("Database is not ready (status: pending)") until provisioning finishes, typically in 2 to 11 seconds. If scripting, poll until the status is provision_ok rather than sleeping a fixed interval:

2. Create the Schema

Create your tables from the terminal, before any code exists. execute takes exactly one of --command or --file, and --remote is required — there is no local emulation.
Longer schemas belong in a file. The file is submitted as one multi-statement script — the engine splits it, the CLI does not:
Add --json to get the raw result object instead of the rendered table. For scripting note one wrinkle: the current CLI writes the JSON to stderr, so capture it with 2>&1 >/dev/null rather than piping stdout. execute is for one-off statements and inspection. For schema changes intended to be kept and replayed on another database, use Migrations — numbered .sql files with a tracking table inside the database itself.

3. Bind the Database to a Function

Declare the database in the function’s manifest. The manifest itself — and the [edge_compute] identity in it — comes from the function, not from this guide: telnyx-edge new-func registers the function and writes func_id when it scaffolds a project. Starting from nothing, run telnyx-edge new-func -n links-api -l ts first and work in the project it creates (it writes a classic func.toml; for the umbrella telnyx.toml form shown here, copy the generated [edge_compute] block across). The only part this step adds is the [storage.sqldb.DB] block. The example below is a telnyx.toml; the [storage.sqldb.<NAME>] block parses the same way in a func.toml project, though a classic func.toml project’s entry point has a different shape from the export default { fetch } handler in step 5:
The block key — DB here — is a name of your choosing, and it is what the code sees on env: this manifest produces env.DB. It must be a valid identifier (letters, digits, underscores; no leading digit, no __) and unique across every binding in the manifest. id must be the database UUID. Binding by name, or creating a database from the manifest, is rejected before deploy. Declare as many [storage.sqldb.<NAME>] blocks as needed; two bindings that carry different ids are two different databases, and neither can see the other’s tables.

4. Generate Types

telnyx-edge types reads the manifest — plus the @telnyx/edge-runtime version declared in package.json — and nothing else: it runs offline and needs no authentication. Re-run it after adding, renaming, or removing a binding. Types are for the compiler only. The binding itself resolves at runtime from the manifest, so a stale telnyx-env.d.ts does not change what the deployed function sees — in either direction.

5. Write the Function

Bindings live on the env imported from @telnyx/edge-runtimenot on the second argument to fetch(req, env). Reading env.DB off that argument type-checks cleanly and is undefined at runtime.
prepare() is synchronous and returns a statement. .bind() returns a new statement with the values attached — it never mutates the one it was called on. The terminal calls are async, and each resolves to a different shape: run() and all() give { results, success, meta }, first() gives a single row or null (first("col") gives that column’s value), and raw() gives an array of value-arrays. run() and all() are the same call: both return rows, including the rows a RETURNING clause produces. Bind every value that comes from a request with ? placeholders rather than building SQL by concatenation. Ordered ?NNN placeholders work too. A failing statement — a syntax error, a UNIQUE violation, a missing table — rejects with a plain Error. There is no typed error class or error-code taxonomy: the SQLite message is nested inside a longer transport string on error.message.

6. Ship and Call It

ship bundles the project, uploads it, and prints the function host — {func-name}-{func-id-prefix}.telnyxcompute.com, without a scheme; add https:// when calling it.

7. Query It Without Deploying

The rows the function wrote are readable from the terminal against the same database:
The CLI renders the rows as a table followed by a row count. This is the same database the function holds on env.DB, reached through the same primary — the CLI is not a second writer, and no schema or data is duplicated. Use it to inspect state, apply a fix-up statement, or seed a table without redeploying.

8. Query It From Any Language

The CLI is a wrapper over one HTTP endpoint. Anything that can make a request can reach the same database — a Go or Python function, a backfill script, a CI job:
The body takes exactly one key, sql. Any other key — including params — is rejected with 400 Invalid request body. The response carries results and nothing else: no meta, no row counts, no duration. A script may hold several ;-separated statements, and the rows returned are the last statement’s. This endpoint has no parameter binding. There is no way to send values separately from the statement, so any value that varies has to be written into the SQL text itself. Never interpolate a string that came from a request, a user, or any other untrusted source into a statement sent this way — that is a SQL injection, and the endpoint gives you nothing to defend with. Use this path for schema, migrations, backfills, and inspection, where the SQL is written by you and fixed in advance. For request-path queries whose values come from callers, use a TypeScript function and the env binding, where prepare().bind() keeps the statement and the data separate. Binary columns come back as JSON arrays of byte values ([9, 8, 7]), not base64. Errors arrive in the standard Telnyx envelope: a SQL failure is a 422 whose detail embeds the SQLite message inside the same longer transport string described in step 5, a database that has not finished provisioning is a 409, and an unknown or other-organization id is a 404.

Next Steps

  • How SQL Databases Work — the shared-database model, consistency, and what env.DB is talking to
  • Migrations — numbered .sql files and the in-database tracking table
  • Runtime API — the full SqlDatabase surface, including batch() and exec()
  • CLI Commandscreate, list, get, delete, execute, migrations
  • Limits — database and value size, bound parameters, and query performance
  • Bindings — how the env binding surface works across storage products

Concepts

How SQL Databases Work

Source: https://developers.telnyx.com/docs/edge-compute/sqldb/concepts/how-sqldb-works.md
A SQL database is stock SQLite, addressed by UUID, living outside any function. Everything in the model follows from one property: a database has exactly one primary, and every caller goes through it.

A Database Is a Standalone Resource

Create a database with the CLI or the REST API and it comes back immediately with status pending; 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:
The name is a label, unique within the organization, matching ^[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.
Keep statements bounded. A simple 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 database DB and another can call the same id SHARED; both statements land in the same place.
Because the alias is local, nothing in the schema records which function created a table. Coordination between functions is ordinary database work: agree on a schema, own it in migrations, and use 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.DB cannot see env.DB2’s tables and env.DB2 cannot see env.DB’s — each direction fails with no 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(), or exec() 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.
Deletion is asynchronous: for a few seconds the record reports 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.
  • Quick Start — Create a database, apply a schema, bind it, query it
  • Runtime API — The SqlDatabase binding 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 env bindings resolve
  • Stateful Actor SQL — The private, per-instance SQLite database

Migrations

Source: https://developers.telnyx.com/docs/edge-compute/sqldb/migrations.md
A migration is a numbered .sql file on disk. telnyx-edge storage sqldb migrations creates those files, applies the ones that have not run yet, and records what it applied — in a table inside the database, not in a state file beside your code. Three commands cover the whole workflow: --remote is required on list and apply. There is no local database to run against — local execution is not supported, and there is no --local flag. create is the exception: it only writes a file, so it needs no authentication and no network.

A Worked Example

Start with a provisioned database named links-db. Create the first migration:
The file is a stub with a header comment. Fill in the schema:
Add a second migration the same way:
Before applying, check what is outstanding:
Apply them:
list now reflects the new state:
Running apply again does nothing — it only ever runs what is pending:

The Ledger Lives in the Database

Applied state is tracked in a table called sqldb_migrations, created inside the database on the first apply:
Read it like any other table:
  • name is the filename, not the label — renaming a file after it has been applied makes it pending again.
  • applied_at is an RFC 3339 UTC timestamp generated by the CLI at apply time, so it reflects the clock of the machine that ran the command.
  • id is assigned by SQLite in apply order.
Because the ledger is a table in the database, there is no external state file to commit, share, or lose. Any checkout of the migration files, on any machine, sees the same applied set. Dropping the table makes every file pending again. list never creates the table — it is read-only, and treats a missing sqldb_migrations as “nothing applied yet”. Only apply creates it.

Apply Is Not All-or-Nothing

Each file is submitted as one script in one call: the migration body, then the INSERT into sqldb_migrations that records it. A script is applied atomically: if any statement in it fails, the whole script rolls back — a script that creates a table, inserts a row, and then hits a bad statement leaves no table behind. So a migration file either applies completely and is recorded, or changes nothing and is not recorded. There is no state in between for a single file. Across the batch there is no atomicity at all. If the third of five migrations fails, the first two stay applied and the command stops:
The line relays the API error verbatim, so the SQLite message — duplicate column name: owner here — arrives embedded in a longer transport string rather than on its own. The command exits non-zero with an error that begins migration 0003_add_owner.sql failed after applying 2 migration(s):, followed by the same relayed API error. The database is left partway through the batch — schema changes from 0001 and 0002 are live, and 0003 is not recorded, so apply will try it again. Recover by fixing forward:
  1. Run migrations list <database> --remote to see exactly where the batch stopped.
  2. Correct the file that failed. It is still pending, and apply runs pending files in numeric order, so the next run reaches it before anything numbered after it — a later migration cannot reconcile around it. Edit that file until it succeeds, or empty it out if the change is no longer wanted.
  3. Run apply --remote again. Only the still-pending files run, beginning with the one that failed.
  4. Prefer statements that are safe to retry — CREATE TABLE IF NOT EXISTS, CREATE INDEX IF NOT EXISTS, INSERT OR IGNORE — because a retry re-runs the failed file from the top.
Never edit a migration that has already been applied. The ledger only knows the filename, so an edited file stays applied and its new contents never run. Nothing coordinates two apply runs against the same database. apply reads the ledger and then submits each pending file as its own call, so two runs started at the same time — two CI jobs, or a pipeline racing an operator — can both see a file as pending and run it twice. Run migrations from one place, and write them so that a second run is harmless. Foreign keys are on and enforced. A migration that rebuilds or drops a referenced table has to order its statements so no statement leaves a dangling reference — a foreign-key violation fails the file like any other error.

Files and Directories

create numbers files with a 4-digit zero-padded, auto-incrementing prefix (0001_, 0002_, …), taking the next number from the highest prefix already in the directory. The label is the name argument with every run of non-alphanumeric characters collapsed to _, and leading or trailing runs dropped. Files land in migrations/<database> by default, with the same collapsing applied to the database argument — links-db becomes migrations/links_db:
Per-database directories keep two databases from sharing one file sequence. The directory name is derived from the argument as given, so passing the id to one command and the name to another points at two different directories — pick one form and use it consistently. Collapsing can also map two different names onto one directory: app-db and app--db both resolve to migrations/app_db. Give one of them an explicit --migrations-dir so their files stay apart. Override the location with --migrations-dir:
Any file matching a numeric prefix, an underscore, and a .sql suffix is picked up, whatever the digit count. Files run in numeric-prefix order, then filename order.

Machine-Readable Output

apply --json always emits the same shape, including on a no-op run ({"applied": []}), and emits no JSON at all on failure. One wrinkle in the current CLI: the JSON — for apply and list both — is written to stderr, not stdout, so capture it with 2>&1 >/dev/null rather than piping stdout:
list --json emits one entry per local file:

Reference

Overview

Source: https://developers.telnyx.com/docs/edge-compute/sqldb/reference.md
The types in this reference are exported from @telnyx/edge-runtime (TypeScript) and describe version ≥ 0.9.0 — the first release that exports SqlDatabase and wires the env binding. They describe the env binding, the in-function surface. Running SQL from outside a function — schema changes, ad-hoc queries, migrations — goes over a separate REST path, covered in the CLI reference. The SQL Databases Runtime API is a single binding type plus the statement and result types its methods return. A database declared as [storage.sqldb.<NAME>] in telnyx.toml resolves on env.<NAME> as a SqlDatabase.

Getting the Binding

Read the binding off the imported env — not the env argument of fetch(request, env). The object handed to fetch carries actor bindings only: env.DB on it is undefined, and the call fails at runtime with Cannot read properties of undefined (reading 'prepare') even though it type-checks cleanly. The generated types deliberately declare the binding in both places, so the compiler will not catch this. Declaring the binding is covered in the Quick Start. The binding resolves at runtime from telnyx.toml; run telnyx-edge types after editing the manifest to regenerate telnyx-env.d.ts, which types env.<NAME> as a SqlDatabase. That command checks the @telnyx/edge-runtime version declared in package.json and fails with @telnyx/edge-runtime <version> does not export SqlDatabase (requires >= 0.9.0) when the floor is not met.
  • SqlDatabase — the method-by-method reference
  • Quick Start — create a database, bind it, query it
  • How SQL Databases Work — one primary per database, and what that means for consistency
  • Bindings — how bindings resolve on env
  • CLItelnyx-edge storage sqldb for provisioning, ad-hoc SQL, and migrations
  • Limits — database and value size, bound parameters, and query performance
  • Stateful Actor SQL — a different product: the private embedded SQLite at ctx.storage.sql, visible to one actor instance only and called synchronously

SqlDatabase

Source: https://developers.telnyx.com/docs/edge-compute/sqldb/reference/sql-database.md
env.<BINDING> (a SqlDatabase) is the in-function handle to a SQL database. The runtime routes the call for you, so function code holds no API key. Every database is served by one primary that serializes writes, and the binding, the CLI, and the REST query endpoint all funnel through that same primary — a row written from a function is immediately readable from the CLI, and the reverse.
Every generic above has a default in the shipped types, so each method is usable without an explicit type argument — all() and run() hand back rows typed as plain records, and raw() hands back arrays of values. Key behaviors:
  • prepare is synchronous; every terminal is async. prepare builds a statement locally — nothing crosses the wire until first, run, all, raw, batch, or exec is awaited.
  • bind returns a new statement. It never mutates the statement it was called on, so a prepared statement can be reused with different values.
  • run() is an alias for all(). Both send the same request and return the same SqlQueryResult, rows included — run() on a SELECT returns rows. This matches Cloudflare D1.
  • batch() is atomic. All statements commit together or none do.
  • exec() returns no rows — only a statement count. Use it for schema scripts.
  • success is always true on a resolved promise. Failures reject; they are never reported through the flag.
  • Errors throw plain Error objects. There is no typed error class and no error-code taxonomy. See Errors.
  • Transaction control statements are rejected. SQL containing BEGIN, COMMIT, ROLLBACK, or SAVEPOINT fails when it runs — through a prepared statement or through exec() — with SqlTransactionControlError in the message. Transaction boundaries belong to the runtime; use batch() for all-or-nothing writes.

Reading What a Write Touched

meta reports the statement’s own accounting — last_row_id, changes, rows_read, rows_written, and duration. RETURNING is the more precise option, and worth preferring for anything you branch on. It names the rows the statement actually touched, so it survives concurrency: every caller of a database shares one connection, and meta describes the statement that most recently ran on it.
SQLite accepts RETURNING on INSERT, UPDATE, and DELETE, and the rows it hands back are exactly the rows the statement touched. SELECT changes() reports the count instead, but it reads whichever statement most recently ran on the shared connection, so a concurrent write can land in between. When a bare count is all you need, issue it in the same batch() as the write so nothing can interleave:

prepare(query)

Build a statement. Synchronous, and safe to call once and reuse.
A query string may hold several ;-separated statements. All of them execute in order, and the rows that come back are the final statement’s — the same rule Cloudflare’s Durable Object SQL storage follows. Bound parameters apply to that final statement only: place placeholders nowhere else, because the earlier statements run without bindings and a ? in one of them fails.

bind(...values)

Attach values to the placeholders in the query and get back a new statement. Always bind user input — never build SQL by string concatenation.
Placeholders are positional: anonymous ? and ordered ?NNN (1-indexed) both work, and values are consumed in argument order.

Type Conversion

How values cross between JavaScript and SQLite, in both directions — what bind() accepts on the way in, and what the terminal methods hand back on the way out. Four JavaScript types reach SQLite — null, number, string, and ArrayBuffer — plus undefined, which is accepted and stored as NULL. Every other value throws, and it throws when the statement runs rather than at the bind() call, because bind() performs no validation. Store a boolean as 0/1 and binary as an ArrayBuffer:
.buffer is the whole backing store, not the view. For a view that does not span its buffer, slice first: bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength). One bound value is capped at 2 MiB (2,097,152 bytes). A larger ArrayBuffer or string is rejected with SqlParameterTooLargeError before the statement runs. This is a ceiling of the storage engine SQL Databases share with per-actor SQLite, and it applies only to values sent through .bind() — a value produced inside SQL, such as randomblob(), is not bound and is not checked. Split larger payloads, or keep them in object storage and bind the key. A bound number arrives with REAL affinitySELECT typeof(?) on a bound 42 returns real. Inserted into a column declared INTEGER, it still lands as an integer (typeof(col) reads back integer); the affinity only shows in untyped or expression contexts. In a STRICT table the check is exact, so binding 3.5 into an INTEGER column correctly fails with cannot store REAL value in INTEGER column. Reading an integer larger than 2^53 − 1 throws RangeError: Value is too large to be represented as a JavaScript number. SQLite stores 64-bit integers, but result columns come back as JavaScript numbers — a value past Number.MAX_SAFE_INTEGER (9007199254740991) can be written and compared in SQL, but the read fails loudly rather than losing precision. Store large ids, snowflake keys, and nanosecond timestamps as TEXT.

first() and first(column)

Return the first row, or a single column of it. No meta.
first() resolves to null when there are no rows. first(column) returns the value at that key; if the column is not in the result set it also resolves to null rather than throwing — a divergence from D1, which raises D1_COLUMN_NOTFOUND.

run() and all()

Run the statement and return the full envelope. The two methods are identical.
results holds the rows as objects keyed by column name. For an INSERT, UPDATE, or DELETE it is an empty array unless the statement carries a RETURNING clause. The generic is a compile-time assertion only — it is not validated against the columns the query actually returns.

raw(options?)

Return rows as arrays of values instead of objects, for callers that already know the column order or that feed a columnar consumer. The rows are converted after they arrive, so this is a shape convenience, not a smaller response.
{ columnNames: true } prepends one row of column names. An empty result set returns [] with no header row, so check the length before treating the first entry as headers. Rows are converted from the same objects all() returns, so two result columns with the same name collapse into one — alias them (SELECT a.id AS a_id, b.id AS b_id) when joining.

batch(statements)

Run several prepared statements as one atomic unit. Results come back in the order the statements were passed.
The whole batch commits together: if any statement fails, every earlier statement in the same batch is rolled back and the promise rejects. A batch whose middle statement violates a UNIQUE constraint leaves the table exactly as it was. Every statement must come from the same database’s prepare(). Passing a statement built by a different binding throws env.<DB>.batch() accepts only statements created by this database's prepare(). batch() is the transaction primitive for parameterized, all-or-nothing writes: explicit BEGIN is rejected, so a transaction cannot be opened by hand.

exec(query)

Run a raw SQL script. Takes no bound parameters and returns no rows.
count is the number of statements in the script. Passing bind arguments throws env.<DB>.exec() takes no bound params — use prepare().bind() for parameterized statements. Use exec for schema and one-shot maintenance work, and prepare().bind() for anything that takes user input. A script is atomic: if any statement in it fails, every statement in that script rolls back. A script that creates a table, inserts a row, and then hits an error leaves no table behind. So exec() either applies a whole script or changes nothing.

Errors

Every failure — syntax error, missing table, constraint violation, bad bind — rejects with a plain Error. There is no typed error class, no cause chain, and no error-code taxonomy equivalent to D1’s D1_ERROR / D1_TYPE_ERROR / D1_COLUMN_NOTFOUND. error.message is a long transport string with the SQLite message embedded near the end:
Match on the SQLite text as a substring. Do not parse the envelope — its shape is not part of the contract, and the surrounding transport detail changes.
Long-running statements fail differently: no SQLite message comes back at all. Statements running for tens of seconds complete, but past the caller’s own budget — a function invocation tops out around 60 seconds — the call was observed to hang rather than fail with anything identifying the statement. The database stays healthy afterwards. See Limits.
  • Overview — the SQL Databases Runtime API surface at a glance
  • Quick Start — declare the binding and type it
  • How SQL Databases Work — one primary per database, and where the data lives
  • Migrations — versioned schema changes from the CLI
  • Limits — statement size, parameter, and duration ceilings
  • Stateful Actor SQL — the per-actor embedded SQLite at ctx.storage.sql, private to one instance and synchronous

CLI

Source: https://developers.telnyx.com/docs/edge-compute/sqldb/cli.md
Manage SQL databases using the telnyx-edge CLI. Every command in this family reaches a database through the same primary as the env binding, so the CLI is not a second writer — SQL run from a terminal and SQL run from a deployed function are serialized together. The storage sqldb family requires CLI v0.3.0 or newer — the first release to carry any SQL surface. On v0.2.5 and earlier telnyx-edge storage sqldb fails as an unknown command. Check with telnyx-edge --version.

Database Management

create returns immediately with status: pending; provisioning finishes in the background, typically in 2 to 11 seconds:
Poll get until the status is provision_ok:
list paginates, newest first:
delete takes effect immediately and asks nothing first — there is no confirmation prompt and no --yes flag:
The record disappears shortly afterwards: get and any query against that id return 404. Delete removes the database record and makes the data unreachable through every path; it is not a guaranteed erase of the stored bytes, so it does not satisfy a data-destruction requirement on its own. It also does not touch functions that still declare the id in a [storage.sqldb.<NAME>] block: those functions stay deployed and their queries start failing. Remove the block and redeploy them.

list Flags

get and delete take the database id only. execute and the migrations subcommands accept either the id or the database name — see Addressing a Database.

Running SQL

execute runs SQL against a database without deploying anything — to create a schema, load seed data, or inspect what a function wrote.
A statement that returns no rows prints a confirmation:
A read prints a table and a row count:
Columns are ordered alphabetically rather than by their order in the SELECT list, and a SQL NULL renders as NULL. To learn a new row’s id, or to count the rows a write touched, add RETURNING to the statement and count the rows it prints. SELECT changes() in a separate call reads a counter on a connection shared with every other caller of the database, so another write can land in between. --json prints the raw result object instead of a table — on stderr in the current CLI, so scripts should capture it with 2>&1 >/dev/null rather than piping stdout. results holds the rows and is the only field the service returns — a statement that produces no rows prints {}:
BLOB columns cross this boundary as a JSON array of byte values — a three-byte blob comes back as [9, 8, 7], not base64 and not an object. Decode it as bytes on the client. Writing binary this way means a SQL hex literal (X'090807'), since this path has no parameter binding; from a function, bind an ArrayBuffer instead.

execute Flags

--remote is mandatory. Omitting it fails with --remote is required before any network call: there is no local SQLite emulation to run against, and no --local flag. The SQL text is submitted verbatim — the CLI never splits statements itself. A script may hold several ;-separated statements; all of them run in order, and the rows returned are the last statement’s rows. The whole script must fit under the ~4 MiB script ceiling (see Limits). A database that has not finished provisioning rejects execute with 409Database is not ready (status: pending). Wait for provision_ok.

Migrations

Versioned schema changes live in numbered .sql files and are tracked in a sqldb_migrations table inside the database itself.

migrations Flags

The full workflow — file naming, the ledger table, and what happens when a migration fails midway — is covered in Migrations.

Generating Types

telnyx-edge types reads the manifest and writes telnyx-env.d.ts, so env.<BINDING> is typed at compile time. It runs fully offline — no authentication, no network.
Two things are checked before anything is written: the id must be a well-formed UUID (inline creation and binding by name are rejected), and @telnyx/edge-runtime in package.json must be 0.9.0 or newer — the first version that exports SqlDatabase. An older pin fails with @telnyx/edge-runtime <declared version> does not export SqlDatabase (requires >= 0.9.0), quoting the range exactly as package.json declares it.

Addressing a Database

execute, migrations list, and migrations apply take a <database> argument that resolves either way:
  • An exact id match always wins.
  • A name resolves when exactly one database in the organization carries it.
  • A name shared by several databases errors with Multiple SQL databases are named "..." — pass the id instead.
  • No match errors with No SQL database found matching "...".
Names are unique within an organization, so an ambiguous match should not occur in practice — the CLI guards against it anyway. get and delete do not resolve names; they take the id.
  • Quick Start — create a database, bind it, query it
  • Migrations — versioned schema changes and the sqldb_migrations ledger
  • Limits — request body size, query duration, and what is not enforced
  • SqlDatabase — the env binding surface
  • CLI Reference — every telnyx-edge command

Limits

Source: https://developers.telnyx.com/docs/edge-compute/sqldb/limits.md

Limits

The ~4 MiB script ceiling covers the whole SQL script sent over the REST query path — which is also what telnyx-edge storage sqldb execute --file and migrations apply use. The service advertises an 8 MiB gate and returns the 413 past it, but the transport underneath rejects anything over roughly 4 MiB (4,194,304 bytes, less a few hundred bytes of envelope) with a 422 whose detail ends in stream too large — so ~4 MiB is the number to plan against, and a large seed file needs splitting across several calls. Result sets are capped at the same ~4 MiB, per statement. A query whose rows exceed it fails with a ResourceExhausted … trying to send message larger than max error, through REST and through the env binding alike. Only shipping the rows is capped — computing over the same data server-side (length(), aggregates) is fine — so page through large reads instead of selecting them whole. The 2 MiB cap applies only to values passed through .bind(). A value produced inside SQL is never bound and never checked, which is why randomblob(100000000) stores without complaint. Keep anything approaching either figure — images, archives, model weights — in object storage and bind the key instead. Database size and bound-value size come from the SQLite storage engine that SQL Databases share with per-actor SQLite, so the same numbers apply on both surfaces.

Query Performance

A database is served by one primary, and statements run one at a time. A long statement delays everything else queued against that database, so the useful discipline is keeping individual statements short. Add indexes on the columns you filter and order by, page through large result sets instead of selecting them whole, and maintain a summary table on write rather than aggregating across millions of rows on read. Long analytical scans belong in a warehouse, not here.