# Telnyx Storage: SQL (Beta) — Full Documentation > Complete page content for SQL (Beta) (Storage section) of the Telnyx developer docs (https://developers.telnyx.com). > This file: https://developers.telnyx.com/docs/development/llms/storage-sql-llms-full-txt.md · Root index: https://developers.telnyx.com/llms.txt ## 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. | | `env` binding | REST API and CLI | |---|---|---| | **Where** | Inside an edge function — TypeScript only | Anywhere — any language, any host, no function required | | **Auth** | Injected by the runtime; no API key in your code | Your `TELNYX_API_KEY` as a bearer token | | **Shape** | `env.DB.prepare(sql).bind(...).all()` | `POST https://api.telnyx.com/v2/storage/sqldbs/{id}/actions/query`, `telnyx-edge storage sqldb execute` | | **Use it for** | Reads and writes on the request path | Creating databases, applying schema and migrations, backfills, one-off inspection — values bind through `params` over REST and `--param` from the CLI | 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](/docs/edge-compute/sqldb/migrations), then ship a function that binds it. ## SQL Databases vs Per-Actor SQL Stateful Actors also have SQLite, at [`ctx.storage.sql`](/docs/edge-compute/stateful-actors/guides/storage/sql). Both surfaces are SQLite, which is where the confusion starts. They solve different problems. | | SQL Database (`env.DB`) | Actor SQL (`ctx.storage.sql`) | |---|---|---| | **Scope** | One database, shared by the functions that bind its id | One private database per actor instance | | **Reached from** | Any bound function, the CLI, the REST API | Only from inside that one actor instance | | **API shape** | Async — `await env.DB.prepare(sql).bind(...).all()` | Synchronous — `ctx.storage.sql.exec(sql, ...)` returns a cursor | | **Use it for** | Application data more than one caller reads: catalogs, links, accounts, anything an operator also needs to query | State owned by a single entity — a room, a session, a device — where the actor itself is the lock | 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](/docs/edge-compute/sqldb/quick-start) — Create a database, apply a schema, bind it, query it - [How SQL Databases Work](/docs/edge-compute/sqldb/concepts/how-sqldb-works) — The single primary, sharing, and what durability means today - [Migrations](/docs/edge-compute/sqldb/migrations) — Versioned schema changes with `storage sqldb migrations` - [Runtime API](/docs/edge-compute/sqldb/reference) — The `env` binding surface (`SqlDatabase`) - [CLI Commands](/docs/edge-compute/sqldb/cli) — Create, inspect, and query databases from the terminal - [Limits](/docs/edge-compute/sqldb/limits) — Size and duration ceilings, and known gaps ## Related Resources - [Bindings](/docs/edge-compute/runtime/bindings) — How the `env` binding surface works - [Stateful Actor SQL](/docs/edge-compute/stateful-actors/guides/storage/sql) — The private, per-instance SQLite database - [KV](/docs/edge-compute/kv) — Key-value storage for read-heavy lookups - [CLI reference](/docs/edge-compute/reference/cli) — 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](/docs/edge-compute/reference/cli), authenticated (`telnyx-edge auth api-key set `), 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. ```bash telnyx-edge storage sqldb create --name my-app-db ``` ```bash curl -X POST https://api.telnyx.com/v2/storage/sqldbs \ -H "Authorization: Bearer $TELNYX_API_KEY" \ -H "Content-Type: application/json" \ -d '{"name": "my-app-db"}' ``` The response carries the database `id` — a UUID, and the only durable handle. Names are not resolution keys: ```json { "data": { "record_type": "storage_sqldb", "id": "550e8400-e29b-41d4-a716-446655440000", "name": "my-app-db", "status": "pending", "created_at": "2026-07-29T14:52:03Z", "updated_at": "2026-07-29T14:52:03Z" } } ``` 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). It must also start and end on a letter or number: hyphens go in between, so `my-db` and `a--b` are fine but `-tenant` and `tenant-` are `422`. That rule exists partly because a leading hyphen reads as a flag when passed to CLI commands, leaving you addressing the database by id. Names are capped at 255 characters; past that the create returns `422` naming the length, like every other name failure. 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: ```bash telnyx-edge storage sqldb get 550e8400-e29b-41d4-a716-446655440000 # or curl https://api.telnyx.com/v2/storage/sqldbs/550e8400-e29b-41d4-a716-446655440000 \ -H "Authorization: Bearer $TELNYX_API_KEY" ``` ## 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. ```bash telnyx-edge storage sqldb execute 550e8400-e29b-41d4-a716-446655440000 --remote \ --command "CREATE TABLE links ( id INTEGER PRIMARY KEY, slug TEXT NOT NULL UNIQUE, target TEXT NOT NULL, created_at TEXT NOT NULL DEFAULT (datetime('now')) )" # ✓ Statement executed — no rows returned ``` Longer schemas belong in a file. The file is submitted as one multi-statement script — the engine splits it, the CLI does not: ```bash telnyx-edge storage sqldb execute 550e8400-e29b-41d4-a716-446655440000 --remote --file schema.sql ``` 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](/docs/edge-compute/sqldb/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.]` 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: ```toml name = "links-api" main = "src/index.ts" compatibility_date = "2026-05-14" [storage.sqldb.DB] id = "550e8400-e29b-41d4-a716-446655440000" # Database ID from step 1 [edge_compute] func_id = "" func_name = "links-api" ``` 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.]` blocks as needed; two bindings that carry different ids are two different databases, and neither can see the other's tables. ## 4. Generate Types ```bash npm install @telnyx/edge-runtime # 0.9.0 or newer — the first release exporting SqlDatabase telnyx-edge types # writes telnyx-env.d.ts # env.DB → SqlDatabase ``` `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-runtime` — **not** on the second argument to `fetch(req, env)`. Reading `env.DB` off that argument type-checks cleanly and is `undefined` at runtime. ```ts import { env } from "@telnyx/edge-runtime"; type Link = { id: number; slug: string; target: string }; export default { async fetch(req: Request): Promise { if (req.method === "POST") { const { slug, target } = (await req.json()) as { slug: string; target: string }; // RETURNING hands back the generated id const inserted = await env.DB.prepare( `INSERT INTO links (slug, target) VALUES (?, ?) RETURNING id`, ) .bind(slug, target) .run<{ id: number }>(); return Response.json({ id: inserted.results[0]!.id }, { status: 201 }); } const slug = new URL(req.url).searchParams.get("slug"); const { results } = slug ? await env.DB.prepare(`SELECT id, slug, target FROM links WHERE slug = ?`) .bind(slug) .all() : await env.DB.prepare( `SELECT id, slug, target FROM links ORDER BY id DESC LIMIT 20`, ).all(); return Response.json({ links: results }); }, }; ``` `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 ```bash telnyx-edge ship ``` `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. ```bash B=https://links-api-.telnyxcompute.com curl -sS -X POST $B -H 'content-type: application/json' \ -d '{"slug":"docs","target":"https://developers.telnyx.com"}' # → {"id":1} curl -sS $B # → {"links":[{"id":1,"slug":"docs","target":"https://developers.telnyx.com"}]} curl -sS "$B?slug=docs" # → {"links":[{"id":1,"slug":"docs","target":"https://developers.telnyx.com"}]} ``` ## 7. Query It Without Deploying The rows the function wrote are readable from the terminal against the same database: ```bash telnyx-edge storage sqldb execute 550e8400-e29b-41d4-a716-446655440000 --remote \ --command "SELECT id, slug, target FROM links ORDER BY id DESC LIMIT 5" ``` 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: ```bash curl -sS -X POST \ https://api.telnyx.com/v2/storage/sqldbs/550e8400-e29b-41d4-a716-446655440000/actions/query \ -H "Authorization: Bearer $TELNYX_API_KEY" \ -H "Content-Type: application/json" \ -d '{"sql": "SELECT id, slug FROM links ORDER BY id DESC LIMIT 5"}' ``` ```json { "data": { "results": [{ "id": 2, "slug": "home" }, { "id": 1, "slug": "docs" }], "meta": { "duration": 5.18, "rows_read": 0, "rows_written": 0, "last_row_id": 0, "changes": 0 } } } ``` The body takes `sql`, plus an optional `params` array of values to bind to its `?` placeholders: ```bash curl -sS -X POST \ https://api.telnyx.com/v2/storage/sqldbs/550e8400-e29b-41d4-a716-446655440000/actions/query \ -H "Authorization: Bearer $TELNYX_API_KEY" \ -H "Content-Type: application/json" \ -d '{"sql": "SELECT id, slug FROM links WHERE slug = ?", "params": ["home"]}' ``` Each value is a string, number, boolean, or null — booleans bind as `1`/`0`. The count must match the placeholders exactly: supply too few or too many and the call is a `422` naming both counts, rather than binding null for the ones you left out. Nested arrays and objects have no single-value binding and are `422` with a `/params/{index}` pointer, as is an integer too large to survive the JSON boundary exactly (pass it as a string). Omitting `params` entirely is still valid — that is the raw-SQL form above. A script may hold several `;`-separated statements; the rows returned are the last statement's, and `params` binds to that statement. **Bind values instead of interpolating them.** Any value that came from a request, a user, or another untrusted source belongs in `params`, never concatenated into the `sql` string — that is a SQL injection, and binding is what defends against it. The same applies wherever you reach the database: `prepare().bind()` from a function through the [`env` binding](#5-write-the-function), `params` over REST, and `--param` / `--param-json` from [`sqldb execute`](/docs/edge-compute/sqldb/cli#binding-values). 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 is the database's own message (`no such table: links`, `near "SELEC": syntax error`), 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](/docs/edge-compute/sqldb/concepts/how-sqldb-works) — the shared-database model, consistency, and what `env.DB` is talking to - [Migrations](/docs/edge-compute/sqldb/migrations) — numbered `.sql` files and the in-database tracking table - [Runtime API](/docs/edge-compute/sqldb/reference) — the full `SqlDatabase` surface, including `batch()` and `exec()` - [CLI Commands](/docs/edge-compute/sqldb/cli) — `create`, `list`, `get`, `delete`, `execute`, `migrations` - [Limits](/docs/edge-compute/sqldb/limits) — database and value size, bound parameters, and query performance - [Bindings](/docs/edge-compute/runtime/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](/docs/edge-compute/sqldb/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: ```toml [storage.sqldb.DB] id = "0198c2c5-8f1e-7a3d-9b21-6e4a0d5f1c88" ``` 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()`](/docs/edge-compute/sqldb/reference/sql-database#batch-statements) 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](/docs/edge-compute/sqldb/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: ```sql CREATE INDEX IF NOT EXISTS links_by_slug ON links(slug); ``` ## 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. | | `env` binding | REST API and CLI | |---|---|---| | **Where** | Inside a TypeScript edge function | Anywhere; no function needs to exist | | **Auth** | Injected by the runtime | `TELNYX_API_KEY` bearer token | | **Entry point** | `env.DB.prepare(sql)`, `.batch()`, `.exec()` | `POST /v2/storage/sqldbs/{id}/actions/query`, `telnyx-edge storage sqldb execute` | | **Typical use** | Reads and writes on the request path | Schema, migrations, backfills, inspection — values bind through `params` over REST and `--param` from the CLI | 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](/docs/edge-compute/sqldb/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**. ```ts import { env } from "@telnyx/edge-runtime"; export default { async fetch(request: Request): Promise { const { results } = await env.DB .prepare("SELECT url FROM links WHERE slug = ?") .bind(new URL(request.url).pathname.slice(1)) .all<{ url: string }>(); return results.length ? Response.redirect(results[0].url, 302) : new Response("not found", { status: 404 }); }, }; ``` ## 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. ```toml # links-api/telnyx.toml [storage.sqldb.DB] id = "0198c2c5-8f1e-7a3d-9b21-6e4a0d5f1c88" ``` ```toml # analytics-worker/telnyx.toml [storage.sqldb.SHARED] id = "0198c2c5-8f1e-7a3d-9b21-6e4a0d5f1c88" ``` 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](/docs/edge-compute/sqldb/migrations). ## Choosing Between SQL, KV, and Per-Actor SQLite Three storage surfaces, three different scopes. They can be used together in one application. | | SQL Database | [KV](/docs/edge-compute/kv) | [Per-actor SQLite](/docs/edge-compute/stateful-actors/guides/storage/sql) | |---|---|---|---| | **Data model** | Relational tables, full SQL | Key to opaque bytes | Relational tables, full SQL | | **Scope** | One dataset shared by every function bound to its id | One namespace in a single global store | One private database per actor instance | | **Reached from** | Any bound function, the CLI, the REST API | Any function, the CLI, the REST API | Only inside that one actor instance | | **Call shape** | `await env.DB.prepare(...).all()` | `await env.KV.get(key)` | `ctx.storage.sql.exec(...)` — synchronous | | **Concurrency** | One primary; statements serialized | Last-write-wins per key | The actor instance is the lock | | **Reach for it when** | Data is relational and more than one caller reads it | Lookups are by key and reads dominate | State belongs to one entity and nothing else reads it | 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](/docs/edge-compute/sqldb/quick-start) — Create a database, apply a schema, bind it, query it - [Runtime API](/docs/edge-compute/sqldb/reference) — The `SqlDatabase` binding surface method by method - [Type Conversion](/docs/edge-compute/sqldb/reference/sql-database#type-conversion) — What `bind()` accepts and what queries hand back - [Migrations](/docs/edge-compute/sqldb/migrations) — Versioned schema changes - [Limits](/docs/edge-compute/sqldb/limits) — Size and duration ceilings, and known gaps - [Bindings](/docs/edge-compute/runtime/bindings) — How `env` bindings resolve - [Stateful Actor SQL](/docs/edge-compute/stateful-actors/guides/storage/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: | Command | What it does | Network | |---|---|---| | `migrations create ` | Writes a new numbered file | None — fully offline | | `migrations list --remote` | Marks each local file `pending` or `applied` | Reads the database | | `migrations apply --remote` | Runs every pending file, in order | Writes the database | `--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: ```bash telnyx-edge storage sqldb migrations create links-db create_links ``` ``` ✓ Created migrations/links_db/0001_create_links.sql ``` The file is a stub with a header comment. Fill in the schema: ```sql -- Migration 0001_create_links.sql CREATE TABLE links ( id INTEGER PRIMARY KEY, slug TEXT NOT NULL UNIQUE, url TEXT NOT NULL ); ``` Add a second migration the same way: ```bash telnyx-edge storage sqldb migrations create links-db add_click_count ``` ``` ✓ Created migrations/links_db/0002_add_click_count.sql ``` ```sql -- Migration 0002_add_click_count.sql ALTER TABLE links ADD COLUMN clicks INTEGER NOT NULL DEFAULT 0; CREATE INDEX links_by_slug ON links(slug); ``` Before applying, check what is outstanding: ```bash telnyx-edge storage sqldb migrations list links-db --remote ``` ``` MIGRATION STATUS ------------------------ -------- 0001_create_links.sql pending 0002_add_click_count.sql pending ``` Apply them: ```bash telnyx-edge storage sqldb migrations apply links-db --remote ``` ``` ✓ Applied 0001_create_links.sql ✓ Applied 0002_add_click_count.sql 2 migration(s) applied ``` `list` now reflects the new state: ``` MIGRATION STATUS ------------------------ -------- 0001_create_links.sql applied 0002_add_click_count.sql applied ``` Running `apply` again does nothing — it only ever runs what is pending: ``` No pending migrations ``` ## The Ledger Lives in the Database Applied state is tracked in a table called `sqldb_migrations`, created inside the database on the first `apply`: ```sql CREATE TABLE IF NOT EXISTS sqldb_migrations ( id INTEGER PRIMARY KEY, name TEXT NOT NULL, applied_at TEXT NOT NULL ) ``` Read it like any other table: ```bash telnyx-edge storage sqldb execute links-db --remote \ --command "SELECT id, name, applied_at FROM sqldb_migrations ORDER BY id" ``` ``` applied_at id name ---------- --- ---- 2026-07-29T15:04:11Z 1 0001_create_links.sql 2026-07-29T15:04:12Z 2 0002_add_click_count.sql 2 row(s) ``` - `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: ``` ✓ Applied 0001_create_links.sql ✓ Applied 0002_add_click_count.sql ✗ 0003_add_owner.sql failed: ❌ API error (HTTP 422): Unprocessable Entity - actor invocation … {"error":"method_failed","message":"Error: duplicate column name: owner","name":"Error"} ``` 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 --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/` by default, with the same collapsing applied to the database argument — `links-db` becomes `migrations/links_db`: ``` migrations/ └── links_db/ ├── 0001_create_links.sql └── 0002_add_click_count.sql ``` 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`: ```bash telnyx-edge storage sqldb migrations apply links-db --remote --migrations-dir ./db/migrations ``` 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: ```bash telnyx-edge storage sqldb migrations apply links-db --remote --json ``` ```json { "applied": [ "0001_create_links.sql", "0002_add_click_count.sql" ] } ``` `list --json` emits one entry per local file: ```json [ { "name": "0001_create_links.sql", "status": "applied" }, { "name": "0002_add_click_count.sql", "status": "pending" } ] ``` ## Related - [CLI](/docs/edge-compute/sqldb/cli) — the full `storage sqldb` command surface - [Quick Start](/docs/edge-compute/sqldb/quick-start) — create a database, bind it, query it - [Limits](/docs/edge-compute/sqldb/limits) — the ~4 MiB script ceiling and query duration - [How SQL Databases Work](/docs/edge-compute/sqldb/concepts/how-sqldb-works) — one primary per database --- ## 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](/docs/edge-compute/sqldb/cli). 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.]` in `telnyx.toml` resolves on `env.` as a `SqlDatabase`. | Surface | Where it lives | What it's for | |---|---|---| | [`SqlDatabase`](/docs/edge-compute/sqldb/reference/sql-database) | `env.` | The binding handle — `prepare`, `batch`, `exec`. | | [`SqlPreparedStatement`](/docs/edge-compute/sqldb/reference/sql-database#prepare-query) | returned by `prepare()` | `bind()`, then one terminal: `first`, `run`, `all`, `raw`. | | [`SqlQueryResult`](/docs/edge-compute/sqldb/reference/sql-database#run-and-all) | resolved by `run()`, `all()`, and each entry of `batch()` | `{ results, success, meta }` — the rows plus their envelope. | | [`SqlQueryResultMeta`](/docs/edge-compute/sqldb/reference/sql-database#reading-what-a-write-touched) | `SqlQueryResult.meta` | `duration`, `rows_read`, `rows_written`, `last_row_id`, `changes` — the statement's own accounting. | | [`SqlExecResult`](/docs/edge-compute/sqldb/reference/sql-database#exec-query) | resolved by `exec()` | `{ count, duration }` — how many statements ran. Returns no rows. | ## Getting the Binding ```ts import { env } from "@telnyx/edge-runtime"; // env.DB is a SqlDatabase, from [storage.sqldb.DB] in telnyx.toml const { results } = await env.DB .prepare("SELECT id, email FROM users WHERE id = ?") .bind(1) .all<{ id: number; email: string }>(); ``` 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](/docs/edge-compute/sqldb/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.` as a `SqlDatabase`. That command checks the `@telnyx/edge-runtime` version declared in `package.json` and fails with `@telnyx/edge-runtime does not export SqlDatabase (requires >= 0.9.0)` when the floor is not met. ## Related Resources - [`SqlDatabase`](/docs/edge-compute/sqldb/reference/sql-database) — the method-by-method reference - [Quick Start](/docs/edge-compute/sqldb/quick-start) — create a database, bind it, query it - [How SQL Databases Work](/docs/edge-compute/sqldb/concepts/how-sqldb-works) — one primary per database, and what that means for consistency - [Bindings](/docs/edge-compute/runtime/bindings) — how bindings resolve on `env` - [CLI](/docs/edge-compute/sqldb/cli) — `telnyx-edge storage sqldb` for provisioning, ad-hoc SQL, and migrations - [Limits](/docs/edge-compute/sqldb/limits) — database and value size, bound parameters, and query performance - [Stateful Actor SQL](/docs/edge-compute/stateful-actors/guides/storage/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.` (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](/docs/edge-compute/sqldb/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. ```ts interface SqlDatabase { prepare(query: string): SqlPreparedStatement; batch(statements: SqlPreparedStatement[]): Promise[]>; exec(query: string): Promise; } interface SqlPreparedStatement { bind(...values: unknown[]): SqlPreparedStatement; first(column: string): Promise; first(): Promise; run(): Promise>; all(): Promise>; raw(options?: { columnNames?: boolean }): Promise; } interface SqlQueryResult { results: T[]; success: boolean; meta: SqlQueryResultMeta; } interface SqlQueryResultMeta { duration: number; rows_read: number; rows_written: number; last_row_id: number; changes: number; } interface SqlExecResult { count: number; duration: number; } ``` 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](#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. ```ts // The id of a new row const row = await env.DB .prepare("INSERT INTO users(email) VALUES (?) RETURNING id") .bind("alice@example.com") .first<{ id: number }>(); const newId = row?.id; // Exactly which rows an UPDATE touched, not just how many const { results } = await env.DB .prepare("UPDATE users SET active = 0 WHERE last_seen < ? RETURNING id") .bind(cutoff) .run<{ id: number }>(); const changed = results.length; ``` 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: ```ts const [, counted] = await env.DB.batch<{ n: number }>([ env.DB.prepare("DELETE FROM sessions WHERE expires_at < ?").bind(Date.now()), env.DB.prepare("SELECT changes() AS n"), ]); const deleted = counted.results[0].n; ``` ## `prepare(query)` Build a statement. Synchronous, and safe to call once and reuse. ```ts const stmt = env.DB.prepare("SELECT id, email FROM users WHERE org = ?"); const acme = await stmt.bind("acme").all<{ id: number; email: string }>(); const globex = await stmt.bind("globex").all<{ id: number; email: string }>(); ``` 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. ```ts const stmt = env.DB .prepare("INSERT INTO events(name, at, payload) VALUES (?, ?, ?)") .bind("signup", Date.now(), null); ``` 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. | JavaScript value | Bound as | Read back as | |---|---|---| | `number` | `REAL` affinity — see below | `number` | | `string` | `TEXT` | `string` | | `null` | `NULL` | `null` | | `undefined` | `NULL` (accepted; D1 rejects it) | `null` | | `ArrayBuffer` | `BLOB` | `ArrayBuffer` over the binding; a JSON array of byte values over REST and the CLI | | `boolean` | **throws** `Provided value cannot be bound to SQLite parameter N` | — | | `Uint8Array` and other typed-array views | **throws** `Unknown named parameter` — pass the underlying `ArrayBuffer` | — | | `bigint` | **throws** `Do not know how to serialize a BigInt` | — | | plain object | **throws** `Unknown named parameter` | — | Store a boolean as `0`/`1` and binary as an `ArrayBuffer`: ```ts const bytes = new Uint8Array([9, 8, 7]); await env.DB .prepare("INSERT INTO flags(active, blob) VALUES (?, ?)") .bind(isActive ? 1 : 0, bytes.buffer) // .buffer, not the view .run(); ``` `.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](/docs/edge-compute/stateful-actors/guides/storage/sql), 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](/docs/cloud-storage/bindings) and bind the key. A bound number arrives with **`REAL` affinity** — `SELECT 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`. ```ts // Whole row, or null when the query matched nothing const user = await env.DB .prepare("SELECT id, email FROM users WHERE id = ?") .bind(1) .first<{ id: number; email: string }>(); // One column, as a bare value const total = await env.DB.prepare("SELECT COUNT(*) AS n FROM users").first("n"); ``` `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. ```ts const { results, success } = await env.DB .prepare("SELECT id, email FROM users WHERE org = ?") .bind("acme") .all<{ id: number; email: string }>(); // results -> [{ id: 1, email: "alice@example.com" }, ...] ``` `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. ```ts const rows = await env.DB.prepare("SELECT id, email FROM users").raw(); // [[1, "alice@example.com"], [2, "bob@example.com"]] const withHeader = await env.DB.prepare("SELECT id, email FROM users").raw({ columnNames: true }); // [["id", "email"], [1, "alice@example.com"], [2, "bob@example.com"]] ``` `{ 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. ```ts const [debited] = await env.DB.batch<{ cents: number }>([ env.DB .prepare("UPDATE accounts SET cents = cents - ? WHERE id = ? RETURNING cents") .bind(500, "a"), env.DB.prepare("UPDATE accounts SET cents = cents + ? WHERE id = ?").bind(500, "b"), ]); const remaining = debited.results[0]?.cents; ``` 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..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. ```ts const { count } = await env.DB.exec(` CREATE TABLE IF NOT EXISTS users(id INTEGER PRIMARY KEY, email TEXT UNIQUE); CREATE INDEX IF NOT EXISTS users_by_email ON users(email); `); // count -> 2 ``` `count` is the number of statements in the script. Passing bind arguments throws `env..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: ```text actor invocation __telnyx_sqldb/.sql returned 500: error invoke actor method: rpc error: code = Internal desc = ... (500) {"error":"method_failed", "message":"Error: UNIQUE constraint failed: users.email","name":"Error"} ``` 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. ```ts try { await env.DB .prepare("INSERT INTO users(email) VALUES (?)") .bind(email) .run(); } catch (err) { const message = err instanceof Error ? err.message : String(err); if (message.includes("UNIQUE constraint failed")) { return new Response("email already registered", { status: 409 }); } throw err; } ``` 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](/docs/edge-compute/sqldb/limits). ## Related - [Overview](/docs/edge-compute/sqldb/reference) — the SQL Databases Runtime API surface at a glance - [Quick Start](/docs/edge-compute/sqldb/quick-start) — declare the binding and type it - [How SQL Databases Work](/docs/edge-compute/sqldb/concepts/how-sqldb-works) — one primary per database, and where the data lives - [Migrations](/docs/edge-compute/sqldb/migrations) — versioned schema changes from the CLI - [Limits](/docs/edge-compute/sqldb/limits) — statement size, parameter, and duration ceilings - [Stateful Actor SQL](/docs/edge-compute/stateful-actors/guides/storage/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 ```bash # List databases telnyx-edge storage sqldb list # Create a database (name: lowercase letters, numbers, hyphens) telnyx-edge storage sqldb create --name links-db # Get one database by id telnyx-edge storage sqldb get # Delete a database by id — no confirmation prompt telnyx-edge storage sqldb delete ``` `create` returns immediately with `status: pending`; provisioning finishes in the background, typically in 2 to 11 seconds: ``` ✓ SQL database 'links-db' created — provisioning runs in the background FIELD VALUE ------------ ------------------------------ SQLDB ID 550e8400-e29b-41d4-a716-446655440000 Name links-db Status pending Created At Jul 29, 2026, 14:52 Use 'telnyx-edge storage sqldb get 550e8400-e29b-41d4-a716-446655440000' to check when it is ready. ``` Poll `get` until the status is `provision_ok`: ``` FIELD VALUE ------------ ------------------------------ SQLDB ID 550e8400-e29b-41d4-a716-446655440000 Name links-db Status provision_ok Created At Jul 29, 2026, 14:52 Updated At Jul 29, 2026, 14:52 ``` `list` paginates, newest first: ``` SQLDB ID NAME STATUS CREATED AT ------------------------------------ -------------------- --------------- ----------------- 7c9e6679-7425-40de-944b-e07fc1f90ae7 analytics provision_ok Jul 29, 2026, 15:11 550e8400-e29b-41d4-a716-446655440000 links-db provision_ok Jul 29, 2026, 14:52 Page 1 of 1 (showing 2 of 2 total SQL databases) ``` `delete` takes effect immediately and asks nothing first — there is no confirmation prompt and no `--yes` flag: ``` ✓ SQL database '550e8400-e29b-41d4-a716-446655440000' deletion started ``` 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.]` block: those functions stay deployed and their queries start failing. Remove the block and redeploy them. ### `list` Flags | Flag | Description | |------|-------------| | `--page` | Page number, 1-based (default `1`) | | `--page-size` | Items per page, `1`–`250` (default `20`) | `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](#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. ```bash # One statement, inline telnyx-edge storage sqldb execute links-db --remote \ --command "CREATE TABLE links (id INTEGER PRIMARY KEY, slug TEXT UNIQUE, url TEXT NOT NULL)" # A whole file — submitted as one multi-statement script telnyx-edge storage sqldb execute links-db --remote --file ./schema.sql # Read rows back telnyx-edge storage sqldb execute links-db --remote \ --command "SELECT id, slug, url FROM links ORDER BY id" ``` A statement that returns no rows prints a confirmation: ``` ✓ Statement executed — no rows returned ``` A read prints a table and a row count: ``` id slug url --- ---- --- 1 docs https://developers.telnyx.com 2 home https://telnyx.com 2 row(s) ``` 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 `{}`: ```bash telnyx-edge storage sqldb execute links-db --remote \ --command "SELECT slug FROM links ORDER BY id" --json ``` ```json { "results": [ { "slug": "docs" }, { "slug": "home" } ] } ``` `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 still means a SQL hex literal (`X'090807'`): `--param` carries strings, numbers, booleans and null, and JSON has no byte-array form to bind. From a function, bind an `ArrayBuffer` instead. ## Binding Values Pass values with `--param` instead of writing them into the SQL. Both flags are repeatable and fill the statement's `?` placeholders left to right, in the order they appear on the command line: ```bash telnyx-edge storage sqldb execute --remote \ --command "SELECT * FROM links WHERE slug = ? AND clicks > ?" \ --param home --param-json 10 ``` `--param` always sends a string; `--param-json` parses its argument as one JSON value, which is the only way to bind a number, boolean, or null from a shell — `--param 42` binds the text `"42"`, `--param-json 42` binds the integer. The count must equal the number of placeholders, or the call is rejected with a `422` naming both counts. Neither flag combines with `--file`: a script binds to its last statement only, so the CLI refuses rather than binding somewhere you did not choose. Bind anything that came from outside your own script — that is what keeps a quote or a `--` in the data from changing the statement. ### `execute` Flags | Flag | Description | |------|-------------| | `--command`, `-c` | SQL to run inline. Mutually exclusive with `--file`; exactly one of the two is required | | `--file`, `-f` | Path to a `.sql` file, submitted verbatim as a single multi-statement script | | `--param` | Bind a value to the next `?` placeholder, as a string. Repeatable; cannot be combined with `--file` | | `--param-json` | Bind a value parsed as one JSON value — `42`, `true`, `null`, `"text"`. Repeatable; interleaves with `--param` in command-line order | | `--remote` | **Required.** Runs against the remote database — local execution is not supported | | `--json` | Print the raw result object instead of a table | `--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](/docs/edge-compute/sqldb/limits)). A database that has not finished provisioning rejects `execute` with `409` — `Database 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. ```bash # Create a numbered migration file (offline — no auth, no network) telnyx-edge storage sqldb migrations create links-db create_links # Show which files are applied and which are pending telnyx-edge storage sqldb migrations list links-db --remote # Apply everything outstanding, in order telnyx-edge storage sqldb migrations apply links-db --remote ``` ``` ✓ Applied 0001_create_links.sql ✓ Applied 0002_add_click_count.sql 2 migration(s) applied ``` ### `migrations` Flags | Flag | Description | |------|-------------| | `--migrations-dir` | Directory holding the migration files (default `migrations/`, with every run of non-alphanumeric characters in the database argument collapsed to `_` and leading or trailing runs dropped) | | `--remote` | **Required** on `list` and `apply`; `create` is offline and does not accept it | | `--json` | Machine-readable output — `apply` emits `{"applied": [...]}`; `list` accepts it too | The full workflow — file naming, the ledger table, and what happens when a migration fails midway — is covered in [Migrations](/docs/edge-compute/sqldb/migrations). ## Generating Types `telnyx-edge types` reads the manifest and writes `telnyx-env.d.ts`, so `env.` is typed at compile time. It runs fully offline — no authentication, no network. ```toml [storage.sqldb.DB] id = "550e8400-e29b-41d4-a716-446655440000" ``` ```bash telnyx-edge types ``` ``` ✓ Generated binding types for 1 binding(s) at telnyx-env.d.ts env.DB → SqlDatabase ``` 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 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 `` 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. ## Related - [Quick Start](/docs/edge-compute/sqldb/quick-start) — create a database, bind it, query it - [Migrations](/docs/edge-compute/sqldb/migrations) — versioned schema changes and the `sqldb_migrations` ledger - [Limits](/docs/edge-compute/sqldb/limits) — request body size, query duration, and what is not enforced - [`SqlDatabase`](/docs/edge-compute/sqldb/reference/sql-database) — the `env` binding surface - [CLI Reference](/docs/edge-compute/reference/cli) — every `telnyx-edge` command --- ### Limits > Source: https://developers.telnyx.com/docs/edge-compute/sqldb/limits.md ## Limits | Limit | Value | Behavior past it | |-------|-------|------------------| | Database size | 1 GiB | Writes fail the way SQLite fails on a full disk | | One bound value | 2 MiB | `SqlParameterTooLargeError`, before the statement runs | | Bound parameters per statement | 32,766 | SQLite's variable limit | | SQL script, REST and CLI | ~4 MiB | `422` — the transport rejects the request with `stream too large`; past 8 MiB, `413` — `SQL body exceeds the maximum size` | | Rows returned by one statement | ~4 MiB | The query fails with a `ResourceExhausted … larger than max` error — on the REST path and the `env` binding alike | | Expression tree depth | 1,000 | `Expression tree is too large` | | Database name | `^[a-z0-9]([a-z0-9-]*[a-z0-9])?$`, up to 255 characters | Character-set violations, and names starting or ending on a hyphen, return `422`; past 255 characters the create returns `422` naming the length; a duplicate name returns `409` | | Bind parameters per query | must equal the `?` placeholders in the statement | `422` naming both counts, with a `/params` pointer — a short list is not padded with nulls | **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](/docs/cloud-storage/bindings) 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](/docs/edge-compute/stateful-actors/guides/storage/sql), 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. | Query | Typical | |-------|---------| | Simple statement, warm | ~10 ms round trip | | 5,000-row read | ~160 ms | 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. ```sql CREATE INDEX IF NOT EXISTS links_by_slug ON links(slug); ``` ## Related - [`SqlDatabase`](/docs/edge-compute/sqldb/reference/sql-database) — the binding surface: methods, type conversion, and errors - [How SQL Databases Work](/docs/edge-compute/sqldb/concepts/how-sqldb-works) — one primary per database - [Migrations](/docs/edge-compute/sqldb/migrations) — versioned schema changes - [CLI](/docs/edge-compute/sqldb/cli) — `execute`, `migrations`, and database management - [Stateful Actor SQL](/docs/edge-compute/stateful-actors/guides/storage/sql) — the separate per-actor SQLite database - [Edge Compute Limits](/docs/edge-compute/platform/limits) — function request timeout, memory, and body sizes --- ## API Reference (SQL (Beta)) ### sql databases - [List SQL databases](https://developers.telnyx.com/api-reference/sql-databases/list-sql-databases.md): Lists the SQL databases for the authenticated user's organization. Results use page-based pagination (`page[number]`/`page[size]`) and can be filtered and sort… - [Create a SQL database](https://developers.telnyx.com/api-reference/sql-databases/create-a-sql-database.md): Creates a new SQL database. Provisioning is asynchronous: the database is returned with status `pending` and becomes usable once it reaches `provision_ok`. - [Get a SQL database](https://developers.telnyx.com/api-reference/sql-databases/get-a-sql-database.md): Retrieves a SQL database by its ID, including its provisioning status. - [Delete a SQL database](https://developers.telnyx.com/api-reference/sql-databases/delete-a-sql-database.md): Deletes a SQL database and all of the data it holds. Deletion is asynchronous and returns `202` with an empty body — the record is not removed synchronously. P… - [Run SQL against a SQL database](https://developers.telnyx.com/api-reference/sql-databases/run-sql-against-a-sql-database.md): Runs SQL against the database and returns the resulting rows — empty for statements that return none, such as DDL. Bind positional `?` placeholders with `param…