Renaming a column in PostgreSQL takes a single command. But on a production system with a live application, that single command can break everything in an instant.
This article walks through why it’s risky and how to do it safely.
The Simple Way (and Why It’s Dangerous)
The straightforward approach is:
ALTER TABLE users RENAME COLUMN fname TO first_name;
This works perfectly in isolation. The problem is that your application is almost certainly referencing the old column name. The moment you run this command, every query that mentions fname will fail with:
ERROR: column "fname" does not exist
There’s no grace period. No deprecation window. The old name is gone immediately.
ALTER TABLE also acquires an ACCESS EXCLUSIVE lock, which blocks all reads and writes on the table for the duration of the operation. On a busy table this can cause a queue of waiting queries to build up very quickly.
When the Simple Way Is Fine
Before reaching for a more complex approach, consider whether you actually need one.
The simple rename is acceptable when:
- You’re in a maintenance window with traffic stopped
- The table is only accessed by a single, deployable application (so you can rename the column and deploy the code change atomically)
- The table is small and internal, with no external consumers
If any of those conditions apply, ALTER TABLE ... RENAME COLUMN is the right tool. The multi-step approach below is for situations where you cannot afford downtime or cannot coordinate a simultaneous code and schema deploy.
The Safe Approach: Expand, Migrate, Contract
The expand-contract pattern lets you rename a column without any downtime. The idea is to run both column names in parallel until the old one has no consumers left.
Step 1: Add the New Column
Add the new column alongside the old one. If the old column has a NOT NULL constraint, the new column should be nullable for now to avoid a table rewrite.
ALTER TABLE users ADD COLUMN first_name VARCHAR(100);
Step 2: Backfill Existing Data
Copy the data from the old column to the new one. For large tables, do this in batches to avoid locking rows for too long.
-- Small tables: single update
UPDATE users SET first_name = fname;
-- Large tables: batch update to reduce lock duration
DO $$
DECLARE
batch_size INT := 10000;
last_id BIGINT := 0;
BEGIN
LOOP
UPDATE users
SET first_name = fname
WHERE id > last_id
AND id <= last_id + batch_size
AND first_name IS NULL;
EXIT WHEN NOT FOUND;
last_id := last_id + batch_size;
PERFORM pg_sleep(0.01); -- brief pause between batches
END LOOP;
END $$;
Step 3: Keep Them in Sync with a Trigger
While your application is being updated, writes will still go to the old column. A trigger keeps both columns in sync during the transition.
CREATE OR REPLACE FUNCTION sync_first_name()
RETURNS TRIGGER AS $$
BEGIN
IF NEW.fname IS DISTINCT FROM OLD.fname THEN
NEW.first_name := NEW.fname;
END IF;
IF NEW.first_name IS DISTINCT FROM OLD.first_name THEN
NEW.fname := NEW.first_name;
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER trg_sync_first_name
BEFORE INSERT OR UPDATE ON users
FOR EACH ROW EXECUTE FUNCTION sync_first_name();
Step 4: Deploy the Application Update
Update your application to read from and write to first_name instead of fname. Deploy this while the trigger is keeping both columns in sync.
At this point:
- Old application instances still use
fname— the trigger keepsfirst_nameup to date - New application instances use
first_name— the trigger keepsfnameup to date
Once the deployment is complete and you’ve confirmed no references to fname remain, move to the final step.
Step 5: Clean Up
Drop the trigger and the old column.
DROP TRIGGER trg_sync_first_name ON users;
DROP FUNCTION sync_first_name();
ALTER TABLE users DROP COLUMN fname;
If you need the NOT NULL constraint on the new column, add it now:
ALTER TABLE users ALTER COLUMN first_name SET NOT NULL;
Summary
| Approach | When to use |
|---|---|
ALTER TABLE RENAME COLUMN |
Maintenance window, or app and schema can be deployed atomically |
| Expand-contract pattern | Zero-downtime rename on a live, high-traffic system |
The expand-contract pattern takes more steps, but it gives you full control over the transition. You can validate data at each stage and roll back at any point before the cleanup step.
Reviewing Rename Operations with PostgresCompare
When working across multiple environments — dev, staging, production — column renames are a common source of schema drift. A rename applied to dev but not yet to production shows up as a column present on one side and missing on the other.
PostgresCompare surfaces these differences clearly in its side-by-side comparison view, letting you see exactly what changed and generate the appropriate migration script before anything reaches production.
Download PostgresCompare to keep your schemas in sync across environments.