Build1 publisher3 min readPublished
PostgreSQL rewrites a 5-million-row table for 10.4 seconds when a new column's default is volatile
PostgreSQL 18.3 spent 10.4 seconds rewriting a 5-million-row table to add a gen_random_uuid() column, against 10 ms for a constant default. Values computed per row need a staged migration, with every statement run under lock_timeout.
The Engineer · Build desk

What happened
- PostgreSQL's ALTER TABLE documentation says a non-volatile default is evaluated once and stored in table metadata, while a volatile one rewrites the entire table and its indexes.
- The gen_random_uuid() rewrite gave the table a new file on disk and grew it from 356 MB to 473 MB, while the constant default left the file untouched.
- Backfilling all 5 million orders in one UPDATE took 19.6 seconds and held a row lock on every order it touched until commit.
- A plain SET NOT NULL run straight after that backfill held its exclusive lock for 1.9 seconds, and primary key lookups on the table could not run during it.
Compiled by The EngineerSomething wrong?How this is made
Why it matters
- exposure A migration that finishes in 10 ms once it holds its lock can still stall every later query on the table while it waits behind one open transaction, so lock_timeout matters as much as the statement chosen.
- cost Batching the 5-million-row backfill adds about 2.4 seconds of total work, paid to keep each row lock to one 220 ms batch instead of the full backfill.
- constraint The fast path can still write wrong data: now() counts as stable and stamps every pre-existing row with the migration's start time.
- decision Servers older than PostgreSQL 12 cannot use a validated check to skip the SET NOT NULL scan, so the cluster's version decides how much of the safe sequence works.
The post's author frames the choice around one question: what the new column should hold for rows that already exist [22]. A single constant goes into the catalogue, and the statement returns in milliseconds whatever the table's size [21]. On the test table the rewrite took about 1,040 times as long as the constant path [1]. In a migration file the two statements look equally harmless [23].
Volatility is a property of the function, so it has to be looked up. now() is stable, so it takes the fast path [7]. Every existing order then gets the same imported_at, the time the migration's transaction started [7]. The author notes that this is correct for an import timestamp and wrong for a column meant to record when each row happened [7]. Leaving the default off fails on the first row with `ERROR: column "channel" of relation "orders" contains null values` [8]. "That is the safe failure. The rewrite is the dangerous one, because it succeeds," the author wrote [9].
For a value computed per row, the post works through an orders table where each order takes its region from its customer [24]. The sequence:
1. `ALTER TABLE orders ADD COLUMN region text;` is a catalogue change that took 0.7 ms [10]. 2. The backfill is an UPDATE joined to customers over 50,000-row id ranges, committing between batches, at about 220 ms a batch [12]. 3. `ADD CONSTRAINT orders_region_nn_check CHECK (region IS NOT NULL) NOT VALID` is instant, since it checks only new and updated rows [16]. 4. `VALIDATE CONSTRAINT` does the full scan, 534 ms in the test, and the author reports that this scan never holds a lock that blocks reads [16][17]. 5. `ALTER COLUMN region SET NOT NULL` then skips its own scan. PostgreSQL has done that since version 12 when a validated check already proves the column has no NULL [15]. The helper constraint is dropped last [20].
Batching costs wall time. Assuming contiguous ids, 5,000,000 rows make 100 batches, roughly 22 seconds against 19.6 for the single UPDATE [3]. I'd pay the extra 2.4 seconds on any table that takes writes, because each row lock lasts one batch, not the whole backfill [11][12].
Running SET NOT NULL without the check constraint holds ACCESS EXCLUSIVE for its whole scan [13]. On a freshly vacuumed table that scan took 367 to 380 ms over three runs [13]. The dead row versions left by the single-statement backfill made it about five times slower [4][14].
The post's last rule covers even the 10 ms constant default. An ALTER TABLE waits behind any open transaction that has touched the table, and every query that arrives after it stalls in turn [18]. The statement is quick once it holds its lock, so the risk sits in the queue. The author sets lock_timeout on every statement in the sequence for that reason [18].
All timings came from PostgreSQL 18.3 in a throwaway container [1]. The absolute milliseconds belong to that container and that 356 MB table. What transfers is which path each statement takes, and the ALTER TABLE documentation decides that [5]. Two version floors apply. The metadata default arrived in PostgreSQL 11 [6], and the scan skip in PostgreSQL 12 [15]. The author builds Schemity, a desktop ERD tool, and the post uses it for its examples [19]. Every statement above is plain SQL.
What to watch
- A repeat of these timings on a table under concurrent writes, where the lock queue behind each ALTER TABLE would show up in the numbers.
- Measurements on tables far larger than 356 MB, to show how the rewrite and VALIDATE scan times scale against the size-independent catalogue path.