ALTER TABLE takes forever on a large table
Why ALTER TABLE hangs or locks your app on large tables, which changes rewrite the whole table, and safe patterns using lock_timeout, NOT VALID constraints and concurrent indexes.
What you see
An ALTER TABLE on a big table runs for minutes, or seems to hang. While it runs, your app slows down or stops: queries on that table pile up, and supabase-js calls time out. Sometimes it ends with canceling statement due to statement timeout.
Why it happens
There are two separate problems that look the same.
1. Waiting for a lock. Most ALTER TABLE forms need an exclusive lock on the table. If any transaction is using the table (a slow report, or a session left idle in transaction), the ALTER waits. Worse, every new query on the table queues behind the waiting ALTER. A change that would take a millisecond can freeze the table for as long as the oldest transaction runs.
2. Rewriting or scanning the table. Some changes touch every row while holding the lock:
| Change | Cost on a large table |
|---|---|
add column with no default, or a constant default |
Instant |
add column ... default gen_random_uuid() (volatile default) |
Rewrites the table |
alter column ... type (for example int to bigint) |
Rewrites the table and its indexes |
alter column ... set not null |
Scans the whole table |
add foreign key / add check |
Scans the whole table |
create index (without concurrently) |
Blocks writes until built |
rename, drop column, set default |
Instant |
How to fix it
Make DDL give up instead of queueing. Set a short
lock_timeoutso a blockedALTERfails fast and doesn't stall your app. Retry it a moment later:set lock_timeout = '3s'; alter table public.bookings add column notes text;Find what's holding the lock if it keeps timing out. See Finding queries blocked by locks.
Build indexes concurrently. This allows reads and writes while the index builds:
create index concurrently if not exists bookings_guest_idx on public.bookings (guest_id);concurrentlycan't run inside a transaction. Run it as a statement on its own.Add constraints in two steps.
not validadds the constraint instantly for new rows;validatechecks old rows without blocking writes:alter table public.bookings add constraint bookings_venue_fk foreign key (venue_id) references public.venues (id) not valid; alter table public.bookings validate constraint bookings_venue_fk;Set NOT NULL without a long lock. A validated check constraint lets Postgres skip the full scan:
alter table public.bookings add constraint bookings_guest_not_null check (guest_id is not null) not valid; alter table public.bookings validate constraint bookings_guest_not_null; alter table public.bookings alter column guest_id set not null; alter table public.bookings drop constraint bookings_guest_not_null;Avoid rewrites for type changes. Add a new column, backfill it in batches, then swap names in one quick transaction.
If a step legitimately needs longer than your timeout, raise it for that session only:
set statement_timeout = '30min';.
On SupaHost
- MCP
apply_migrationruns the whole migration as one transaction, socreate index concurrentlyfails there. Run concurrent index builds in Studio's SQL editor (Dashboard → your project → Database tab → Open Studio) as a single statement, or with psql through the SSH tunnel on Business. - Each project's Data API has 5 database connections. A table locked by a waiting
ALTERcan tie up all 5 within seconds, and then every API request for that project waits, not only those touching the table. A shortlock_timeoutprevents this. - Other projects on the instance have separate databases, so they aren't affected by the lock, though a big rewrite does use the shared CPU and disk.