Expand-contract migrations
Two helpers that turn a single risky DDL into a paired expand → contract plan. The expand phase adds the new shape and back-fills from the old; the contract phase drops the old once every running version is on the new shape.
Why expand-contract
Renaming a column or changing its type as a single migration breaks every application instance that hasn't redeployed yet — the old binary is reading full_name while the database now only has name. The expand-contract pattern eliminates that window: both columns exist for a stretch, applications update at their own pace, and the destructive cleanup happens in a second migration once every version has switched.
The risk model flags renames and type changes as high risk specifically because the naive form has this window — these helpers are the way out.
Renaming a column — expandContractRename
import { expandContractRename } from 'zanith'; const [expand, contract] = expandContractRename({ table: 'users', from: 'full_name', to: 'name', type: 'text', destructivePolicy: 'archive', // 'soft_drop' | 'archive' | 'drop' retentionDays: 30, // for soft_drop / archive notNullAfter: true, // mark new column NOT NULL after backfill}); // expand: addColumn name (nullable) → backfill name = full_name → alter NOT NULL// contract: archiveColumn full_name (or softDrop / drop based on policy)Options
| Option | Default | Effect |
|---|---|---|
table | required | Table being modified. |
from | required | Existing column name. |
to | required | New column name. |
type | required | SQL type for the new column. |
destructivePolicy | 'archive' | What the contract phase does to the old column. Archive moves rows to an artifact; soft-drop keeps the column with a tombstone; drop is irreversible. |
retentionDays | engine default | How long the artifact survives before purgeOlderThan collects it. |
notNullAfter | false | After backfill, alter the new column to NOT NULL. |
idPrefix | undefined | Optional YYYYMMDDHHMMSS prefix for the generated migration ids. |
Changing a column's type — expandContractTypeChange
import { expandContractTypeChange } from 'zanith'; const [expand, contract] = expandContractTypeChange({ table: 'orders', column: 'total', newType: 'numeric(12, 2)', using: '"total"::numeric', // optional USING expression validate: true, // CHECK constraint in expand, dropped in contract});| Option | Default | Effect |
|---|---|---|
table | required | Table being modified. |
column | required | Column whose type is changing. |
newType | required | Target SQL type. |
using | | Postgres USING clause for the cast. |
validate | false | Add a CHECK constraint in the expand phase to validate existing rows; drop it in contract. |
idPrefix | undefined | Optional id prefix. |
The deployment shape
| Step | What runs | Why |
|---|---|---|
1. Apply expand | New column added, backfill runs, both columns coexist. | Old binaries keep working — they read the old column. |
| 2. Deploy app code | Application now writes both columns; reads from new. | Both shapes valid — the database is forgiving. |
| 3. Wait | All instances roll forward. | Confirm via dashboards / logs / canary that no instance is still on the old code. |
4. Apply contract | Old column archived / soft-dropped / dropped. | Only new code is running, so dropping the old column is safe. |
| 5. (Optional) Recover | If step 4 was archive / soft-drop, the column can still be restored. | Nothing is permanent until purgeArtifact runs. |
Related
Risk model for which ops require expand-contract. Recovery for what archive / soft-drop look like in practice. Preflight for the checks that catch data shape problems before the expand phase runs.