PostgreSQL migration
Does ALTER COLUMN TYPE rewrite the table in PostgreSQL?
Often, yes. ALTER COLUMN TYPE always takes an ACCESS EXCLUSIVE lock; int to bigint, shortening a varchar or adding a length limit rewrites the whole table and all its indexes. Widening varchar(n), varchar to text and widening numeric precision are instant.
Tested on PostgreSQL 14, 15, 16, 17, 18 · Updated 9 October 2026
- Lock
- ACCESS EXCLUSIVE
- Blocks
- Reads and writes
- Rewrites the table
- Depends on the change (see the table)
Short answer
Every ALTER TABLE … ALTER COLUMN … TYPE takes an ACCESS EXCLUSIVE lock,
so reads and writes wait while it runs. How long it runs depends on whether PostgreSQL can reuse the
stored values as they are:
- Instant when the old values are already valid in the new type:
varchar(50)→varchar(100),varchar(n)→text,text→varchar(no length),numeric(10,2)→numeric(12,2). - Full rewrite of the table and every index on it when each value has to be converted or
checked:
int→bigint,int→text,varchar(50)→varchar(20),text→varchar(100),numeric(10,2)→numeric(10,4), andtimestamp→timestamptzunless the session time zone is UTC.
A rewrite holds the lock for as long as it takes to copy the table and rebuild its indexes. For those changes, use one of the patterns below instead.
What it locks
- ACCESS EXCLUSIVE on the table, for every type change we tried, held
until the transaction ends.
SELECTandINSERTfrom other sessions waited. - When the table is rewritten, an ACCESS EXCLUSIVE lock on every index of the table too, because each one is rebuilt.
- When it isn’t rewritten, only the indexes that include the column are locked, and they’re usually kept as they are.
Does it rewrite the table?
On a 200,000-row table with six indexes, identical on PostgreSQL 14, 15, 16, 17 and 18:
| Change | Table rewritten | Indexes rebuilt | Time |
|---|---|---|---|
int → bigint | Yes | All 6 | 607–665 ms |
int → text | Yes | All 6 | 770–828 ms |
varchar(50) → varchar(100) | No | None | 3 ms |
varchar(50) → varchar(20) | Yes | All 6 | 627–765 ms |
varchar(50) → text | No | None | 3–7 ms |
varchar(50) → varchar | No | None | 3–4 ms |
text → varchar | No | None | 2–6 ms |
text → varchar(100) | Yes | All 6 | 628–863 ms |
numeric(10,2) → numeric(12,2) | No | None | 3 ms |
numeric(10,2) → numeric | No | None | 3–4 ms |
numeric(10,2) → numeric(10,4) | Yes | All 6 | 596–641 ms |
timestamp → timestamptz, TimeZone = 'UTC' | No | The column’s index only | 29–33 ms |
timestamp → timestamptz, TimeZone = 'Europe/London' | Yes | All 6 | 628–673 ms |
On a 3-million-row table with no indexes, int → bigint took 1.5 to 2.1 seconds (median of
three). The time grows with the size of the table and the number of indexes, and reads and writes
are blocked for all of it.
Why text → varchar(100) rewrites: every value has to be checked against the new limit, and
PostgreSQL does that check by rewriting. Widening a limit needs no check, so it’s instant.
Run it safely
For an instant change: lock_timeout and retry.
SET lock_timeout = '2s';
ALTER TABLE customers ALTER COLUMN email TYPE varchar(320);
If it fails with ERROR: canceling statement due to lock timeout, nothing changed; retry after a
pause. See lock timeout.
Adding a length limit: use a CHECK constraint instead of a type. It enforces the same rule with no rewrite, and can be validated without blocking writes:
SET lock_timeout = '2s';
ALTER TABLE customers ADD CONSTRAINT customers_email_length
CHECK (char_length(email) <= 320) NOT VALID; -- instant
ALTER TABLE customers VALIDATE CONSTRAINT customers_email_length; -- scans, but reads and writes continue
See adding a CHECK constraint.
timestamp → timestamptz: run it with the session in UTC, if the stored values are UTC times.
PostgreSQL then reads them as UTC and doesn’t rewrite the table, though it still rebuilds indexes on
that column while holding the lock. If the values were stored in some other local time, this would
shift them; in that case convert with a rewrite (USING col AT TIME ZONE '…') or the swap pattern
below.
SET TimeZone = 'UTC';
SET lock_timeout = '2s';
ALTER TABLE events ALTER COLUMN created_at TYPE timestamptz;
int → bigint on a big table: add a new column and swap. Each step either is instant or lets
reads and writes continue. For an orders table whose id serial PRIMARY KEY is running out:
-- 1. New column, kept in step for new and changed rows
ALTER TABLE orders ADD COLUMN id_new bigint;
CREATE FUNCTION orders_sync_id() RETURNS trigger LANGUAGE plpgsql AS $$
BEGIN NEW.id_new := NEW.id; RETURN NEW; END $$;
CREATE TRIGGER orders_sync_id BEFORE INSERT OR UPDATE ON orders
FOR EACH ROW EXECUTE FUNCTION orders_sync_id();
-- 2. Backfill in batches, one transaction each
UPDATE orders SET id_new = id WHERE id BETWEEN 1 AND 10000 AND id_new IS NULL;
-- 3. Prove NOT NULL and build the unique index without long locks
ALTER TABLE orders ADD CONSTRAINT orders_id_new_not_null CHECK (id_new IS NOT NULL) NOT VALID;
ALTER TABLE orders VALIDATE CONSTRAINT orders_id_new_not_null;
CREATE UNIQUE INDEX CONCURRENTLY orders_id_new_key ON orders (id_new);
-- 4. Swap in one short transaction
BEGIN;
SET LOCAL lock_timeout = '2s';
ALTER TABLE orders DROP CONSTRAINT orders_pkey;
ALTER TABLE orders ALTER COLUMN id_new SET NOT NULL; -- no scan: the CHECK proves it
ALTER TABLE orders ADD CONSTRAINT orders_pkey PRIMARY KEY USING INDEX orders_id_new_key;
ALTER TABLE orders ALTER COLUMN id DROP DEFAULT;
ALTER TABLE orders ALTER COLUMN id DROP NOT NULL;
ALTER TABLE orders RENAME COLUMN id TO id_old;
ALTER TABLE orders RENAME COLUMN id_new TO id;
ALTER TABLE orders ALTER COLUMN id SET DEFAULT nextval('orders_id_seq');
ALTER SEQUENCE orders_id_seq OWNED BY orders.id;
ALTER SEQUENCE orders_id_seq AS bigint;
DROP TRIGGER orders_sync_id ON orders;
ALTER TABLE orders DROP CONSTRAINT orders_id_new_not_null;
COMMIT;
-- 5. Later
ALTER TABLE orders DROP COLUMN id_old;
The swap transaction took 4 to 7 ms with no table scan on every version we tested. If other tables
have foreign keys to orders.id, they need the same treatment first, and dropping orders_pkey
needs those foreign keys dropped and re-added (adding a foreign key).
How we checked
On PostgreSQL 14.24, 15.19, 16.14, 17.11 and 18.6 (Docker on a Mac), each change inside a transaction that was rolled back:
CREATE TABLE seo_mig.ty (id int PRIMARY KEY, i int, v varchar(50), tx text,
nm numeric(10,2), ts timestamp, vu varchar(50));
INSERT INTO seo_mig.ty
SELECT g, g, 'v' || g, 't' || g, g / 100.0, now(), 'u' || g FROM generate_series(1, 200000) g;
CREATE INDEX ty_i_idx ON seo_mig.ty (i);
CREATE INDEX ty_v_idx ON seo_mig.ty (v);
-- … and on tx, nm and ts
BEGIN;
SELECT pg_relation_filenode('seo_mig.ty'), pg_relation_filenode('seo_mig.ty_i_idx');
ALTER TABLE seo_mig.ty ALTER COLUMN i TYPE bigint;
SELECT relation::regclass, mode FROM pg_locks
WHERE pid = pg_backend_pid()
AND relation IN (SELECT oid FROM pg_class WHERE relnamespace = 'seo_mig'::regnamespace);
SELECT pg_relation_filenode('seo_mig.ty'), pg_relation_filenode('seo_mig.ty_i_idx');
ROLLBACK;
A new data file number for the table means it was rewritten; a new number for an index means it was rebuilt. Locks we saw:
| Version | int → bigint | varchar(50) → text | SELECT / INSERT meanwhile |
|---|---|---|---|
| 14.24 | AccessExclusiveLock on ty and all 6 indexes | AccessExclusiveLock on ty and ty_v_idx | Waits / waits |
| 15.19 | AccessExclusiveLock on ty and all 6 indexes | AccessExclusiveLock on ty and ty_v_idx | Waits / waits |
| 16.14 | AccessExclusiveLock on ty and all 6 indexes | AccessExclusiveLock on ty and ty_v_idx | Waits / waits |
| 17.11 | AccessExclusiveLock on ty and all 6 indexes | AccessExclusiveLock on ty and ty_v_idx | Waits / waits |
| 18.6 | AccessExclusiveLock on ty and all 6 indexes | AccessExclusiveLock on ty and ty_v_idx | Waits / waits |
pg_locks also listed a SHARE lock on the table; ACCESS EXCLUSIVE already blocks everything, so it
changes nothing. The blocking test ran ALTER COLUMN a TYPE bigint in one session while another ran a
SELECT and an INSERT with lock_timeout = '1s'. The swap recipe ran end to end on each version
against a 200,000-row table with a serial key; afterwards id was bigint NOT NULL, the primary key
was on it, and a new insert got the next sequence value.
In Inlet
The structure editor shows the ALTER TABLE … TYPE statement before it runs and warns when a change
rewrites or scans the table. The Activity monitor shows what a long-running change is blocking, and
lets you cancel it.