Altering Tables Safely
Add columns, change types, drop constraints — and know which ones are dangerous
Adding a nullable column to a 10 million row table takes milliseconds in PostgreSQL 16+. Changing a column type takes minutes — and locks every read and write while it runs.\n\nYou need to know which operations are instant metadata changes and which ones trigger a full table rewrite. In production, the difference is the difference between a two-second deploy and a 20-minute outage.
Why ALTER TABLE Complexity Matters
In development, every ALTER TABLE feels instant. In production against a 50M row table that is being queried 500 times per second, the wrong ALTER TABLE can take 15 minutes and block every read and write on that table for the duration.
The Key Operations and Their Cost
Operation | Lock | Rewrites table?
--------------------------------------------|-------|----------------
ADD COLUMN (nullable, no default) | Brief | No
ADD COLUMN NOT NULL WITH DEFAULT (PG 16+) | Brief | No ← key change
ADD COLUMN NOT NULL (no default) | Long | Yes
RENAME COLUMN | Brief | No
ALTER COLUMN TYPE (same family) | Long | Yes
ALTER COLUMN TYPE (same storage, e.g. text) | Brief | No (cast-in-place)
DROP CONSTRAINT (check/unique) | Brief | No
ADD CONSTRAINT NOT NULL | Long | Full table scan
The PostgreSQL 16 NOT NULL + DEFAULT Change
Before PostgreSQL 16, adding a NOT NULL column with a DEFAULT required rewriting every row in the table (to fill in the default). In PostgreSQL 16+, the default is stored in the catalog and only materialised on read — instant even on 100M rows.
This means the "safe" pattern changed between versions. Always know your version.
ACCESS EXCLUSIVE lock
The most restrictive lock in PostgreSQL. An ACCESS EXCLUSIVE lock blocks all other operations on the table — reads, writes, other DDL — until the operation completes. ALTER TABLE, VACUUM FULL, TRUNCATE, and DROP TABLE all acquire this lock.
Table rewrite
An operation that creates a new physical copy of the table with the changes applied, then swaps the old and new versions. During the rewrite, an ACCESS EXCLUSIVE lock is held. The duration depends on the table size — GBs can take minutes.
🔌 Connect to the Beer Database
Before altering any tables, connect to beer_db. Run psql -U beer_db -d beer_db to open a psql session.
psql -U beer_db -d beer_dbbeer_db=>
✅ Add a Nullable Column (Safe)
Add a nullable style_notes text column to the beers table. This is a metadata-only change — no table rewrite, minimal lock.
ALTER TABLE beers ADD COLUMN style_notes text;ALTER TABLE
🔍 Verify the Column Was Added
Run \d beers and confirm style_notes appears in the column list.
\d beersstyle_notes | text | | |
⚡ Add a NOT NULL Column WITH a Default (PG 16+ Fast Path)
Add a is_seasonal boolean NOT NULL DEFAULT false column to beers. On PostgreSQL 16+, this is instant — the default is stored in the catalog, not written to every row.
ALTER TABLE beers ADD COLUMN is_seasonal boolean NOT NULL DEFAULT false;ALTER TABLE
✏️ Rename a Column
Rename style_notes to brewing_notes. Renaming is always a metadata-only change — no data is touched, minimal lock duration.
ALTER TABLE beers RENAME COLUMN style_notes TO brewing_notes;ALTER TABLE
🔒 Simulate a Blocking ALTER TABLE in the Background
From psql, use \! to start a background transaction that holds an AccessExclusiveLock on beers for 60 seconds:
\! psql -U beer_db -d beer_db -c "BEGIN; LOCK TABLE beers IN ACCESS EXCLUSIVE MODE; SELECT pg_sleep(60); COMMIT;" &
The & runs it in the background. Exit your current psql session with \q, then reconnect from the shell with psql -U beer_db -d beer_db. Complete all three commands before checking the step.
\! psql -U beer_db -d beer_db -c "BEGIN; LOCK TABLE beers IN ACCESS EXCLUSIVE MODE; SELECT pg_sleep(60); COMMIT;" &\qpsql -U beer_db -d beer_dbbeer_db=>
🔍 Observe the AccessExclusiveLock on pg_locks
Now query pg_locks joined with pg_stat_activity to see the AccessExclusiveLock and how long it has been held:
SELECT l.pid, l.relation::regclass, l.mode, l.granted,
now() - a.query_start AS elapsed
FROM pg_locks l
JOIN pg_stat_activity a USING (pid)
WHERE l.relation = 'beers'::regclass;
You should see the background session holding the lock with a growing elapsed time.
SELECT l.pid, l.relation::regclass, l.mode, l.granted, now() - a.query_start AS elapsed FROM pg_locks l JOIN pg_stat_activity a USING (pid) WHERE l.relation = 'beers'::regclass;pid | relation | mode | granted | elapsed -------+----------+---------------------+---------+---------------- 12345 | beers | AccessExclusiveLock | t | 00:00:08.123456
🚫 Witness a Query Blocked by the Lock
While the background lock is still held, try a simple SELECT on beers. It will hang — AccessExclusiveLock blocks even reads that conflict with it:
SELECT COUNT(*) FROM beers;
Press Ctrl+C to cancel it after a few seconds, or wait for the lock to expire and the count to return. Either result completes this step.
SELECT COUNT(*) FROM beers;ERROR: canceling statement due to user request
🔍 Check pg_locks After the Lock Is Released
Wait for the background transaction to finish (60 seconds total), then re-run the same query. The AccessExclusiveLock row should be gone.
SELECT l.pid, l.relation::regclass, l.mode, l.granted, now() - a.query_start AS elapsed FROM pg_locks l JOIN pg_stat_activity a USING (pid) WHERE l.relation = 'beers'::regclass;(0 rows)
Lab complete! You know which ALTER TABLE operations are safe and which require planning:\n\n\n ADD nullable column : instant ✅\n ADD NOT NULL + DEFAULT (16+) : instant ✅\n RENAME COLUMN : instant ✅\n CHANGE COLUMN TYPE : TABLE LOCK ⚠️\n ADD NOT NULL (no default) : TABLE SCAN ⚠️\n
Enable JavaScript to run the live terminal and track your progress.