Db migration safe
Reviews and safely applies SQL schema migrations. Detects locking and blocking hazards (table rewrites, ACCESS EXCLUSIVE locks, non-concurrent indexes, validating constraints, unsafe NOT NULL / type changes), rewrites unsafe DDL into zero-downtime multi-step migrations, generates rollbacks, and gates execution behind a plan-validate-execute workflow. Use when creating, reviewing, or running a database migration; altering a table, column, index, or constraint; or when the user mentions ALTER TABLE, CREATE INDEX, schema change, migration, zero-downtime, locking, downtime, Postgres, or MySQL.From its SKILL.md
npx -y skills add alexbobkovv/db-migration-safeAssembled from the repository path, not quoted from the project. Check it against their README if it does not work.
One thing to look at
- 0 stars0 stars. Stars are a popularity signal and not a quality one, but at this level it is likely that nobody has read this closely except its author, and you would be relying on your own review.
SKILL.md
11.6 KB, ~2.8k tokens by cl100k_base, as published. Nobody here has run it
db-migration-safe
Make SQL schema migrations safe. This skill detects locking/blocking hazards in a migration, rewrites unsafe DDL into zero-downtime multi-step migrations, generates a rollback, and gates the actual run behind a plan → validate → execute workflow.
Postgres is the strong, deterministic path. MySQL is an honest hybrid (see
references/mysql-catalog.md) — materially weaker, and labeled as such.
This skill shells out to external linters (squawk, eugene) and OSC tools
(gh-ost/pt-osc). It does not bundle them. If they are missing, install per
references/tool-setup.md. Static analysis (squawk + eugene lint) needs no DB;
eugene trace and EXECUTE need a Postgres.
When to use / when NOT to use
Use for any DDL: ALTER TABLE, CREATE INDEX, ADD/DROP COLUMN, type changes,
constraints (NOT NULL, CHECK, UNIQUE, foreign keys), renames, and backfills —
whether hand-written SQL or generated by an ORM. Do not use for pure SELECT/DML
reporting queries, or for declarative schema engines that own their own apply step
(e.g. Atlas) — those have their own safety path.
Safety contract (read first)
- PLAN and VALIDATE never touch the user's database. They are analysis only and may run freely.
eugene traceruns the migration against an ephemeral throwaway Postgres (or a disposable clone) and rolls back by default. It never touches production.- EXECUTE applies real DDL to a real database. Never run EXECUTE unless the user
explicitly asks to apply / run / ship the migration. Always dry-run first, always
wrap in
lock_timeout, never runCONCURRENTLYinside a transaction. EXECUTE is procedural guidance the user drives — there is no auto-execute script.
Note on auto-invocation: this is a single skill, so its frontmatter governs the whole file. PLAN/VALIDATE are safe to auto-trigger, so model-invocation is left enabled. The EXECUTE phase is gated behaviorally by the rule above. If you would rather make the entire skill user-triggered only, add
disable-model-invocation: trueto the frontmatter — it will require explicit invocation for analysis too.
PLAN
Run the static analyzer on the migration file. No database is required.
python3 scripts/analyze.py path/to/migration.sql --dialect postgres
# add --pg-version 13.0 to gate version-specific rules
# add --json for machine-readable output (CI)
analyze.py runs squawk and eugene lint, normalizes both into one verdict, and
exits nonzero if any error-level finding is present (so it doubles as a CI gate).
For --dialect mysql it applies the built-in InnoDB Online DDL heuristics instead (no
free static MySQL lock-linter exists; see references/mysql-catalog.md).
If neither binary is installed (or you pass --no-external), the Postgres path falls back
to a stdlib heuristic over the cataloged ops so PLAN still flags obviously-unsafe DDL with
zero install. It is banner-flagged HEURISTIC MODE / heuristic_fallback: true and is
non-authoritative — it cannot see table size, partitioning, or a cross-file validated
CHECK — so install squawk/eugene before relying on the VALIDATE gate.
Read the merged verdict:
- Findings — each has a
source(squawk/eugene/heuristic), a rule id, alevel(error/warning), the line, the message, and a workaround. - Interpretation — map rule ids to plain meaning via
references/squawk-rules.mdandreferences/eugene-hints.md. - Table size — static linters cannot tell 100 rows from 100k. Probe real size with
scripts/table_size.sql(needs DB access) before deciding whether a "slow" op is actually a problem:psql "$DATABASE_URL" -v tablename=public.orders -f scripts/table_size.sql - Probe for partitioning before indexing or constraining a table. Static linters
cannot see that a table is a partitioned parent, where
CREATE INDEX CONCURRENTLYerrors and a plainCREATE INDEXblocks writes across every partition (seereferences/postgres-catalog.md#12). They silently pass both:psql "$DATABASE_URL" -v tablename=public.events -f scripts/is_partitioned.sql - Escalate to trace when the verdict involves a possible table/index rewrite (E5,
E10), an FK without a covering index (E15), or any lock whose real duration depends on
row count.
traceobserves the actual lock mode and how long it is held:python3 scripts/trace.py path/to/migration.sql # ephemeral temp server python3 scripts/trace.py path/to/migration.sql --host db.staging --port 5432 \ --user app --database app # disposable clone
Then produce three artifacts for the user:
- a RISK REPORT (the verdict, in plain language),
- a SAFE REWRITE (next section), and
- a generated ROLLBACK (
scripts/gen_rollback.py).
Safe rewrites
For every error/warning finding, replace the unsafe statement with the cataloged
zero-downtime rewrite. The full table of 12 operations → rewrites is in
references/postgres-catalog.md (MySQL: references/mysql-catalog.md). The two Postgres
primitives behind almost every rewrite:
CONCURRENTLY— build/drop indexes without anACCESS EXCLUSIVEwrite lock (must run outside a transaction).NOT VALID→VALIDATE CONSTRAINT— add a constraint in two phases so the expensive full-table validation takes a weakSHARE UPDATE EXCLUSIVElock instead of blocking writes.
Quick reference (see catalog for the exact multi-step SQL and PG-version nuances):
| Unsafe | Safe rewrite |
|---|---|
CREATE INDEX | CREATE INDEX CONCURRENTLY (outside txn) |
ADD COLUMN ... NOT NULL [DEFAULT] | nullable col → set default → batch backfill → NOT NULL via CHECK |
SET NOT NULL | ADD CHECK (c IS NOT NULL) NOT VALID → VALIDATE → SET NOT NULL → drop check |
ADD FOREIGN KEY | ... NOT VALID → VALIDATE CONSTRAINT |
ADD CHECK | ... CHECK (...) NOT VALID → VALIDATE CONSTRAINT |
ADD UNIQUE | CREATE UNIQUE INDEX CONCURRENTLY → ADD CONSTRAINT ... USING INDEX |
ALTER COLUMN ... TYPE | expand-contract (new col → dual-write → backfill → swap → drop) |
| rename column/table | expand-contract, or compatibility VIEW |
backfill UPDATE | bounded batches in short txns, throttled |
CREATE INDEX on a partitioned table | per-partition CONCURRENTLY → CREATE INDEX ON ONLY parent → ATTACH PARTITION |
DROP INDEX on a partitioned table | bounded non-concurrent DROP INDEX on the parent (CONCURRENTLY is unavailable; children can't be dropped alone) |
Write the rewritten statements to a new file (e.g. migration.safe.sql) so it can be
re-validated.
Rollback
Generate the reverse migration from the (rewritten) DDL:
python3 scripts/gen_rollback.py migration.safe.sql > migration.rollback.sql
gen_rollback.py emits the inverse of every recognized cataloged op
(ADD COLUMN→DROP COLUMN, CREATE INDEX CONCURRENTLY→DROP INDEX CONCURRENTLY, …).
Review the output:
- Irreversible ops (
DROP COLUMN,DROP TABLE, type narrowing) cannot be restored from DDL alone. The generator marks them and emits the backup-table SQL that the forward migration must run first (<table>_<col>_backup_<date>) plus a best-effort restore. It never emits a silently-wrong inverse. - Unknown statement shapes produce an explicit
manual rollback required: <reason>comment, not a guess.
VALIDATE
This is the gate. Re-run the analyzer on the rewritten migration:
python3 scripts/analyze.py migration.safe.sql --dialect postgres
Only proceed when it passes with zero error-level findings (warnings are
configurable via .squawk.toml / -- eugene: ignore). If it still flags errors, return
to Safe rewrites and loop. Re-run trace if the rewrite changed which locks are taken.
EXECUTE (gated — user-triggered only)
Do not enter this phase unless the user explicitly asked to apply the migration.
Apply the validated migration with locking guardrails. Dry-run first.
- Bound every lock. Wrap blocking statements so they fail fast instead of queueing
behind a long transaction and stalling all traffic:
SET lock_timeout = '5s'; SET statement_timeout = '0'; -- or a sane cap for the specific op CONCURRENTLYruns outside a transaction. Do not putCREATE/DROP INDEX CONCURRENTLYin aBEGIN/COMMITblock or a transactional migration runner. After a failed concurrent build, drop the leftoverINVALIDindex before retrying.- Two-phase constraints. Run the
NOT VALIDstep, let it commit, thenVALIDATE CONSTRAINTin a separate statement. - Batched backfills. Update in bounded chunks in separate short transactions, with a
short sleep between batches to let replication and autovacuum keep up:
-- repeat until 0 rows affected UPDATE t SET col = expr WHERE id IN (SELECT id FROM t WHERE col IS NULL ORDER BY id LIMIT 5000) - Dry-run the whole sequence against the ephemeral/clone via
scripts/trace.pyone last time, confirmpassed_all_checks, then apply to the target. Note: eugene traces the script in a single transaction, soCREATE/DROP INDEX CONCURRENTLYcannot be traced (Postgres forbids it in a transaction) — trace the non-concurrent statements and rely on static lint for theCONCURRENTLYstep (it takes only aSHARE UPDATE EXCLUSIVElock).
For MySQL, force ALGORITHM=…, LOCK=… so the server errors instead of silently falling
back to a COPY rebuild, and delegate large rewrites to gh-ost/pt-osc — see
references/mysql-catalog.md.
Reference index
references/postgres-catalog.md— the 12 unsafe ops → safe rewrites (core knowledge).references/mysql-catalog.md— InnoDB Online DDL matrix + OSC delegation (weaker path).references/squawk-rules.md— squawk rule ids and what each catches.references/eugene-hints.md— eugene E1–E15 / W12–W14 and their workarounds.references/tool-setup.md— installing squawk / eugene / gh-ost; ephemeral Postgres.
Scripts
scripts/migrate_safe.py— one dispatcher overanalyze/trace/rollback(migrate-safe analyze … | trace … | rollback …); the shared entry for CI, pre-commit, and this skill. Each subcommand keeps its own flags and exit codes.scripts/analyze.py— orchestrates squawk + eugene lint (or MySQL heuristics) → one verdict; stdlib heuristic fallback when no binaries are installed. Exits nonzero on error-level findings.scripts/trace.py— runseugene trace(ephemeral temp server or clone) → real lock report.scripts/gen_rollback.py— generates the reverse migration; flags irreversible ops.scripts/table_size.sql—pg_class.reltuplessize probe.scripts/is_partitioned.sql— detects a partitioned parent + lists partitions (catalog #12).
All scripts are Python 3 standard library only — no pip install.
What ships with it: 52 files
1668.7 KB alongside SKILL.md, 15 of them executable
assets/
- demo.gif814.6 KB
- social-preview.png675.7 KB
evals/
- cases/01_add_column_not_null_default.sql457 B
- cases/02_bare_create_index.sql294 B
- cases/03_alter_column_type.sql345 B
- cases/04a_index_concurrently.sql283 B
- cases/04b_add_column_nullable.sql254 B
- cases/04c_set_default.sql261 B
- cases/04d_add_check_not_valid.sql341 B
- cases/04e_validate_constraint.sql257 B
- cases/04f_set_not_null.sql456 B
- cases/04g_expand_add_column.sql344 B
- cases/05_rollback_input.sql935 B
- cases/06_mysql_type_change.sql393 B
- cases/07_quoted_identifiers.sql632 B
- cases/08_partitioned_index_safe.sql1.4 KB
- cases/08_partitioned_index_unsafe.sql847 B
- cases/09_partitioned_drop_index_safe.sql955 B
- cases/09_partitioned_drop_index_unsafe.sql1.1 KB
- eval.md16.3 KB
references/
- eugene-hints.md5.0 KB
- mysql-catalog.md4.4 KB
- postgres-catalog.md12.8 KB
- squawk-rules.md4.2 KB
- tool-setup.md3.5 KB
scripts/
- analyze.pyruns22.5 KB
- gen_rollback.pyruns11.4 KB
- is_partitioned.sql1.1 KB
- migrate_safe.pyruns2.0 KB
- table_size.sql989 B
- trace.pyruns7.1 KB
tests/
- action.yml3.8 KB
- CHANGELOG.md6.1 KB
- CONTRIBUTING.md3.8 KB
- .gitignore158 B
- LICENSE11.1 KB
- .pre-commit-hooks.yaml308 B
- README.md10.3 KB
12 more files not listed here. See all 52 in the repository.
Gives 0 of the 12 instructions most databases sql skills give in ~2.8k tokens
Counted across 589 of the 662 authors here whose files we hold, read 2026-08-07
- Use parameterized queriesin 37 of 589, across 34 files
- Use timestamptz for timestampsin 30 of 589, across 14 files
- Index foreign keysin 29 of 589, across 18 files
- Create indexes concurrentlyin 29 of 589, across 24 files
- Use numeric type for moneyin 25 of 589, across 8 files
- Use cursor pagination instead of offsetin 24 of 589, across 17 files
- Select only required columnsin 24 of 589, across 20 files
- Add indexes manually on foreign key columnsin 22 of 589, across 12 files
- Normalize to third normal formin 19 of 589, across 10 files
- Configure connection poolingin 19 of 589, across 17 files
- Put equality columns before range columns in indexesin 18 of 589, across 10 files
- Read individual rule files for detailed explanationsin 18 of 589, across 4 files
Said here and by no other author read
- run static analysis on the migration
- probe table size and partitioning
- escalate possible rewrites to trace
- rewrite unsafe DDL into zero-downtime steps
- write rewritten statements to a new file
- re-run analysis on the rewritten migration
Grouped from the skills themselves: near-identical wordings counted once, and counted by distinct author, so one author publishing three of these counts once. Length counted with cl100k_base; the agent that loads this file may tokenize it differently.