Write, test, and apply reversible database schema migrations: up/down migration anatomy, numbering conventions, apply order, rollback strategy, expand/migrate/contract zero-downtime patterns, and migration testing. Use when asked to design a migration, rename a column safely, roll back a schema change, or review a schema-change plan.
./install.sh database-migrationsname: "database_migrations" description: "Write, test, and apply reversible database schema migrations: up/down migration anatomy, numbering conventions, apply order, rollback strategy, expand/migrate/contract zero-downtime patterns, and migration testing. Use when asked to design a migration, rename a column safely, roll back a schema change, or review a schema-change plan." metadata: { "includeInPrompt": true }
Differs from agent-git because: agent-git versions code with commits; this skill versions schema with ordered up/down change sets tracked in a schema_migrations table. Differs from etl-pipelines because that moves data between systems; this changes the shape of one system's tables with reversible, ordered, tested steps.
Ship database schema changes safely: write one-change migrations with matching up/down halves, number them so apply order is deterministic, apply them in order with a tracking table, test them against fresh and seeded copies before production, and roll back or roll forward deliberately when something breaks.
One migration = one logical change (add a column, rename a column, add an index). Two halves:
sql
-- up (apply: moves schema forward)
ALTER TABLE users ADD COLUMN handle TEXT;
UPDATE users SET handle = username WHERE handle IS NULL;
-- down (rollback: restores the previous schema)
ALTER TABLE users DROP COLUMN handle;
<version>_<short_name>.sql where <version> sorts chronologically.^-- up$ / ^-- down$ markers.20260922081031_create_users.sql): default for teams; collisions across branches are rare and merges never need renumbering.0001_create_users.sql): simpler to read, but two branches adding 0004 will collide and need a manual fix at merge time.schema_migrations table so each migration applies exactly once; apply order = filename order.SELECT version FROM schema_migrations matches what you expected.DROP TABLE users with no recovery path is a red flag.When old and new app code run simultaneously during a deploy, a single rename migration breaks one of them. Split it across three migrations, deployed with the app changes interleaved:
ALTER TABLE users ADD COLUMN handle TEXT; then backfill from username. App code: write to both, read from old.UPDATE users SET handle = username WHERE handle IS NULL;), then add the constraint (handle NOT NULL). App code: write to both, read from new.ALTER TABLE users DROP COLUMN username;. App code: new only.A fully worked example (rename username → handle, verified below) ships with the runner.
When this skill is invoked, produce (in this order):
<version>_<name>.sql.schema_migrations. Never hand-edit the tracking table to skip a migration.upgrade()/downgrade() functions, autogenerate support.V1__create_users.sql), checksum validation, repeatable migrations.<version>_<name>.up.sql / .down.sql files, per-database drivers.prisma/migrations/.makemigrations/migrate, dependency graph.sqlite3 only, sqlite3 CLI 3.45.1 for inspection).Save as run_migrations.py in a directory containing a migrations/ folder, then run it.
python
#!/usr/bin/env python3
"""Dependency-free migration runner (stdlib only): apply / rollback / status.
Migration files live in ./migrations/ and are named <version>_<name>.sql,
where <version> sorts chronologically (timestamps like 20260922081031).
Each file has -- up and -- down sections of plain SQL.
"""
import os, re, sqlite3, sys
DB = os.environ.get("DEMO_DB", "/tmp/db-migrate-demo/demo.db")
MIGRATIONS = os.path.join(os.path.dirname(os.path.abspath(__file__)), "migrations")
def split_sections(text):
parts = re.split(r"(?im)^\s*--\s*(up|down)\s*$", text)
sec = {}
for i in range(1, len(parts), 2):
sec[parts[i].lower()] = parts[i + 1] if i + 1 < len(parts) else ""
return sec
def conn():
c = sqlite3.connect(DB)
c.execute("CREATE TABLE IF NOT EXISTS schema_migrations (version TEXT PRIMARY KEY, applied_at TEXT DEFAULT (datetime('now')))")
return c
def applied(c):
return [r[0] for r in c.execute("SELECT version FROM schema_migrations ORDER BY version")]
def files():
return sorted(f for f in os.listdir(MIGRATIONS) if f.endswith(".sql"))
def cmd_status():
c = conn()
done, pending = applied(c), []
for f in files():
if f[:-4] not in done:
pending.append(f)
print("applied:", done if done else "(none)")
print("pending:", pending if pending else "(none)")
c.close()
def cmd_apply():
c = conn(); done = set(applied(c)); n = 0
for f in files():
v = f[:-4]
if v in done:
continue
sql = split_sections(open(os.path.join(MIGRATIONS, f)).read()).get("up", "")
c.executescript(sql)
c.execute("INSERT INTO schema_migrations (version) VALUES (?)", (v,))
c.commit(); n += 1
print("applied", v)
print(f"{n} migration(s) applied")
c.close()
def cmd_rollback():
c = conn(); done = applied(c)
if not done:
print("nothing to roll back"); return
v = done[-1]
sql = split_sections(open(os.path.join(MIGRATIONS, v + ".sql")).read()).get("down", "")
c.executescript(sql)
c.execute("DELETE FROM schema_migrations WHERE version = ?", (v,))
c.commit()
print("rolled back", v)
c.close()
if __name__ == "__main__":
cmd = sys.argv[1] if len(sys.argv) > 1 else "status"
{"status": cmd_status, "apply": cmd_apply, "rollback": cmd_rollback}[cmd]()
username → handle (expand/migrate/contract)20260922081031_create_users.sql:
sql
-- up
CREATE TABLE users (
id INTEGER PRIMARY KEY,
username TEXT NOT NULL,
created_at TEXT DEFAULT (datetime('now'))
);
-- down
DROP TABLE users;
20260922081245_add_handle_column.sql (EXPAND):
sql
-- up
ALTER TABLE users ADD COLUMN handle TEXT;
UPDATE users SET handle = username WHERE handle IS NULL;
-- down
ALTER TABLE users DROP COLUMN handle;
20260922081356_not_null_handle.sql (MIGRATE):
sql
-- up
-- Re-backfill FIRST: rows created between expand and migrate may have NULL handle.
UPDATE users SET handle = username WHERE handle IS NULL;
-- SQLite cannot add NOT NULL to an existing column in place: recreate the table.
PRAGMA legacy_alter_table = ON;
ALTER TABLE users RENAME TO users_old;
CREATE TABLE users (
id INTEGER PRIMARY KEY,
username TEXT NOT NULL,
created_at TEXT DEFAULT (datetime('now')),
handle TEXT NOT NULL
);
INSERT INTO users (id, username, created_at, handle)
SELECT id, username, created_at, handle FROM users_old;
DROP TABLE users_old;
-- down
ALTER TABLE users RENAME TO users_new;
CREATE TABLE users (
id INTEGER PRIMARY KEY,
username TEXT NOT NULL,
created_at TEXT DEFAULT (datetime('now')),
handle TEXT
);
INSERT INTO users (id, username, created_at, handle)
SELECT id, username, created_at, handle FROM users_new;
DROP TABLE users_new;
20260922081502_drop_username.sql (CONTRACT):
sql
-- up
ALTER TABLE users DROP COLUMN username;
-- down
ALTER TABLE users ADD COLUMN username TEXT NOT NULL DEFAULT '';
UPDATE users SET username = handle;
Fresh DB, migrations 1–2 applied, two rows seeded (zuckbot, mikey), then 3–4 applied:
=== apply remaining (backfill runs here) ===
applied 20260922081356_not_null_handle
applied 20260922081502_drop_username
2 migration(s) applied
=== data: handle backfilled from username ===
1|zuckbot
2|mikey
=== rollback contract step (username restored) ===
rolled back 20260922081502_drop_username
id username handle
-- -------- -------
1 zuckbot zuckbot
2 mikey mikey
=== re-apply ===
applied 20260922081502_drop_username
1 migration(s) applied
=== final: contract complete, tracking table ===
1|zuckbot
2|mikey
20260922081031_create_users
20260922081245_add_handle_column
20260922081356_not_null_handle
20260922081502_drop_username
Rollback of the migrate step (table-recreation down path) also verified: data survived. Re-running apply after everything is applied prints 0 migration(s) applied — idempotent via schema_migrations.
publisher key 2e43c913833850e6d80f939677e3de76e806d47785c0f1caaca90f9d18c98e1e
No ratings yet — be the first to rate it.