Skip to content
Back to skills

Migration Safety

ASecurity

Safe database migrations in production — expand-and-contract, lock-safe DDL, timing estimation, rollback SQL.

  • 19 stars
  • 0 votes
  • 0 copies
  • 0 views
  • Added October 19, 2026
ai-agentspythonbashsqldatabase

Works with

  • cursor

Security analysis

A100/100

Scanned October 19, 2026

npx -y skills add sawrus/agent-guides --skill migration-safety --agent claude-code

Installs into .claude/skills of the current project.

Are you the author of Migration Safety?

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

Security grade badge for Migration Safety
[![Security: A — Skills Directory](https://www.skillsdirectory.com/api/skills/sawrus-migration-safety/badge)](https://www.skillsdirectory.com/skills/sawrus-migration-safety)

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: migration-safety
type: skill
description: Safe database migrations in production — expand-and-contract, lock-safe DDL, timing estimation, rollback SQL.
related-rules:
  - migration-runbook.md
  - backup-policy.md
allowed-tools: Read, Write, Edit, Bash
---

# Skill: Migration Safety

> **Expertise:** Expand-and-contract, `CREATE INDEX CONCURRENTLY`, migration timing estimation, rollback planning.

## When to load

When planning or executing a production database migration, estimating migration duration, or writing rollback SQL.

## Expand-and-Contract Pattern

```sql
-- ❌ DANGEROUS: direct rename locks table and breaks old app version
ALTER TABLE orders RENAME COLUMN user_id TO customer_id;

-- ✅ SAFE: expand-and-contract over multiple deploys

-- Phase 1: EXPAND (add new column, keep old)
ALTER TABLE orders ADD COLUMN customer_id BIGINT;

-- Phase 2: DUAL-WRITE (app v2 writes to both; reads from customer_id)
-- (code change, no migration needed)

-- Phase 3: BACKFILL (run in small batches to avoid lock)
UPDATE orders SET customer_id = user_id
WHERE customer_id IS NULL
  AND id BETWEEN <batch_start> AND <batch_end>;

-- Phase 4: CONTRACT (app v3 no longer uses user_id)
ALTER TABLE orders DROP COLUMN user_id;
```

## Lock-Safe DDL

```sql
-- ✅ Safe: CREATE INDEX CONCURRENTLY (no table lock)
CREATE INDEX CONCURRENTLY idx_orders_customer_id ON orders(customer_id);
-- If concurrent creation fails:
DROP INDEX CONCURRENTLY idx_orders_customer_id_invalid;
-- Then retry

-- ❌ Dangerous on large tables: full table lock
CREATE INDEX idx_orders_customer_id ON orders(customer_id);

-- ✅ Safe: ADD COLUMN with no default (instant in PostgreSQL 11+)
ALTER TABLE orders ADD COLUMN processed_at TIMESTAMPTZ;

-- ❌ Dangerous: ADD COLUMN with DEFAULT rewrites all rows (pre-PG11) / blocks (PG11+)
ALTER TABLE orders ADD COLUMN processed_at TIMESTAMPTZ NOT NULL DEFAULT now();
-- ✅ Safe alternative: add nullable, backfill, add constraint
ALTER TABLE orders ADD COLUMN processed_at TIMESTAMPTZ;
UPDATE orders SET processed_at = created_at WHERE processed_at IS NULL;
ALTER TABLE orders ALTER COLUMN processed_at SET NOT NULL;
```

## Estimating Migration Duration

```sql
-- Estimate rows to process
SELECT reltuples::BIGINT AS estimated_rows
FROM pg_class
WHERE relname = 'orders';

-- Rough timing: ~100k rows/sec for simple UPDATE on indexed column
-- ~10k rows/sec for complex JOIN-based UPDATE
-- Always test on production-size staging first!

-- Watch migration progress (PostgreSQL 9.6+)
SELECT
  phase,
  blocks_done,
  blocks_total,
  round(100.0 * blocks_done / NULLIF(blocks_total, 0), 1) AS pct_done
FROM pg_stat_progress_create_index
WHERE relid = 'orders'::regclass;

SELECT
  phase,
  tuples_done,
  tuples_total,
  round(100.0 * tuples_done / NULLIF(tuples_total, 0), 1) AS pct_done
FROM pg_stat_progress_vacuum
WHERE relid = 'orders'::regclass;
```

## Batched Backfill (avoid long transactions)

```python
# Never: UPDATE orders SET customer_id = user_id  (locks all rows, huge transaction)
# Always: batch by primary key range

import psycopg2

BATCH_SIZE = 10_000

with psycopg2.connect(DSN) as conn:
    with conn.cursor() as cur:
        cur.execute("SELECT MIN(id), MAX(id) FROM orders WHERE customer_id IS NULL")
        min_id, max_id = cur.fetchone()

    batch_start = min_id
    while batch_start <= max_id:
        batch_end = batch_start + BATCH_SIZE
        with conn.cursor() as cur:
            cur.execute("""
                UPDATE orders
                SET customer_id = user_id
                WHERE id >= %s AND id < %s AND customer_id IS NULL
            """, (batch_start, batch_end))
        conn.commit()  # commit each batch — releases row locks
        print(f"Backfilled {batch_start}–{batch_end}")
        batch_start = batch_end
```

## Rollback SQL Template

```sql
-- Every migration should have a paired rollback script

-- Migration (forward):
-- ALTER TABLE orders ADD COLUMN customer_id BIGINT;
-- CREATE INDEX CONCURRENTLY idx_orders_customer_id ON orders(customer_id);

-- Rollback:
DROP INDEX CONCURRENTLY IF EXISTS idx_orders_customer_id;
ALTER TABLE orders DROP COLUMN IF EXISTS customer_id;
```

## Pre-Migration Checklist

```bash
# 1. Take snapshot backup
pgbackrest --stanza=main --type=full backup
pgbackrest --stanza=main info   # verify backup completed

# 2. Check current lock activity (no long-running transactions)
psql -c "SELECT pid, now() - query_start AS duration, state, query
         FROM pg_stat_activity
         WHERE state != 'idle'
         ORDER BY duration DESC LIMIT 10;"

# 3. Set statement_timeout for migration session (prevent runaway)
psql -c "SET statement_timeout = '10min'; <migration_sql>"

# 4. Test rollback on staging first
```

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.