Skip to content
EL
Contents

Database migrations at scale

Lab: change a 5-million-row table without stopping writes

LabExpert6 min read50 min hands-on#operations#concurrency

The idea in 30 seconds

Build a 5-million-row table and find out, for each common schema change, whether it rewrites the table and whether it blocks writes while it works. Then do the same jobs the safe way: a concurrent index, a NOT VALID constraint validated separately, and a backfill in batches. Measure how long a concurrent write waits each way.

Helps to know first: Database migrations and schema evolution (coming soon), Locks and locking strategies (coming soon), Database reliability engineering

On this page

Before you start

You need a throwaway database with about 1.5 GB free and two terminals connected to it. Build the table:

CREATE TABLE payments (
  id          int PRIMARY KEY,
  customer_id int,
  status      varchar(20),
  amount      numeric(10,2),
  created_at  timestamptz
);
INSERT INTO payments
SELECT g, g % 100000, (ARRAY['paid', 'pending', 'refunded'])[1 + g % 3],
       (g % 10000) / 100.0, now() - g * interval '1 second'
FROM generate_series(1, 5000000) g;
VACUUM ANALYZE payments;
SELECT pg_size_pretty(pg_relation_size('payments'));   -- ours: 287 MB
\timing on

Our numbers are medians of three runs on PostgreSQL 18.3, Apple M1 Pro laptop. Yours will differ in milliseconds; which changes rewrite, scan or block should not differ at all.

1. Which changes rewrite the table

A table lives in a file whose number pg_relation_filenode reports. A rewrite writes a new file, so the number changes. Wrap each change like this:

SELECT pg_relation_filenode('payments') AS before \gset
ALTER TABLE payments ADD COLUMN note text;
SELECT CASE WHEN :before = pg_relation_filenode('payments') THEN 'same file' ELSE 'REWRITTEN' END;

Now try each of these the same way, in order, and fill in the table:

ALTER TABLE payments ADD COLUMN currency text NOT NULL DEFAULT 'INR';
ALTER TABLE payments ALTER COLUMN status TYPE varchar(50);
ALTER TABLE payments ALTER COLUMN status TYPE text;
ALTER TABLE payments ALTER COLUMN customer_id SET NOT NULL;
ALTER TABLE payments ADD COLUMN imported_at timestamptz DEFAULT clock_timestamp();
ALTER TABLE payments ALTER COLUMN id TYPE bigint;
ChangeOursTimeYours
ADD COLUMN note textsame file3 ms
ADD COLUMN ... DEFAULT 'INR'same file2 ms
status to varchar(50)same fileunder 1 ms
status to textsame fileunder 1 ms
customer_id SET NOT NULLsame file (scanned)258 ms
ADD COLUMN ... DEFAULT clock_timestamp()rewritten8.3 s
id to bigintrewritten13.2 s

The SET NOT NULL kept the same file but still took a quarter of a second: it read every row to check for nulls, under ACCESS EXCLUSIVE.

2. Does it block writes?

Time alone does not tell you about the outage; the lock does. In terminal A, start a long change. In terminal B, within a second, insert one row:

-- A
CREATE INDEX pay_cust ON payments (customer_id);

-- B
INSERT INTO payments (id, customer_id, status, amount, created_at)
VALUES (-1, 1, 'paid', 1, now());

Note how long B’s insert took. Then drop the index and repeat with the concurrent version:

-- A
DROP INDEX pay_cust;
CREATE INDEX CONCURRENTLY pay_cust ON payments (customer_id);

-- B
INSERT INTO payments (id, customer_id, status, amount, created_at)
VALUES (-2, 1, 'paid', 1, now());
Index buildInsert during the buildYours
CREATE INDEX1.3 swaited 1.2 s
CREATE INDEX CONCURRENTLY2.1 s0.6–1.1 ms

The concurrent build took longer, and that is the trade: more total work in exchange for never stopping writes. On a table where a normal build takes an hour, that hour is an hour of failed checkouts.

3. Constraints in two steps

Do the same with a CHECK constraint. First the usual way, with B inserting while A runs:

-- A
ALTER TABLE payments ADD CONSTRAINT amount_positive CHECK (amount >= 0);

Then in two steps, inserting from B during the VALIDATE:

-- A
ALTER TABLE payments ADD CONSTRAINT amount_small CHECK (amount < 1000) NOT VALID;
ALTER TABLE payments VALIDATE CONSTRAINT amount_small;
ScanInsert during the scanYours
ADD CONSTRAINT ... CHECK349 mswaited 244 ms
... NOT VALID, then VALIDATE1 ms, then 361 ms0.7 ms

Both versions read the whole table. Only the first stopped writes while it did. On PostgreSQL 18 try the same with NOT NULL: ALTER TABLE payments ADD CONSTRAINT amount_not_null NOT NULL amount NOT VALID; then validate it.

4. Backfill: one statement or many

You have added amount_paise bigint and need to fill it for 5 million rows.

ALTER TABLE payments ADD COLUMN amount_paise bigint;

The one-statement way. In A:

UPDATE payments SET amount_paise = amount * 100;

Five seconds in, from B, update a row near the start of the table, the kind of thing the application does all day:

UPDATE payments SET status = 'refunded' WHERE id = 10;

B waits until A finishes: A has already updated row 10 and holds its lock until it commits. Ours waited 23 s of a 28 s backfill.

The batched way. Rebuild the table from “Before you start” (or set amount_paise back to NULL and vacuum), add the column again, and backfill in chunks of 50,000 rows, committing after each:

CREATE PROCEDURE backfill_paise(batch int) LANGUAGE plpgsql AS $$
DECLARE lo int := 0; hi int;
BEGIN
  SELECT max(id) INTO hi FROM payments;
  WHILE lo < hi LOOP
    UPDATE payments SET amount_paise = amount * 100
    WHERE id > lo AND id <= lo + batch AND amount_paise IS NULL;
    lo := lo + batch;
    COMMIT;
  END LOOP;
END $$;

CALL backfill_paise(50000);   -- in A; repeat B's update five seconds in
Total timeB’s single-row update waitedTable size afterYours
One UPDATE28.2 s23.2 s613 MB
Batches of 50,00025.0 s7.2 ms613 MB

Batching took about as long overall, and it did not save space: both more than doubled the table, because every update writes a new row version and the old one stays until vacuum reclaims it. What batching changed is how long anyone else waits: at most one batch, instead of the whole backfill. On a real system you would also pause between batches to let replicas and autovacuum keep up.

The amount_paise IS NULL condition makes the procedure safe to stop and rerun: finished rows are skipped.

What you measured

  • Constant defaults and compatible type changes were instant; a volatile default and int to bigint rewrote 287 MB in 8 and 13 seconds.
  • A plain index build and a plain CHECK blocked a concurrent insert for their whole duration; the concurrent and NOT VALID versions did not.
  • Batching a backfill cut another session’s wait from 23 seconds to 7 milliseconds, for about the same total time.

Clean up

DROP TABLE payments;
DROP PROCEDURE backfill_paise(int);

Check yourself

  1. How can you tell, without reading the documentation, whether an ALTER TABLE rewrote the table?

    Show answer

    Compare pg_relation_filenode before and after. A rewrite creates a new file for the table, so the number changes.

  2. The concurrent index build took longer than the plain one. Why is it still the right choice on a busy table?

    Show answer

    The plain build blocks every insert, update and delete for its whole duration. The concurrent one does more work but lets writes continue, so the extra time costs nothing visible to users.

  3. Why did the single-statement backfill block an update to row 10 for 23 seconds?

    Show answer

    The big UPDATE had already changed row 10 and holds that row’s lock until its transaction commits at the end. In batches, each batch commits within a fraction of a second and releases its locks.

Finished reading?

Saved in this browser only.

Sources

Where each claim on this page comes from.

  1. 1.
    PostgreSQL documentation: ALTER TABLE
    Primary· Notes

    Which forms of ALTER TABLE rewrite the table, which scan it, and which lock each takes.

  2. 2.
    PostgreSQL documentation: CREATE INDEX
    Primary· Building Indexes Concurrently

    A normal index build blocks writes until it finishes; CONCURRENTLY does not.

  3. 3.
    PostgreSQL documentation: 41.8 Transaction Management
    Primary· §41.8 Transaction Management

    A procedure called with CALL can COMMIT inside a loop.

  4. 4.
    PostgreSQL documentation: 24.1 Routine Vacuuming
    Primary· §24.1.2 Recovering Disk Space

    An UPDATE leaves the old row version behind until vacuum makes its space reusable.

  5. 5.
    PostgreSQL documentation: 9.28 System Administration Functions
    Primary· §9.28.7 Database Object Management Functions: pg_relation_filenode

    pg_relation_filenode returns the file node number currently assigned to a relation, which changes when the table is rewritten.

Go further

What to read next, and why it is worth the time.

  1. 1.
    PostgreSQL documentation: CREATE INDEX
    Primary· Building Indexes Concurrently

    The exact sequence a concurrent build goes through, what it waits for, and how to recover when it fails.

Applies to PostgreSQL 18. Draft: not yet fact-checked end to end.