Renaming a live PostgreSQL status column without downtime
A fictional case study of moving orders.status to orders.fulfillment_state with an expand-and-contract migration.
Renaming a live PostgreSQL status column without downtime
Renaming orders.status looks like a small SQL change until the application has to survive it. One process still selects status, another writes it, a report has the name embedded in a query, and a rolling deployment leaves all of those versions connected to the same database. A direct ALTER TABLE ... RENAME COLUMN is valid SQL. It is also an immediate break for every client that has not learned the new name.
Suppose the column began as a payment status and then absorbed packing, dispatch, delivery, and return states. status now hides the domain model. The useful name is fulfillment_state, but changing the vocabulary must not change what a request, worker, or report can observe while releases overlap. The migration therefore has two jobs: introduce the new representation and keep the old contract alive long enough for every consumer to move.
Composite example: Northstar Market, its traffic, row count, deployment process, status values, and operational thresholds are fictional assumptions assembled for this case study. The PostgreSQL behavior and version notes come from the linked documentation. This example does not describe a real production migration or measured production results.
The two shapes and the meaning between them
The starting schema is intentionally familiar:
CREATE TABLE orders (
id bigint PRIMARY KEY,
customer_id bigint NOT NULL,
status text NOT NULL,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX orders_status_idx ON orders (status);
ALTER TABLE orders
ADD CONSTRAINT orders_status_check
CHECK (status IN (
'pending', 'paid', 'processing', 'shipped',
'delivered', 'cancelled', 'returned'
));The target keeps the table and primary key. Only the domain vocabulary changes:
CREATE TABLE orders (
id bigint PRIMARY KEY,
customer_id bigint NOT NULL,
fulfillment_state text NOT NULL,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);The mapping is part of the interface. Put it in the design review, code, parity query, and operational notes. In this fictional example, the mapping is:
Old status | New fulfillment_state | Meaning |
|---|---|---|
pending | queued | The order exists but work has not started. |
paid | ready_to_pack | Payment is complete and fulfillment can begin. |
processing | picking | Warehouse work is in progress. |
shipped | dispatched | The carrier has accepted the parcel. |
delivered | delivered | Delivery has been recorded. |
cancelled | cancelled | The order will not be fulfilled. |
returned | returned | A delivered order came back. |
Assume the table has no null or unmapped values, no child tables that depend on the old column, and no supported external SQL client that will remain after cutover. Those are facts to establish for a real system, not defaults supplied by PostgreSQL. Inventory views, triggers, generated queries, reporting jobs, replicas, dashboards, support scripts, and direct clients before setting the release boundaries.
The added column starts nullable. Historical rows do not have a value yet, so declaring NOT NULL during expansion would make the DDL lie about the current data. Completeness comes first. The stronger constraint comes after the copy and its checks.
Three boundaries to keep separate
A migration like this crosses three boundaries that are easy to conflate.
Application version skew. During a rolling deployment, old and new binaries share the table. The old binary knows only status; a new binary may know both. Any intermediate schema must work for both versions. That rules out deleting or renaming the old column at the start. It also rules out backfilling before every writer has the new behavior, because an old writer can make a row stale again.
Database lock acquisition. DDL is not outside normal traffic. PostgreSQL 18 documents that ACCESS EXCLUSIVE conflicts with every table lock mode, including ACCESS SHARE for ordinary reads and ROW EXCLUSIVE for inserts, updates, and deletes. A command may execute quickly after it gets the lock and still cause trouble while waiting for one. A queued request for a strong lock can also block later requests that would have been compatible with current traffic. The queue, not just the statement runtime, is part of the incident surface.
For example, a drop waits behind a reporting transaction that opened a cursor and forgot to close it. New reads arrive after the drop request, but they wait behind the queued ACCESS EXCLUSIVE request too. The drop has not changed a row, yet request latency rises. A two second lock_timeout turns that hidden queue into an observable failed attempt, giving the operator a chance to find the reporting transaction before application connections pile up. Brandur Leach's writing on large database lock casualties makes this queue behavior concrete: the waiting statement can be less dangerous than the work that queues behind it.
Data movement. Copying old rows is work proportional to the table, not a schema toggle. It consumes CPU, I/O, WAL, locks, pool connections, and autovacuum capacity. Make each batch short enough to stop. Commit progress with the rows it describes. A worker restart should resume safely, and a pause should leave valid data rather than a half-finished transaction holding locks.
Keeping these boundaries separate gives the migration a shape that can be observed and interrupted. A release can add a nullable column without starting a copy. A worker can pause without reversing an application deploy. A read switch can wait for parity instead of being tied to the completion of DDL.
The compatibility contract
For every committed order during the transition, fulfillment_state is either the mapped equivalent of status or is deliberately null while that row waits for the documented backfill. Every writer that can run in that release keeps the pair consistent for new and changed rows. Every reader tolerates the other column being present but not yet complete.
This is Martin Fowler's parallel change pattern applied to a table: expand the interface, move clients, then contract it. It is also commonly called expand and contract. The old and new shapes coexist for a while because deployment is a sequence of overlapping states, not a single instant.
The first new release may read status and leave fulfillment_state alone. Once every writer is dual writing, the copy can begin. Old readers can overlap with the new release. Old writers cannot overlap with a completed backfill unless a database-side compatibility mechanism prevents them from erasing or contradicting the new value. If an old binary performs full-row saves from a stale object, even an unrelated update can write a null or old value into the new column. Detect that behavior before starting the worker.
The diagram shows release boundaries, not one transaction. A backfill can run for hours while normal traffic continues. Each boundary should leave the previous application behavior usable until the next one has been proven.
| Point in the migration | What changes | What must be true before moving on |
|---|---|---|
| Expand | Add nullable fulfillment_state; optionally build its index. | Old binaries still work and the new column is present everywhere traffic can reach. |
| Writers | Deploy the explicit mapping and dual writes. | Every supported writer updates both columns in one transaction. |
| Backfill | Copy historical rows in resumable batches. | A final sweep finds no null or unmapped rows. |
| Verification | Validate the new constraint and compare both representations. | Repeated parity checks stay at zero during live writes. |
| Reads | Serve fulfillment_state; retain status as a sink. | All old readers have an owner and a tested application rollback exists. |
| Contract | Retire old clients, then drop the index, constraint, and column. | No supported client requires status; the drop is the irreversible boundary. |
Add the new shape without waiting on traffic
Start with the smallest DDL that gives new code somewhere to write. Do not rename, convert, and copy in the same transaction.
ALTER TABLE orders
ADD COLUMN fulfillment_state text;Use a dedicated migration connection with session-local timeouts. lock_timeout covers waiting to acquire a lock. statement_timeout covers the statement's total runtime. They are separate controls, and both should be scoped to this connection rather than applied as a global setting.
import psycopg
def expand_orders(dsn: str) -> None:
# A migration session must not be reused by request traffic.
with psycopg.connect(dsn) as conn:
conn.execute("SET lock_timeout = '2s'")
conn.execute("SET statement_timeout = '30s'")
conn.commit()
with conn.transaction():
conn.execute(
"ALTER TABLE orders "
"ADD COLUMN IF NOT EXISTS fulfillment_state text"
)The commit ends the transaction opened by the first command so the DDL block has an explicit boundary. Psycopg commits a connection context on successful exit and rolls back when an exception escapes. The migration runner should record a timeout, inspect pg_stat_activity and pg_locks, then make a deliberate retry. Raising a timeout to force the command through without identifying the blocker turns a bounded wait into an outage risk.
Indexes belong to their own decision. If new reads need an index, build it separately and check that the write and I/O cost fits the current workload. CREATE INDEX CONCURRENTLY permits concurrent inserts, updates, and deletes, but takes longer because PostgreSQL performs multiple scans and waits for relevant transactions.
import psycopg
def build_active_state_index(dsn: str) -> None:
# CONCURRENTLY is rejected inside a transaction block.
with psycopg.connect(dsn, autocommit=True) as conn:
conn.execute("SET lock_timeout = '2s'")
conn.execute("SET statement_timeout = '30s'")
conn.execute(
"CREATE INDEX CONCURRENTLY orders_fulfillment_state_active_idx "
"ON orders (fulfillment_state) "
"WHERE fulfillment_state IN ("
"'queued', 'ready_to_pack', 'picking', 'dispatched'"
")"
)A failed concurrent build can leave an invalid index behind. Check pg_index.indisvalid before retrying and drop the invalid object if appropriate. The index is optional for the data contract. Do not let an optimization dictate the migration schedule.
After expansion, deploy code that can tolerate the extra nullable column, then prove old binaries still work. The column must exist everywhere reachable by traffic, including relevant partitions or replicas where schema changes are applied separately. Only then is the application ready for dual writes.
Keep writers in sync before copying history
The dual writer should calculate one new value and send both values in the same database transaction. Make the mapping total for every value accepted by the old constraint. An unknown value should fail loudly, not become a plausible default.
from typing import Literal, cast
import psycopg
OldStatus = Literal[
"pending", "paid", "processing", "shipped",
"delivered", "cancelled", "returned",
]
FulfillmentState = Literal[
"queued", "ready_to_pack", "picking", "dispatched",
"delivered", "cancelled", "returned",
]
STATE_FOR_STATUS: dict[OldStatus, FulfillmentState] = {
"pending": "queued",
"paid": "ready_to_pack",
"processing": "picking",
"shipped": "dispatched",
"delivered": "delivered",
"cancelled": "cancelled",
"returned": "returned",
}
def set_order_status(
conn: psycopg.Connection,
order_id: int,
status: str,
) -> None:
try:
old_status = cast(OldStatus, status)
fulfillment_state = STATE_FOR_STATUS[old_status]
except KeyError as exc:
raise ValueError(f"unmapped order status: {status!r}") from exc
with conn.transaction():
conn.execute(
"""
UPDATE orders
SET status = %s,
fulfillment_state = %s,
updated_at = now()
WHERE id = %s
""",
(old_status, fulfillment_state, order_id),
)Psycopg's %s markers are placeholders, not Python string interpolation. The values stay separate from the SQL text. The same rule applies to inserts, imports, warehouse consumers, scheduled jobs, and admin tools. Update only the columns a caller owns when possible. A full-row save from an object loaded before the expansion can overwrite a newly populated value with null.
The application rollout canary should watch database write volume and error rate as well as request behavior. Dual writing adds work even when the table update remains one statement. Do not start the historical backfill until every supported writer is on this path, or until a separately tested database trigger or compatibility layer covers the writers that are not.
Run a backfill that can stop
Once current writes preserve both names, old rows can be copied. Use small committed batches, a dedicated pool, and a cursor that makes forward progress without scanning the whole table on every pass. The web pool must not compete with the worker pool. A correct update can still take down the application if workers consume every available connection.
A progress table makes the cursor durable and gives operators a place to inspect throughput:
CREATE TABLE migration_progress (
migration_name text PRIMARY KEY,
last_id bigint NOT NULL,
rows_changed bigint NOT NULL DEFAULT 0,
updated_at timestamptz NOT NULL DEFAULT now()
);Keep the progress update in the same transaction as the batch. If the transaction rolls back, the cursor must not claim those rows were copied.
from dataclasses import dataclass
from psycopg_pool import ConnectionPool
@dataclass(frozen=True)
class BatchResult:
last_id: int
rows_changed: int
BATCH_SQL = """
WITH batch AS (
SELECT id
FROM orders
WHERE id > %s
AND fulfillment_state IS NULL
AND status IN (
'pending', 'paid', 'processing', 'shipped',
'delivered', 'cancelled', 'returned'
)
ORDER BY id
LIMIT %s
FOR UPDATE SKIP LOCKED
), changed AS (
UPDATE orders AS o
SET fulfillment_state = CASE o.status
WHEN 'pending' THEN 'queued'
WHEN 'paid' THEN 'ready_to_pack'
WHEN 'processing' THEN 'picking'
WHEN 'shipped' THEN 'dispatched'
WHEN 'delivered' THEN 'delivered'
WHEN 'cancelled' THEN 'cancelled'
WHEN 'returned' THEN 'returned'
END,
updated_at = now()
FROM batch
WHERE o.id = batch.id
RETURNING o.id
)
SELECT id FROM changed ORDER BY id
"""
PROGRESS_SQL = """
INSERT INTO migration_progress (migration_name, last_id, rows_changed, updated_at)
VALUES (%s, %s, %s, now())
ON CONFLICT (migration_name) DO UPDATE
SET last_id = EXCLUDED.last_id,
rows_changed = migration_progress.rows_changed + EXCLUDED.rows_changed,
updated_at = EXCLUDED.updated_at
"""
def run_batch(
pool: ConnectionPool,
migration_name: str,
last_id: int,
batch_size: int,
) -> BatchResult:
with pool.connection() as conn:
with conn.transaction():
rows = conn.execute(BATCH_SQL, (last_id, batch_size)).fetchall()
returned_ids = [row[0] for row in rows]
next_id = max(returned_ids, default=last_id)
conn.execute(
PROGRESS_SQL,
(migration_name, next_id, len(returned_ids)),
)
return BatchResult(next_id, len(returned_ids))Keyset pagination avoids an ever-growing OFFSET. FOR UPDATE protects selected rows from conflicting writers until this short transaction ends. SKIP LOCKED lets another worker take available rows instead of waiting behind a busy one, which is useful for queue-like work but changes what the cursor means. If a lower ID is locked, a later batch can move past it. A zero-row batch can mean "everything currently visible to this query is locked", not "the table is complete".
The fulfillment_state IS NULL predicate makes retries idempotent for rows already copied. The worker should retain the same cursor on a retryable failure, and it should stop for mapping errors, statement timeouts that indicate sustained pressure, unique violations, or any other error the operator has not classified as transient.
import logging
import time
from collections.abc import Callable
from threading import Event
from psycopg_pool import ConnectionPool
logger = logging.getLogger(__name__)
TRANSIENT_SQLSTATES = {"40001", "40P01", "55P03"}
def should_retry_database_error(error: Exception) -> bool:
# Retry serialization failures, deadlocks, and lock-not-available errors.
return getattr(error, "sqlstate", None) in TRANSIENT_SQLSTATES
def run_backfill(
pool: ConnectionPool,
migration_name: str,
initial_cursor: int,
batch_size: int,
stop_event: Event,
health_ok: Callable[[], bool],
) -> None:
cursor = initial_cursor
while not stop_event.is_set():
if not health_ok():
logger.warning("pausing backfill because database health is degraded")
stop_event.wait(timeout=15)
continue
started = time.monotonic()
try:
batch = run_batch(pool, migration_name, cursor, batch_size)
except Exception as error:
if not should_retry_database_error(error):
raise
logger.warning(
"transient backfill error; retrying the same cursor",
exc_info=error,
)
stop_event.wait(timeout=2)
continue
elapsed = time.monotonic() - started
cursor = batch.last_id
logger.info(
"backfill batch committed",
extra={
"migration": migration_name,
"cursor": cursor,
"rows_changed": batch.rows_changed,
"duration_seconds": elapsed,
},
)
if batch.rows_changed == 0:
# SKIP LOCKED requires a later completeness sweep.
break
# Do not hold a pooled connection or row locks while waiting.
stop_event.wait(timeout=0.1)health_ok should use concrete signals such as replication lag, WAL volume, lock waits, autovacuum activity, disk headroom, and application latency. Batch size can shrink when those signals move in the wrong direction. A worker should pause after its current commit, not sleep inside a transaction. On shutdown, a SIGTERM handler can set stop_event, let the short batch finish or roll back, close the pool, and exit before the deployment grace period expires.
GitLab's batched background migration guidance treats this data work as a separately throttled job rather than an extension of schema DDL. That separation is useful here: operators can pause the copy without reversing a schema release.
Run a blocker query before claiming completion:
SELECT status, count(*)
FROM orders
WHERE fulfillment_state IS NULL
GROUP BY status
ORDER BY status;A result can mean a missing mapping, a writer that bypassed the release, or a false scenario assumption. Stop and investigate it. Do not choose a guessed state to make the count disappear. After workers report zero rows, run a sweep from the beginning. The sweep catches rows that were skipped by SKIP LOCKED or became eligible after an earlier cursor passed them.
Prove parity while the old read remains authoritative
The worker's row count says what it changed. It does not prove that every committed row now agrees with the mapping. Compare the representations directly, including nulls and unmapped values:
SELECT count(*) AS mismatches
FROM orders
WHERE fulfillment_state IS DISTINCT FROM CASE status
WHEN 'pending' THEN 'queued'
WHEN 'paid' THEN 'ready_to_pack'
WHEN 'processing' THEN 'picking'
WHEN 'shipped' THEN 'dispatched'
WHEN 'delivered' THEN 'delivered'
WHEN 'cancelled' THEN 'cancelled'
WHEN 'returned' THEN 'returned'
END;Run this query repeatedly while traffic is active. A zero result can be followed by a mismatch if one writer still updates only the old field. Record the timestamp, row count, deployment version, and result. Treat a rising mismatch count as a release blocker, not as a reason to hide the new value behind a fallback.
A sampled shadow read adds a second view of the same transition. The technique has the same shape as GitHub Scientist's experiment pattern: run the candidate beside the established path, compare outcomes, and keep the established result authoritative until discrepancies are understood. Keep status authoritative until the read cutover. Fetch both columns, derive the expected new value in application code, and record bounded metrics for disagreement. Do not double every production query by accident, and do not put unrelated customer data into migration logs.
from dataclasses import dataclass
from typing import cast
import psycopg
from psycopg.rows import dict_row
@dataclass(frozen=True)
class OrderColumns:
status: OldStatus
fulfillment_state: FulfillmentState | None
def read_order_with_shadow(
conn: psycopg.Connection,
order_id: int,
logger,
record_metric,
) -> OrderColumns | None:
with conn.cursor(row_factory=dict_row) as cur:
row = cur.execute(
"""
SELECT status, fulfillment_state
FROM orders
WHERE id = %s
""",
(order_id,),
).fetchone()
if row is None:
return None
status = cast(OldStatus, row["status"])
fulfillment_state = row["fulfillment_state"]
expected = STATE_FOR_STATUS.get(status)
if expected is None:
logger.error(
"unmapped status in shadow read",
extra={"order_id": order_id, "status": status},
)
record_metric("orders.status_unmapped", 1)
elif fulfillment_state != expected:
record_metric("orders.status_parity_mismatch", 1)
logger.error(
"order status columns disagree",
extra={
"order_id": order_id,
"status": status,
"fulfillment_state": fulfillment_state,
"expected": expected,
},
)
# The old field remains authoritative until read cutover.
return OrderColumns(status=status, fulfillment_state=fulfillment_state)Before switching reads, install a value constraint on the new field. NOT VALID allows PostgreSQL to add a CHECK without scanning all existing rows immediately. It still checks new inserts and updates. Validate the existing table as a separate operation:
ALTER TABLE orders
ADD CONSTRAINT orders_fulfillment_state_check
CHECK (fulfillment_state IN (
'queued', 'ready_to_pack', 'picking', 'dispatched',
'delivered', 'cancelled', 'returned'
)) NOT VALID;
ALTER TABLE orders
VALIDATE CONSTRAINT orders_fulfillment_state_check;PostgreSQL 18 documents VALIDATE CONSTRAINT with a SHARE UPDATE EXCLUSIVE lock. That is weaker than ACCESS EXCLUSIVE, but validation still reads the table and can contend for I/O or wait behind conflicting activity. Keep it separate from unrelated DDL. Once the value constraint and the non-null check are valid, apply SET NOT NULL in its own short attempt with the migration session's lock and statement timeouts.
The value check does not prove that the column is non-null, because a CHECK expression that evaluates to NULL is not a violation. Add a separate CHECK (fulfillment_state IS NOT NULL) NOT VALID, validate it, and only then promote the column to native NOT NULL. On PostgreSQL 12 and later, a validated check can let SET NOT NULL skip the full table scan.
ALTER TABLE orders
ADD CONSTRAINT orders_fulfillment_state_not_null
CHECK (fulfillment_state IS NOT NULL) NOT VALID;
ALTER TABLE orders
VALIDATE CONSTRAINT orders_fulfillment_state_not_null;Inspect the actual catalog state as well as migration files:
SELECT
a.attnum,
a.attname,
a.atttypid::regtype AS data_type,
a.attnotnull,
a.atthasmissing,
a.attisdropped
FROM pg_attribute AS a
WHERE a.attrelid = 'public.orders'::regclass
AND a.attnum > 0
ORDER BY a.attnum;pg_attribute retains a row for a dropped column and marks it attisdropped; the parser no longer accepts that name. Catalog inspection confirms what PostgreSQL has recorded. It does not tell you whether a report, job, or external client still carries the old query.
Move reads, then leave a compatibility sink
Switch reads only after repeated parity checks remain at zero, the new constraint validates, and the dependency inventory has owners. The new application release selects and returns fulfillment_state, while the dual writer continues to populate status. A feature flag can move traffic gradually, but both paths must have the same response meaning. Log which path served each request and alert on nulls or constraint failures.
Read paths include more than application handlers. Search dashboards, exports, cache keys, event payloads, support tools, SQL fragments, views, and report definitions. Dynamic query builders and external clients make a repository search incomplete. During the read rollout, keep the old field available so the application can switch back without inventing an emergency writer.
The first release after the read switch can make fulfillment_state the source field and retain status as a compatibility sink. That sink is useful only while old readers remain. Keep it until the last old binary, report, and supported SQL consumer has been retired. The longer it stays, the more opportunities there are to measure whether the old contract is still in use.
Attach fresh operational evidence to the cutover decision: dependency owners, parity results taken during live writes, shadow mismatch counts, constraint status, replica and pool health, and the command that restores the previous application version. A green query run before the latest deploy does not cover the latest writer.
Remove the old shape as a deliberate point of no return
Contract begins with clients, not DDL. Remove old binaries, old read paths, compatibility flags, and jobs that write only status. Recheck connection metadata, query logs, deployment inventory, code ownership, views, triggers, generated reports, replicas, and support tooling immediately before destructive operations. A long migration gives the dependency inventory time to become stale.
Keep transaction-permitted DDL, concurrent index operations, and the strong-lock drop in separate runner calls:
import psycopg
def set_migration_timeouts(conn: psycopg.Connection) -> None:
conn.execute("SET lock_timeout = '2s'")
conn.execute("SET statement_timeout = '30s'")
def set_not_null(dsn: str) -> None:
with psycopg.connect(dsn) as conn:
set_migration_timeouts(conn)
conn.commit()
with conn.transaction():
conn.execute(
"ALTER TABLE orders "
"ALTER COLUMN fulfillment_state SET NOT NULL"
)
conn.execute(
"ALTER TABLE orders "
"DROP CONSTRAINT IF EXISTS orders_fulfillment_state_not_null"
)
def drop_old_index(dsn: str) -> None:
# Concurrent index removal also cannot run in a transaction block.
with psycopg.connect(dsn, autocommit=True) as conn:
set_migration_timeouts(conn)
conn.execute("DROP INDEX CONCURRENTLY IF EXISTS orders_status_idx")
def drop_old_column(dsn: str) -> None:
# Attempt strong-lock DDL alone so a timeout cannot hide other work.
with psycopg.connect(dsn) as conn:
set_migration_timeouts(conn)
conn.commit()
with conn.transaction():
conn.execute(
"ALTER TABLE orders "
"DROP CONSTRAINT IF EXISTS orders_status_check"
)
conn.execute("ALTER TABLE orders DROP COLUMN status")These functions show boundaries, not a universal migration framework. The runner should record each operation and stop after a timeout so an operator can inspect blockers. Retrying at a quieter time is different from increasing the timeout until a blocked command eventually wins. DROP INDEX CONCURRENTLY needs autocommit. Dropping the column requires a strong table lock and can remove dependent indexes and constraints. If a replacement index is needed, create and observe it before this point.
After the drop, update schema snapshots, generated types, report definitions, and runbooks. PostgreSQL may still show a physical dropped-column slot in pg_attribute. That catalog detail does not make status available to SQL clients.
What can still surprise you
The examples target PostgreSQL 18, but several behaviors have version boundaries or failure states:
- A
CHECKconstraint that allows a nullable column does not prove non-nullness. Use a separate validatedCHECK (fulfillment_state IS NOT NULL)beforeSET NOT NULL, or use the current PostgreSQL not-null constraint syntax where your supported version permits it. NOT VALIDskips the initial scan only for supported constraints. The current PostgreSQL documentation includes foreign keys,CHECK, and not-null constraints.VALIDATE CONSTRAINTstill scans existing data and usesSHARE UPDATE EXCLUSIVE.
Rollback and the final check
Rollback means restoring a supported application behavior. It does not mean every database action is reversible.
| Migration point | Application rollback | Database state and condition |
|---|---|---|
| After expansion | Redeploy the old binary. | The old column remains intact; the nullable column can stay unused. |
| During dual writes or backfill | Stop workers and restore old readers. | Keep both columns. Old full-row writers must not erase the new value. |
| During validation or shadow reads | Disable the new read path or shadow sampling. | Investigate mismatches before proceeding; keep the compatibility writer. |
| After read cutover, before contract | Switch reads to status and restore the previous version. | Both names and the old writer still exist. |
| After old writers stop, before the drop | Re-enable a tested compatibility writer if it is still deployed. | Do not depend on an untested emergency patch. |
After DROP COLUMN status commits | No ordinary in-place rollback. | Restore from backup or logical export, then design another expand and contract migration. |
Before the drop
Keep the final review short and concrete:
- Name an owner for every remaining old reader and writer, including external SQL clients.
- Confirm repeated parity at live traffic, a valid new constraint, non-null completeness, and healthy replicas, pools, WAL, and disk headroom.
- Verify the application rollback command and remove the old contract from deployed binaries before attempting strong-lock DDL.
- Check for invalid concurrent indexes and inspect blockers with the same session timeouts that will protect the drop.
The difficult part of the rename is not finding a statement that changes a column name. It is preserving a contract while binaries, transactions, locks, and rows change at different speeds. Expand first, keep writers consistent, move data in work that can stop, measure parity, move reads, and remove the old shape only when no supported client needs it. Cleanup is intentionally expensive because it ends the compatibility period. That cost is what makes the earlier releases reversible.