Expand/contract migrations
Expand/contract is a pattern for changing columns without downtime. Instead of altering a column in place (which may lock the table or lose data), you:
- Add a new column alongside the old one (fast — no row scan).
- A trigger mirrors writes from the old column into the new one.
- Backfill existing rows out-of-band (
schema-flow backfill). - Switch the application to the new column.
- Drop the old column (
schema-flow contract --allow-destructive).
The same invariant — new IS DISTINCT FROM transform(old) — gates the trigger, the backfill loop, and the contract check. It is null-safe by construction, so nullable source columns and identity-transform renames behave correctly.
Defining an expand
Section titled “Defining an expand”Add expand to a column in your table YAML:
table: userscolumns: - name: id type: uuid primary_key: true - name: email type: text nullable: false - name: email_lower type: text expand: from: email transform: 'lower(email)' reverse: 'email_lower' # optional — bidirectional dual-write batch_size: 5000 # optional — backfill batch size, default 1000Expand fields
Section titled “Expand fields”| Field | Type | Required | Description |
|---|---|---|---|
from |
string | yes | Source column name |
transform |
string | yes | SQL expression to compute new from old (use the source column for rename) |
reverse |
string | no | SQL expression to compute old from new — enables bidirectional dual-write |
batch_size |
number | no | Backfill batch size (default: 1000) |
Workflow
Section titled “Workflow”Step 1: Expand (run)
Section titled “Step 1: Expand (run)”npx @smplcty/schema-flow runThis:
- Creates the new column (
email_lower). - Installs a guarded dual-write trigger that mirrors
emailwrites intoemail_lower. Direct writes toemail_lowerare preserved (the trigger only fires when the source column actually changes). - Records state in
_smplcty_schema_flow.expand_state.
No row scan, no lock contention — run stays fast regardless of table size.
Step 2: Backfill (out-of-band)
Section titled “Step 2: Backfill (out-of-band)”npx @smplcty/schema-flow backfillDrains pending backfills in batches. Idempotent and resumable: safe to kill and restart, safe to run multiple times. Background it via your OS of choice:
nohup schema-flow backfill > backfill.log 2>&1 &disownFilter to a specific scope and/or parallelise:
schema-flow backfill --table usersschema-flow backfill --column users.email_lowerschema-flow backfill --concurrency 4Step 3: Monitor
Section titled “Step 3: Monitor”npx @smplcty/schema-flow expand-statusShows in-progress migrations along with the per-state count of rows still pending backfill:
public.users.email → email_lower: expanded — 1,234 row(s) remainingStep 4: Switch application
Section titled “Step 4: Switch application”Deploy the app reading + writing the new column. Code that still writes the old column stays in sync via the trigger. Code that writes the new column directly is preserved — the trigger no longer clobbers explicit writes.
Step 5: Contract
Section titled “Step 5: Contract”Once backfill has completed:
npx @smplcty/schema-flow contract --allow-destructivecontract verifies the invariant count(*) WHERE new IS DISTINCT FROM transform(old) = 0 and refuses if any row diverges:
ERROR: 1,234 row(s) still satisfy `email_lower IS DISTINCT FROM (lower(email))`. Run `schema-flow backfill` to complete, then re-run contract.Bypass intentionally (rare — only when you accept losing source values for diverged rows):
npx @smplcty/schema-flow contract --allow-destructive --force --i-understand-data-lossStep 6: Clean up YAML
Section titled “Step 6: Clean up YAML”Remove the expand: block (and the old column entry) from the table file. The next run is a no-op.
State tracking
Section titled “State tracking”Expand state lives in _smplcty_schema_flow.expand_state:
| Column | Description |
|---|---|
table_name |
Qualified schema.table |
new_column |
New column name |
old_column |
Source column name |
transform |
SQL transform expression |
trigger_name |
Generated trigger name |
status |
expanded or contracted |
created_at |
When the state row was inserted |
The row is inserted automatically by schema-flow run when the dual-write trigger is installed.
Example: zero-downtime column rename
Section titled “Example: zero-downtime column rename”Identity transform — copy with no change. Works for nullable sources without infinite-looping or stranding rows.
- name: middle_name_v2 type: text expand: from: middle_name transform: middle_name # identity → renameOperator sequence:
schema-flow run # adds column + trigger, fastnohup schema-flow backfill > backfill.log 2>&1 & # drain off-peakschema-flow expand-status # check progress# ... deploy app reading + writing middle_name_v2 ...schema-flow contract --allow-destructive # drops middle_name once verified# ... remove `expand:` from YAML ...Example: change column type
Section titled “Example: change column type”- name: amount_numeric type: numeric(10, 2) expand: from: amount transform: 'amount::numeric(10,2)'Example: bidirectional dual-write
Section titled “Example: bidirectional dual-write”Useful during long rolling deploys when both old and new code coexist:
- name: price_dollars type: numeric expand: from: price_cents transform: 'price_cents / 100.0' reverse: 'price_dollars * 100'Writes to either column propagate to the other, so old readers and new readers both see consistent data.