> ## Documentation Index
> Fetch the complete documentation index at: https://developers.telnyx.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Migrations

> Version a SQL database's schema with numbered .sql files applied by telnyx-edge storage sqldb migrations create, list --remote, and apply --remote, tracked in a sqldb_migrations table inside the database itself.

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 <database> <name>`  | Writes a new numbered file                   | None — fully offline |
| `migrations list <database> --remote`  | Marks each local file `pending` or `applied` | Reads the database   |
| `migrations apply <database> --remote` | Runs every pending file, in order            | Writes the database  |

<Note>
  `--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.
</Note>

## A Worked Example

Start with a provisioned database named `links-db`. Create the first migration:

```bash theme={null}
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 theme={null}
-- 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 theme={null}
telnyx-edge storage sqldb migrations create links-db add_click_count
```

```
✓ Created migrations/links_db/0002_add_click_count.sql
```

```sql theme={null}
-- 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 theme={null}
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 theme={null}
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 theme={null}
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 theme={null}
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 <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.

<Note>
  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.
</Note>

## 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`:

```
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 theme={null}
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 theme={null}
telnyx-edge storage sqldb migrations apply links-db --remote --json
```

```json theme={null}
{
  "applied": [
    "0001_create_links.sql",
    "0002_add_click_count.sql"
  ]
}
```

`list --json` emits one entry per local file:

```json theme={null}
[
  {
    "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
