How to Remove a Column in PostgreSQL Safely
Chat2DB TeamRemoving a column in PostgreSQL is one line of SQL. Removing it without taking down an application, orphaning a view, or discovering you needed the data after all takes a bit more thought. Here is the full statement, what it does to your table on disk, and the rollout order that makes it safe on a live system.
The statement
ALTER TABLE products DROP COLUMN legacy_code;Add IF EXISTS to make the migration idempotent — useful when the same script may run twice:
ALTER TABLE products DROP COLUMN IF EXISTS legacy_code;Drop several columns in one statement. This is not just tidier; it takes the table lock once instead of three times:
ALTER TABLE products
DROP COLUMN IF EXISTS legacy_code,
DROP COLUMN IF EXISTS old_price,
DROP COLUMN IF EXISTS import_batch;What actually happens on disk
DROP COLUMN does not rewrite the table and does not free any space. PostgreSQL marks the column as dropped in the system catalog (pg_attribute.attisdropped = true) and renames it to something like ........pg.dropped.7......... The data stays in every existing row, invisible and unreachable.
This is good news operationally: dropping a column from a 500 GB table is instant, no matter how large the table is. It is bad news if you dropped the column to reclaim disk space, because you did not reclaim any.
Two consequences follow.
The space comes back only when rows are rewritten. Any UPDATE to a row rewrites it without the dropped column. To reclaim everything at once you need a full rewrite:
-- Rewrites the table, reclaims the space, takes an ACCESS EXCLUSIVE lock
VACUUM FULL products;VACUUM FULL blocks all reads and writes for the duration and needs free disk space equal to the table size. On a production table, pg_repack does the same job online. Plain VACUUM does not help here — it reclaims dead tuples, not dropped-column bytes in live ones.
The 1,600-column limit counts dropped columns. Postgres allows at most 1,600 columns per table, and dropped ones still occupy a slot until the table is rewritten. A table that has columns added and dropped repeatedly (some migration tooling does this) can eventually fail with tables can have at most 1600 columns even though \d shows forty. VACUUM FULL or a pg_repack clears the slots.
You can see the hidden columns yourself:
SELECT attnum, attname, attisdropped
FROM pg_attribute
WHERE attrelid = 'products'::regclass
AND attnum > 0
ORDER BY attnum;Dependencies: RESTRICT vs CASCADE
By default DROP COLUMN is RESTRICT: if anything depends on the column, the statement fails.
ALTER TABLE products DROP COLUMN category;
-- ERROR: cannot drop column category of table products because other objects depend on it
-- DETAIL: view product_summary depends on column category of table products
-- HINT: Use DROP ... CASCADE to drop the dependent objects too.CASCADE drops the dependents along with it:
ALTER TABLE products DROP COLUMN category CASCADE;
-- NOTICE: drop cascades to view product_summaryRead that NOTICE before you trust it. CASCADE will happily drop a view, a materialized view, or a foreign key constraint that some other part of your system relies on, and it does so silently apart from that one notice line. On production, find the dependents first and handle them deliberately.
Find what depends on a column
SELECT DISTINCT
dependent.relname AS dependent_object,
dependent.relkind AS kind -- v = view, m = matview, r = table, i = index
FROM pg_depend d
JOIN pg_rewrite r ON r.oid = d.objid
JOIN pg_class dependent ON dependent.oid = r.ev_class
JOIN pg_class source ON source.oid = d.refobjid
JOIN pg_attribute a ON a.attrelid = d.refobjid AND a.attnum = d.refobjsubid
WHERE source.relname = 'products'
AND a.attname = 'category'
AND dependent.relname <> 'products';Indexes and constraints are easier to spot:
-- Indexes that include the column
SELECT indexname, indexdef
FROM pg_indexes
WHERE tablename = 'products' AND indexdef LIKE '%category%';
-- Constraints referencing it
SELECT conname, pg_get_constraintdef(oid)
FROM pg_constraint
WHERE conrelid = 'products'::regclass
AND pg_get_constraintdef(oid) LIKE '%category%';Indexes and constraints on the dropped column are removed automatically, no CASCADE needed. A multi-column index that includes the dropped column is dropped entirely — (category, created_at) disappears, not just the category part. If queries relied on the created_at prefix, rebuild it first:
CREATE INDEX CONCURRENTLY idx_products_created_at ON products (created_at);
-- then drop the column, which removes the old composite indexGenerated columns and functions
A generated column referencing the target blocks the drop and is not always obvious:
ALTER TABLE products DROP COLUMN price;
-- ERROR: cannot drop column price of table products because other objects depend on it
-- DETAIL: column price_with_tax of table products depends on column priceFunctions are the opposite problem: PL/pgSQL bodies are not dependency-tracked. A function that does SELECT category FROM products will drop fine and then fail at runtime with column "category" does not exist. Grep your function bodies before you drop:
SELECT n.nspname, p.proname
FROM pg_proc p
JOIN pg_namespace n ON n.oid = p.pronamespace
WHERE n.nspname NOT IN ('pg_catalog', 'information_schema')
AND p.prosrc ILIKE '%category%';The locking problem
ALTER TABLE ... DROP COLUMN takes an ACCESS EXCLUSIVE lock. The catalog update itself is instantaneous, so this is usually fine — but the lock has to be acquired, and that is where migrations stall.
If a long-running query is reading the table, your ALTER TABLE waits. Worse, it queues ahead of every subsequent query, so a 30-second analytics query plus a one-millisecond ALTER TABLE equals 30 seconds of your application blocking on a table it normally reads in microseconds.
Always bound the wait:
SET lock_timeout = '3s';
ALTER TABLE products DROP COLUMN legacy_code;If the lock cannot be acquired in three seconds the statement fails with canceling statement due to lock timeout and you retry later, instead of taking the site down. Wrap it in a retry loop in your migration tool.
Check what is holding the lock:
SELECT pid, state, wait_event_type, now() - query_start AS duration, left(query, 80)
FROM pg_stat_activity
WHERE pid IN (SELECT pid FROM pg_locks WHERE relation = 'products'::regclass)
ORDER BY duration DESC;The zero-downtime rollout
The dangerous window is not the ALTER TABLE — it is the gap where the column is gone but your application still selects it. SELECT * in an ORM, a cached prepared statement, or a stale pod all produce column products.legacy_code does not exist and 500s.
Deploy in this order:
-
Stop reading it. Ship an application version that never references the column — no
SELECT *on that table, no ORM field mapping to it. Verify with logs orpg_stat_statements:SELECT calls, left(query, 120) FROM pg_stat_statements WHERE query ILIKE '%legacy_code%' ORDER BY calls DESC;Reset the stats, wait a full business cycle (including nightly batch jobs), and check again. Zero calls is your green light.
-
Stop writing it. If the column is
NOT NULL, drop that constraint first so that older instances still writing rows do not fail:ALTER TABLE products ALTER COLUMN legacy_code DROP NOT NULL; -
Back up the data, if there is any doubt. Dropping the column is not reversible without a restore. A cheap insurance policy:
CREATE TABLE products_legacy_code_backup AS SELECT id, legacy_code FROM products WHERE legacy_code IS NOT NULL;Keep it for a release cycle, then drop it.
-
Rename first, drop later. The safest intermediate step is to rename rather than drop — it is equally instant, but trivially reversible:
ALTER TABLE products RENAME COLUMN legacy_code TO legacy_code_deprecated_20260826;Anything still referencing the old name fails loudly and immediately, in a way you can undo with a second
RENAME. Wait a week; if nothing broke, drop it for real. -
Drop it, with a
lock_timeout, during a low-traffic window. -
Reclaim space with
pg_repackif the column held a lot of data and the table is large.
Rolling back
There is no UNDROP COLUMN. Adding a column with the same name gives you an empty column, not your data:
ALTER TABLE products ADD COLUMN legacy_code text; -- all NULLsRecovery means restoring from a backup or point-in-time recovery to just before the migration, then copying the column across. That is why steps 3 and 4 above exist: the backup table and the rename window cost almost nothing and are the difference between a five-minute fix and a restore.
Summary
ALTER TABLE t DROP COLUMN cis instant and does not reclaim disk space; only a rewrite (VACUUM FULLorpg_repack) does.- Dropped columns still consume one of the 1,600 column slots until the table is rewritten.
- Check dependents before using
CASCADE; views and matviews are tracked, function bodies are not. - A composite index containing the column is dropped entirely — rebuild what you still need first.
- Always set
lock_timeoutso a blockedALTER TABLEcannot pile up behind a long query. - Rename before you drop. It buys you a free, instantly reversible test.
For exploring dependencies visually before a migration — which views, indexes and constraints touch a column — a GUI client helps: Chat2DB (opens in a new tab) shows table structure, indexes and dependent objects side by side and can generate the migration SQL for you, with a browser version at app.chat2db.ai (opens in a new tab).
