Skip to content
Back to skills

Database Zero Downtime Migrator

ASecurity

Generates Expand-and-Contract (Parallel Change) migration SQL as four ordered stage files plus a JSON manifest from a MigrationPlan, emitting Postgres or MySQL dialect output through a Python stdlib argparse module.

  • 2 stars
  • 0 votes
  • 0 copies
  • 1 view
  • Added September 27, 2026
developmentpythonsqlapidatabase

Works with

  • cli
  • api

Security analysis

A100/100

Scanned September 27, 2026

npx -y skills add RMAzrim/opencode-artes --skill database-zero-downtime-migrator --agent claude-code

Installs into .claude/skills of the current project.

Are you the author of Database Zero Downtime Migrator?

Add the live security badge to your README. It updates with every re-scan.

Security grade badge for Database Zero Downtime Migrator
[![Security: A — Skills Directory](https://www.skillsdirectory.com/api/skills/rmazrim-database-zero-downtime-migrator/badge)](https://www.skillsdirectory.com/skills/rmazrim-database-zero-downtime-migrator)

More formats (shields.io, HTML) on the badges page. Keep it an A: scan every change in CI with Pro.

Download with Pro
SKILL.md
---
name: Database Zero-Downtime Migrator
description: Generates Expand-and-Contract (Parallel Change) migration SQL as four ordered stage files plus a JSON manifest from a MigrationPlan, emitting Postgres or MySQL dialect output through a Python stdlib argparse module.
metadata:
  source: skills/database-zero-downtime-migrator/database-zero-downtime-migrator.md
---

# Database Zero-Downtime Migrator

## 1. System Architecture & Prerequisites

The skill generates a **four-stage Expand-and-Contract (Parallel Change)**
migration for relational schemas so that application code and schema stay
compatible at every step. It is a *SQL emitter* — it never connects to a
database. The agent runs the generated files in order.

Stage pipeline:

1. **Expand** — add new (nullable) columns; nothing breaks, reads still work.
2. **Dual-Write** — a DB trigger (or the app layer) keeps new columns in sync
   with legacy columns on every insert/update.
3. **Backfill** — batch `UPDATE` loops fill historical rows, 1000 rows/chunk.
4. **Contract** — once verification reports zero mismatches and all reads use
   the new columns, drop the legacy columns (reversible only via re-expand).

Prerequisites:

- **Python 3.9+** with the standard library only (`argparse`, `dataclasses`,
  `enum`, `json`, `pathlib`, `datetime`). No external packages.
- **Target database client** to execute emitted SQL:
  - **PostgreSQL 11+** — generated code uses `CREATE OR REPLACE FUNCTION`,
    `EXECUTE FUNCTION`, `ADD COLUMN IF NOT EXISTS`, `DROP COLUMN IF EXISTS`,
    and `DO $$ ... $$` blocks.
  - **MySQL 8.0.19+** — generated code uses `ADD COLUMN IF NOT EXISTS`,
    `DROP COLUMN IF EXISTS`, derived-table subqueries for batched `UPDATE`,
    and `DELIMITER` client directives (must be run through the `mysql`
    client, not an API driver).
- No ORM, no migration framework, no network permissions required.

## 2. Input/Output Data Contracts

### Input: `MigrationPlan` (JSON Schema)

```json
{
  "$schema": "http://json-schema.org/draft-07/schema#",
  "title": "MigrationPlan",
  "type": "object",
  "required": ["table", "add_cols", "source_cols", "pk"],
  "properties": {
    "table": { "type": "string", "description": "Physical table name" },
    "add_cols": {
      "type": "array",
      "items": {
        "type": "object",
        "required": ["name", "sql_type"],
        "properties": {
          "name": { "type": "string" },
          "sql_type": { "type": "string", "example": "VARCHAR(255)" },
          "nullable": { "type": "boolean", "default": true },
          "default": { "type": ["string", "null"], "default": null },
          "comment": { "type": "string", "default": "" }
        }
      }
    },
    "source_cols": {
      "type": "array",
      "items": { "type": "string" },
      "description": "Legacy columns that feed the new columns"
    },
    "pk": { "type": "string", "example": "id" },
    "dialect": { "enum": ["postgres", "mysql"], "default": "postgres" },
    "batch": { "type": "integer", "minimum": 1, "default": 1000 },
    "description": { "type": "string", "default": "" }
  }
}
```

### Output artifacts

| Path (relative to `--out`, default `migration/`) | Content |
| ------------------------------------------------- | ------- |
| `01_expand.sql`      | `ALTER TABLE ... ADD COLUMN` statements |
| `02_dual_write.sql`  | `CREATE OR REPLACE FUNCTION` + `CREATE TRIGGER` (or MySQL twin triggers) |
| `03_backfill.sql`    | Batched `UPDATE` loop, `batch` rows per chunk |
| `04_contract.sql`    | `ALTER TABLE ... DROP COLUMN IF EXISTS` |
| `05_verify.sql`      | Integration check: queries comparing old vs new columns |
| `manifest.json`      | Serialized plan, stage file map, verify SQL, generation timestamp |

Each `.sql` file is idempotent where the dialect permits (`IF EXISTS` /
`OR REPLACE`) and carries a header comment stating its stage and the
precondition required before it may run.

## 3. Production Reference Implementation

Save as `migrator.py` (module name `migrator`). Fully runnable, importable,
stdlib-only.

```python
#!/usr/bin/env python3
"""Zero-downtime migration SQL generator (Expand-and-Contract / Parallel Change).

Emits four ordered stage files plus a JSON manifest. Never touches the DB.
Usage:
    python migrator.py --plan plan.json --out migration
    python migrator.py --out migration          # demo 'users' plan
"""

from __future__ import annotations

import argparse
import datetime
import enum
import json
from dataclasses import asdict, dataclass, field
from pathlib import Path
from typing import Dict, List, Optional

STAGES = ("expand", "dual_write", "backfill", "contract")


class Dialect(enum.Enum):
    """Identifier quoting + dialect-specific SQL flavors."""

    POSTGRES = "postgres"
    MYSQL = "mysql"

    def quote(self, ident: str) -> str:
        if self is Dialect.MYSQL:
            return "`" + ident.replace("`", "``") + "`"
        return '"' + ident.replace('"', '""') + '"'


@dataclass(frozen=True)
class AddColumn:
    name: str
    sql_type: str
    nullable: bool = True
    default: Optional[str] = None
    comment: str = ""


APP_LAYER_DUAL_WRITE = """\
-- APP-LAYER DUAL-WRITE (alternative to the triggers below; use one or the other)
-- Legacy write path (still fine during Expand):
--   INSERT INTO users (full_name) VALUES (%s);
-- New write path (writes BOTH columns):
--   INSERT INTO users (full_name, display_name) VALUES (%s, %s);
--   UPDATE users SET display_name = full_name WHERE id = %s;
-- The trigger below enforces the same invariant for any code path that has
-- not been upgraded yet, closing the dual-write gap at the database.
"""


@dataclass
class MigrationPlan:
    table: str
    add_cols: List[AddColumn] = field(default_factory=list)
    source_cols: List[str] = field(default_factory=list)
    pk: str = "id"
    dialect: Dialect = Dialect.POSTGRES
    batch: int = 1000
    description: str = ""

    def __post_init__(self) -> None:
        if not self.add_cols:
            raise ValueError("add_cols must contain at least one AddColumn")
        if not self.source_cols:
            raise ValueError("source_cols must name at least one legacy column")

    @property
    def new_cols(self) -> List[str]:
        return [c.name for c in self.add_cols]

    def to_dict(self) -> dict:
        return {
            "table": self.table,
            "add_cols": [asdict(c) for c in self.add_cols],
            "source_cols": list(self.source_cols),
            "pk": self.pk,
            "dialect": self.dialect.value,
            "batch": self.batch,
            "description": self.description,
        }

    @classmethod
    def from_dict(cls, data: dict) -> "MigrationPlan":
        col_fields = set(AddColumn.__dataclass_fields__.keys())
        add_cols = [
            AddColumn(**{k: v for k, v in item.items() if k in col_fields})
            for item in data.get("add_cols", [])
        ]
        return cls(
            table=data["table"],
            add_cols=add_cols,
            source_cols=[str(s) for s in data.get("source_cols", [])],
            pk=data.get("pk", "id"),
            dialect=Dialect(data.get("dialect", "postgres")),
            batch=int(data.get("batch", 1000)),
            description=data.get("description", ""),
        )


def _col_pairs(new_cols: List[str], source_cols: List[str]) -> List[tuple]:
    """Pair each new column with the legacy column that feeds it.

    A single source column may feed several new ones (e.g. one raw JSONB
    source driving two typed columns); otherwise pairing is positional.
    """
    if len(source_cols) == 1 and len(new_cols) > 1:
        return [(nc, source_cols[0]) for nc in new_cols]
    if len(new_cols) != len(source_cols):
        raise ValueError(
            f"new/source column count mismatch: {len(new_cols)} vs {len(source_cols)}"
        )
    return list(zip(new_cols, source_cols))


def expand_sql(table: str, add_cols: List[AddColumn],
               dialect: Dialect = Dialect.POSTGRES) -> str:
    q = dialect.quote
    stmts = [
        "-- Stage 1 EXPAND: additive, nullable columns. Safe on live traffic."
    ]
    for c in add_cols:
        parts = [q(c.name), c.sql_type, "NULL" if c.nullable else "NOT NULL"]
        if c.default:
            parts.append("DEFAULT " + c.default)
        sql = "ALTER TABLE {} ADD COLUMN IF NOT EXISTS {};".format(
            q(table), " ".join(parts)
        )
        stmts.append(sql)
        if c.comment:
            stmts.append(
                f"COMMENT ON COLUMN {q(table)}.{q(c.name)} IS '{c.comment}';"
                if dialect is Dialect.POSTGRES
                else f"-- note: {c.comment}"
            )
    return "\n".join(stmts) + "\n"


def dual_write_trigger_sql(table: str, new_cols: List[str],
                           source_cols: List[str],
                           dialect: Dialect = Dialect.POSTGRES) -> str:
    q = dialect.quote
    pairs = _col_pairs(new_cols, source_cols)
    fn_name = f"sync_{table}_new_cols"
    trg_name = f"trg_{table}_dual_write"
    source_list = ", ".join(dict.fromkeys(sc for _, sc in pairs))

    if dialect is Dialect.POSTGRES:
        assignments = "\n".join(
            f"    NEW.{q(nc)} := NEW.{q(sc)};" for nc, sc in pairs
        )
        body = f"""\
-- Stage 2 DUAL-WRITE: trigger keeps new columns mirroring legacy columns.
-- Prerequisite: stage 1 (Expand) already applied.

CREATE OR REPLACE FUNCTION {q(fn_name)}() RETURNS trigger AS $$
BEGIN
{assignments}
    RETURN NEW;
END;
$$ LANGUAGE plpgsql;

DROP TRIGGER IF EXISTS {q(trg_name)} ON {q(table)};
CREATE TRIGGER {q(trg_name)}
    BEFORE INSERT OR UPDATE OF {source_list} ON {q(table)}
    FOR EACH ROW
    EXECUTE FUNCTION {q(fn_name)}();
"""
        return APP_LAYER_DUAL_WRITE + body

    assignments = "\n".join(f"    SET NEW.{q(nc)} = NEW.{q(sc)};" for nc, sc in pairs)
    body = f"""\
-- Stage 2 DUAL-WRITE: twin BEFORE triggers (MySQL has no column list on UPDATE).
-- Prerequisite: stage 1 (Expand) already applied.

DELIMITER $$

CREATE TRIGGER {q(trg_name + "_insert")}
BEFORE INSERT ON {q(table)}
FOR EACH ROW
BEGIN
{assignments}
END$$

CREATE TRIGGER {q(trg_name + "_update")}
BEFORE UPDATE ON {q(table)}
FOR EACH ROW
BEGIN
{assignments}
END$$

DELIMITER ;
"""
    return APP_LAYER_DUAL_WRITE + body


def backfill_sql(table: str, new_cols: List[str], pk: str, batch: int = 1000,
                 source_cols: Optional[List[str]] = None,
                 dialect: Dialect = Dialect.POSTGRES) -> str:
    if source_cols is None:
        source_cols = new_cols
    if batch < 1:
        raise ValueError("batch must be >= 1")
    q = dialect.quote
    t, p = q(table), q(pk)
    assign = ", ".join(f"{q(nc)} = {q(sc)}" for nc, sc in _col_pairs(new_cols, source_cols))
    null_clause = " AND ".join(f"{q(c)} IS NULL" for c in new_cols)

    if dialect is Dialect.POSTGRES:
        return f"""\
-- Stage 3 BACKFILL: fill historical rows in {batch}-row chunks until done.
-- Prerequisite: stage 2 (Dual-Write) already applied.
-- Idempotent: rows where the new column is already set are skipped.

DO $$
DECLARE
    _rows INT;
BEGIN
    LOOP
        UPDATE {t}
        SET {assign}
        WHERE {p} IN (
            SELECT {p}
            FROM (
                SELECT {p}
                FROM {t}
                WHERE {null_clause}
                ORDER BY {p}
                LIMIT {batch}
            ) AS _batch
        );
        GET DIAGNOSTICS _rows = ROW_COUNT;
        EXIT WHEN _rows = 0;
    END LOOP;
END $$;
"""

    return f"""\
-- Stage 3 BACKFILL: REPEAT-loop stored procedure, {batch} rows per chunk.
-- Prerequisite: stage 2 (Dual-Write) already applied.
-- NOTE: DELIMITER is a mysql-client directive; run this file through the
-- 'mysql' client, not through an API driver.

DELIMITER $$

DROP PROCEDURE IF EXISTS backfill_{table} $$

CREATE PROCEDURE backfill_{table}()
BEGIN
    REPEAT
        UPDATE {t}
        SET {assign}
        WHERE {p} IN (
            SELECT {p}
            FROM (
                SELECT {p}
                FROM {t}
                WHERE {null_clause}
                ORDER BY {p}
                LIMIT {batch}
            ) AS _batch
        );
    UNTIL ROW_COUNT() = 0 END REPEAT;
END $$

DELIMITER ;

CALL backfill_{table}();
DROP PROCEDURE backfill_{table};
"""


def contract_sql(table: str, legacy_cols: List[str],
                 dialect: Dialect = Dialect.POSTGRES) -> str:
    q = dialect.quote
    drops = "\n".join(
        f"ALTER TABLE {q(table)} DROP COLUMN IF EXISTS {q(c)};" for c in legacy_cols
    )
    return f"""\
-- Stage 4 CONTRACT: drop legacy columns.
-- PRECONDITION: 05_verify.sql returned 0 mismatches AND application reads
-- have been switched to the new columns. IRREVERSIBLE without re-running
-- the Expand stage, so gate it behind the verify query.

{drops}
"""


def integration_check_sql(table: str, new_cols: List[str],
                          legacy_cols: List[str],
                          dialect: Dialect = Dialect.POSTGRES) -> str:
    q = dialect.quote
    conditions = []
    for nc, sc in _col_pairs(new_cols, legacy_cols):
        if dialect is Dialect.POSTGRES:
            conditions.append(
                f"(COALESCE({q(nc)}::text, '') <> COALESCE({q(sc)}::text, ''))"
            )
        else:
            conditions.append(
                f"(COALESCE({q(nc)}, '') <> COALESCE({q(sc)}, ''))"
            )
        conditions.append(f"({q(nc)} IS NULL AND {q(sc)} IS NOT NULL)")
    where = "\n    OR ".join(conditions)
    return f"""\
-- Integration check: 0 rows means old and new views agree, backfill is whole,
-- and the Contract stage may run after reads switch to new columns.

SELECT COUNT(*) AS mismatches
FROM {q(table)}
WHERE {where};
"""


def plan_to_sql(plan: MigrationPlan) -> Dict[str, List[str]]:
    """Render a whole plan to ordered SQL stages.

    Returns {"expand": [...], "dual_write": [...], "backfill": [...],
    "contract": [...]}, plus "integration_check" for the verify file.
    """
    legacy = plan.source_cols
    new = plan.new_cols
    if not legacy or not new:
        raise ValueError("MigrationPlan needs both add_cols and source_cols")
    return {
        "expand": [expand_sql(plan.table, plan.add_cols, plan.dialect)],
        "dual_write": [dual_write_trigger_sql(plan.table, new, legacy, plan.dialect)],
        "backfill": [
            backfill_sql(plan.table, new, plan.pk, plan.batch, legacy, plan.dialect)
        ],
        "contract": [contract_sql(plan.table, legacy, plan.dialect)],
        "integration_check": [
            integration_check_sql(plan.table, new, legacy, plan.dialect)
        ],
    }


def _stage_header(stage: str, plan: MigrationPlan) -> str:
    return (
        f"-- ==============================================\n"
        f"-- Stage: {stage} | table: {plan.table} | dialect: {plan.dialect.value}\n"
        f"-- Generated by database-zero-downtime-migrator\n"
        f"-- ==============================================\n"
    )


def _demo_plan() -> MigrationPlan:
    return MigrationPlan(
        table="users",
        add_cols=[
            AddColumn(
                name="display_name",
                sql_type="VARCHAR(255)",
                nullable=True,
                comment="mirror of full_name to be read by the new path",
            )
        ],
        source_cols=["full_name"],
        pk="id",
        dialect=Dialect.POSTGRES,
        batch=1000,
        description="demo: split full_name into display_name",
    )


def main(argv: Optional[List[str]] = None) -> int:
    parser = argparse.ArgumentParser(
        prog="migrator",
        description="Generate Expand-and-Contract zero-downtime migration SQL.",
    )
    parser.add_argument(
        "--plan", metavar="JSON_FILE",
        help="MigrationPlan JSON; omit to use the built-in demo plan",
    )
    parser.add_argument(
        "--out", default="migration",
        help="output directory (default: migration)",
    )
    args = parser.parse_args(argv)

    if args.plan:
        with open(args.plan, encoding="utf-8") as fh:
            plan = MigrationPlan.from_dict(json.load(fh))
    else:
        plan = _demo_plan()

    sql = plan_to_sql(plan)
    out = Path(args.out)
    out.mkdir(parents=True, exist_ok=True)

    stage_files: Dict[str, Path] = {}
    for i, stage in enumerate(STAGES, start=1):
        path = out / f"{i:02d}_{stage}.sql"
        content = _stage_header(stage, plan) + "".join(sql[stage]) + "\n"
        path.write_text(content, encoding="utf-8")
        stage_files[stage] = path

    verify_path = out / "05_verify.sql"
    verify_path.write_text(
        _stage_header("verify", plan) + "".join(sql["integration_check"]),
        encoding="utf-8",
    )

    manifest = {
        "skill": "database-zero-downtime-migrator",
        "version": "1.0.0",
        "stages": list(STAGES),
        "stage_files": {k: p.name for k, p in stage_files.items()},
        "verify_file": verify_path.name,
        "verify_sql": "".join(sql["integration_check"]).strip(),
        "plan": plan.to_dict(),
        "generated_at": datetime.datetime.now(datetime.timezone.utc)
        .isoformat(timespec="seconds"),
        "run_order": (
            "1) 01_expand.sql   2) 02_dual_write.sql   3) 03_backfill.sql   "
            "4) 05_verify.sql until 0 mismatches -> switch reads to new cols "
            "-> 5) 04_contract.sql"
        ),
    }
    (out / "manifest.json").write_text(
        json.dumps(manifest, indent=2, sort_keys=False) + "\n", encoding="utf-8"
    )

    print(f"Wrote {len(stage_files) + 2} artifacts to {out}")
    for stage, path in stage_files.items():
        print(f"  - {path.name} ({stage})")
    print(f"  - {verify_path.name} (verify)")
    print(f"  - manifest.json")
    return 0


if __name__ == "__main__":
    raise SystemExit(main())
```

Importable API summary:

```python
from migrator import (
    AddColumn,
    Dialect,
    MigrationPlan,
    expand_sql,
    plan_to_sql,
    backfill_sql,
    dual_write_trigger_sql,
    contract_sql,
    integration_check_sql,
)

plan = MigrationPlan(
    table="users",
    add_cols=[AddColumn(name="display_name", sql_type="VARCHAR(255)")],
    source_cols=["full_name"],
    pk="id",
    dialect=Dialect.POSTGRES,
)
sql_by_stage = plan_to_sql(plan)  # -> dict[str, list[str]] over the 4 stages + integration_check
```

## 4. Execution Protocol & Step-by-Step Workflow

1. **Survey the schema.** Read the live DDL and identify the legacy
   column(s) to split/move and the exact data type the new column needs.
2. **Author the plan.** Either write a `MigrationPlan` JSON file or use the
   demo plan. Keep `add_cols` **nullable** during expansion — a NOT NULL
   column cannot be added without a default on a live, large table.
3. **Generate.** Run `python migrator.py --plan plan.json --out migration`
   (or write your own `main` via `plan_to_sql`). Inspect the manifest for
   dialect, batch size, and run order.
4. **Expand.** Apply `01_expand.sql`. Confirm `\d <table>` (psql) or
   `SHOW CREATE TABLE <table>` shows the new column as NULL.
5. **Dual-Write.** Apply `02_dual_write.sql`. If your app layer already
   writes both columns, skip the triggers and keep only the app path — never
   run both without ensuring they agree.
6. **Backfill.** Apply `03_backfill.sql`. Watch for long locks on huge
   tables; in that case lower `batch` and schedule off-peak.
7. **Verify.** Run `05_verify.sql` repeatedly. It must return `0` before any
   Contraction. If mismatches persist, fix the trigger/app dual-write first.
8. **Cut over reads.** Deploy app code reading only the new columns; the
   legacy columns are now write-orphaned.
9. **Contract.** After observing a full read+write cycle (recommend ≥ one
   release), apply `04_contract.sql`. Update the manifest to mark the skill
   complete.

## 5. Edge Cases & Error Handling

- **Trigger syntax errors on MySQL** — MySQL has no `UPDATE OF <col>` clause
  and no `DO` blocks; the generated twin triggers and `DELIMITER` procedure
  are the only portable path. Always run MySQL scripts through the `mysql`
  client so `DELIMITER` is honored.
- **Pre-existing non-NULL data** — the backfill loop skips rows where the new
  column is already set. If rows were pre-populated with the *wrong* values,
  the verify query surfaces them (`<>` comparison) instead of overwriting.
- **MySQL 5.7 lacks `IF EXISTS`** — fall back to plain `ADD COLUMN` /
  `DROP COLUMN` and tolerate transient errors; or document a manual check.
- **Locking/long-running backfill** — keep `batch` modest (default 1000),
  rely on `ORDER BY pk LIMIT` so each chunk is index-driven, and run during a
  low-traffic window. On Postgres, prefer smaller batches over `LOCK TABLE`.
- **`COMMIT` inside PL/pgSQL** — cannot be issued inside a `DO` block; the
  generated code leaves per-statement autocommit, which is correct here.
- **Count mismatch between new/source columns** — `_col_pairs` raises a clear
  `ValueError` before any file is written; re-check the plan JSON.
- **Reserved words / mixed-case identifiers** — all identifiers are quoted
  per dialect (backtick vs double-quote), so reserved names survive.
- **Rollback after Contract** — dropping is irreversible without re-running
  Expand. Gate `04_contract.sql` behind the verify query and a tagged backup
  (`pg_dump -t <table>` on Postgres, `mysqldump` on MySQL).
- **Stage ordering mistakes** — the manifest's `run_order` is the agent's
  checklist; if `03_backfill.sql` runs before the trigger, new writes made
  between stage 2 and 3 are already mirrored, but anything written between
  stage 1 and 2 is caught by the verify query (`05_verify.sql` shows the
  exact drift) and corrected by a backfill re-run. If a stage fails, fix and
  re-run the failed file — every generator emits idempotent statements
  (`IF EXISTS`, `OR REPLACE`).

Attribution

Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.

Comments

Loading comments…

Newsletter

Get the weekly list of safe new skills, and the ones we pulled.