Your Agent's RENAME COLUMN Breaks Every Old Pod. Expand-Contract in 4 Steps A developer demonstrates why a single-line `ALTER TABLE users RENAME COLUMN` migration breaks applications during a Kubernetes rolling deploy, where old and new Pods serve traffic against one schema simultaneously. The writeup shows that the rename both creates an unavoidable code-schema mismatch and takes an `ACCESS EXCLUSIVE` lock that queues every subsequent read and write behind it. The proposed fix is the four-step expand-contract (Parallel Change) pattern: add the new column, dual-write, backfill, then drop the old one. You ask a coding agent to rename users.name to full name . It updates the model, fixes every query, and writes a migration with one line in it: ALTER TABLE users RENAME COLUMN name TO full name; . The diff is tidy and the tests pass, because the tests run against one version of the code. Production runs two. A Kubernetes Deployment rolls out by scaling the new ReplicaSet up while it scales the old one down, and both maxUnavailable and maxSurge default to 25% https://kubernetes.io/docs/concepts/workloads/controllers/deployment/ . For the length of the rollout, old Pods and new Pods serve traffic side by side. Your migration runs once, just before or somewhere inside that window. Every old Pod still alive after the rename sends SELECT name ... to a table that no longer has a name column. There are two separate failures packed into that one line. The first is the code-schema mismatch. Run the rename before the new code and the old Pods fail. Ship the code first and the new Pods fail until the migration lands. No ordering makes both versions happy, which is why the strong migrations https://github.com/ankane/strong migrations README puts it bluntly: "Renaming a column that's in use will cause errors in your application." The Squawk https://github.com/sbdchd/squawk linter has a renaming-column https://squawkhq.com/docs/renaming-column rule for the same reason. The second is the lock. A rename touches no data; the Postgres docs https://www.postgresql.org/docs/current/sql-altertable.html say "there is no effect on the stored data." But ALTER TABLE takes an ACCESS EXCLUSIVE lock "unless explicitly noted," and that mode conflicts with every other lock mode https://www.postgresql.org/docs/current/explicit-locking.html , including the ACCESS SHARE lock a plain SELECT takes. If a long transaction is holding the table, the rename waits. While it waits, as Xata's write-up on the lock queue https://xata.io/blog/migrations-and-exclusive-locks explains, every later statement that needs a lock on that table queues behind it. The table is blocked for reads and writes until the rename finishes or gives up. The fix is old. Danilo Sato described it as Parallel Change https://martinfowler.com/bliki/ParallelChange.html in 2014: expand the interface to support old and new, migrate the clients, then contract back to only the new. For a column rename in Postgres, that is four steps. During a rolling deploy, two versions of your code share one schema. Start a throwaway Postgres 18 and seed a table: docker run -d --name pg -e POSTGRES PASSWORD=pw postgres:18 wait a few seconds for it to accept connections docker exec -i pg psql -U postgres <<'SQL' CREATE TABLE users id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, name text NOT NULL ; INSERT INTO users name SELECT 'user ' || g FROM generate series 1, 200000 g; SQL The "app" is two files, one per image version. Each does what a request handler would do: write a row, read it back. -- old app.sql: what the old image runs INSERT INTO users name VALUES 'Old Pod Writer' ; SELECT id, name FROM users ORDER BY id DESC LIMIT 1; -- new app.sql: what the new image runs INSERT INTO users full name VALUES 'New Pod Writer' ; SELECT id, full name FROM users ORDER BY id DESC LIMIT 1; Run either one with docker exec -i pg psql -U postgres < old app.sql . Right now old app.sql works and new app.sql fails. Apply the agent's rename and the result flips: old app.sql fails with a column-does-not-exist error. That flip is your rolling deploy. Rename the column back to name before you go on. You can also watch the lock queue. Open three terminals with docker exec -it pg psql -U postgres : BEGIN; SELECT count FROM users; and leave the transaction open. ALTER TABLE users RENAME COLUMN name TO full name; hangs, waiting for A. SELECT id FROM users LIMIT 1; hangs too, queued behind B. Cancel B with Ctrl-C and C returns at once. Now run SET lock timeout = '2s'; in B and try the rename again. B gives up after two seconds with a lock-timeout error instead of holding everyone hostage. lock timeout https://www.postgresql.org/docs/current/runtime-config-client.html "applies separately to each lock acquisition attempt," and it is the setting every migration below starts with. Roll back A when you're done. The first deploy is a migration only. No application code changes. It adds the new column and a trigger that keeps the two columns in sync, whichever one a writer touches. -- 001 expand.sql deploy 1, app code unchanged SET lock timeout = '2s'; SET statement timeout = '10s'; BEGIN; ALTER TABLE users ADD COLUMN full name text; CREATE FUNCTION users sync name RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN IF TG OP = 'INSERT' THEN NEW.full name := coalesce NEW.full name, NEW.name ; NEW.name := coalesce NEW.name, NEW.full name ; ELSIF NEW.name IS DISTINCT FROM OLD.name THEN NEW.full name := NEW.name; ELSIF NEW.full name IS DISTINCT FROM OLD.full name THEN NEW.name := NEW.full name; END IF; RETURN NEW; END $$; CREATE TRIGGER users sync name BEFORE INSERT OR UPDATE ON users FOR EACH ROW EXECUTE FUNCTION users sync name ; COMMIT; Apply it with docker exec -i pg psql -U postgres -v ON ERROR STOP=1 < 001 expand.sql . If the lock wait times out, psql exits with status 3 https://www.postgresql.org/docs/current/app-psql.html , the transaction rolls back, and you run it again: two seconds lost, instead of every query on the table stuck behind your ALTER . Three details make this safe. Adding a column with no default gives existing rows NULL, and the ALTER TABLE page says "in neither case is a rewrite of the table required," so the exclusive lock is held only briefly. The trigger is a BEFORE row trigger, so it fills name before the NOT NULL check runs, which is what lets new app.sql insert without naming the old column. And the UPDATE branch copies in one direction only, from whichever column changed, so a writer on either version never has its value overwritten by the other column. Run both app files now. Both succeed, and the last two rows have name and full name filled. Old and new code can live together, which is the whole point of the expand step. The 200,000 seed rows still have full name empty. Don't fix that with one UPDATE users SET full name = name . On a big table that is one long transaction holding row locks on every row, and strong migrations names the three keys to doing it safely: "batching, throttling, and running it outside a transaction." One batch, keyed on the primary key so each batch starts where the last one stopped: -- backfill.sql: one batch of 1,000 rows SET lock timeout = '2s'; SET statement timeout = '30s'; WITH batch AS SELECT id FROM users WHERE id :last ORDER BY id LIMIT 1000 , upd AS UPDATE users u SET full name = u.name FROM batch b WHERE u.id = b.id AND u.full name IS NULL SELECT coalesce max id , 0 FROM batch; The upd step has no RETURNING and nothing reads it, and it still runs: Postgres executes data-modifying statements in WITH https://www.postgresql.org/docs/current/queries-with.html "exactly once, and always to completion." The query prints the last id it handled, or 0 when nothing is left. A short loop drives it. psql runs in autocommit mode, so each batch is its own transaction: bash /usr/bin/env bash backfill.sh: run after deploy 1, before deploy 2 last=0 while :; do next=$ docker exec -i pg psql -U postgres -qtA \ -v ON ERROR STOP=1 -v last="$last" < backfill.sql \ || { echo "batch failed, retrying"; sleep 1; continue; } "$next" -eq 0 && break echo "backfilled through id $next" last=$next sleep 0.2 done Size the batch so each one finishes far below the 30-second statement timeout . When a batch hits a timeout, it rolls back on its own and the loop retries the same range. Then check that nothing drifted: SELECT count FROM users WHERE full name IS DISTINCT FROM name; That count has to be zero before you go on. Expand, migrate, contract: the schema gets wider before it gets narrower. Step 3 is a normal code deploy deploy 2 : the image that runs new app.sql , reading and writing only full name . During that rollout, old Pods still write name and the trigger copies it across. New Pods write full name and the trigger copies it back, so an old Pod reading name still sees rows the new Pods wrote. Until step 4, rolling back to the old image loses nothing. If the column should be NOT NULL , Postgres 18 lets you add that without a long lock. The ALTER TABLE docs https://www.postgresql.org/docs/18/sql-altertable.html allow NOT VALID on not-null constraints, and validation runs as a separate statement: SET lock timeout = '2s'; SET statement timeout = '15min'; ALTER TABLE users ADD CONSTRAINT users full name nn NOT NULL full name NOT VALID; ALTER TABLE users VALIDATE CONSTRAINT users full name nn; The statement timeout is longer than the 10 seconds used elsewhere because VALIDATE scans the whole table. It only takes a SHARE UPDATE EXCLUSIVE lock while it does, so reads and writes keep going. Step 4 waits until no released code reads name and you are past the point where you would roll back. Then it removes the old column in its own deploy: -- 002 contract.sql deploy 3, nothing reads name SET lock timeout = '2s'; SET statement timeout = '10s'; BEGIN; DROP TRIGGER users sync name ON users; DROP FUNCTION users sync name ; ALTER TABLE users DROP COLUMN name; COMMIT; DROP COLUMN is quick too. Per the same page, Postgres "does not physically remove the column"; it makes the column invisible to SQL, and the space comes back on the next table rewrite. One catch: some ORMs cache the column list at boot. strong migrations notes that Active Record "caches database columns at runtime," so on Rails you add the column to ignored columns and deploy that before you run the drop. The agent wrote the one-liner because nothing told it about the second version of the code. Tell it. Claude Code loads path-scoped rules https://code.claude.com/docs/en/memory from .claude/rules/ when it works on matching files, so this file only shows up when the agent touches a migration: --- paths: - "migrations/ / .sql" --- Postgres migration rules - Never RENAME COLUMN or RENAME TABLE on a table the running app reads. Propose expand-contract instead. - Expand migration: ADD COLUMN nullable, no volatile default plus a BEFORE INSERT OR UPDATE trigger that keeps the old and new columns in sync. - Backfill is a separate batched script keyed on the primary key, never part of the migration. - Every migration file starts with SET lock timeout and SET statement timeout. - DROP COLUMN goes in its own migration, in a later deploy, after no released code reads the column. - When asked for a rename, write out the 4-step plan and put only step 1 in this change. Other coding agents in the repo read AGENTS.md , so copy the bullets there too. Claude Code keeps loading .claude/rules/ either way, and the same docs page covers when it reads AGENTS.md at all. A rule file is advice. The docs say so directly: Claude "treats them as context, not enforced configuration." So back it with a check that does not care who wrote the SQL. Run squawk 'migrations/ .sql' in CI, or the squawk-action https://github.com/sbdchd/squawk-action on pull requests. Its renaming-column rule catches the one-liner, and require-lock-timeout https://squawkhq.com/docs/require-lock-timeout and require-statement-timeout https://squawkhq.com/docs/require-statement-timeout catch a migration that forgot its timeouts. It will also flag the drops in step 4, which is fine: that is the one migration a human should read before it ships. If you want the expand-contract bookkeeping automated, pgroll https://github.com/xataio/pgroll does the add-column, backfill and sync-trigger work for you and serves old and new schema versions through views at the same time. Knowing the four steps by hand is still what lets you review what it, or your agent, is about to run. The four steps here are what a schema change costs once a store has live traffic and more than one version of your code talking to it. I wrote the Database Playbook for the decision that comes before all of this: which store to pick for a system, knowing you will be changing its schema under real load for years. If this post made you look harder at your own migrations, the book is the wider version of that look.