Test PostgreSQL migrations before COMMIT • pgmi
Test PostgreSQL migrations before COMMIT#<br>By Alexey Evlampiev<br>Most migration pipelines test before deployment or after commit. PostgreSQL<br>allows a third checkpoint: after applying the change, but before committing<br>it . Lint and rehearsal catch what they can in advance; integration suites<br>catch what they can afterwards. The checkpoint in between is the only one<br>where the real target database — with whatever drift it has accumulated — is<br>in its migrated state while rollback is still one statement away.<br>PostgreSQL supports this with no framework at all: run the migration and<br>the checks that prove it worked inside one transaction, and let COMMIT<br>depend on the checks. If an assertion fails, the deployment doesn’t fail<br>partially — within PostgreSQL’s transactional scope, it never happened.<br>The pattern needs no framework: first we’ll build it with plain SQL and psql,<br>then look at where its limits are and how to automate it across a project.<br>The foundation: DDL you can roll back#<br>PostgreSQL’s documentation defines transactions as<br>all-or-nothing operations<br>and documents savepoints for selectively discarding part of a transaction.<br>What makes the deployment story unusual is that PostgreSQL’s transactional<br>treatment extends to most DDL: CREATE TABLE, ALTER TABLE,<br>CREATE FUNCTION, and index creation obey BEGIN and ROLLBACK like<br>ordinary writes. The PostgreSQL wiki’s competitive<br>analysis<br>compares this across engines; the short version is that a failed migration on<br>MySQL can leave you half-migrated (DDL there commits implicitly), while<br>PostgreSQL rolls the transaction’s schema and data changes back together.<br>Most migration tools use this property defensively: if a statement errors,<br>the migration rolls back. That’s good, but it only protects you from<br>migrations that fail loudly. The more expensive failures are the ones that<br>succeed syntactically and are wrong semantically — the backfill that missed<br>rows, the function that returns the wrong shape, the constraint that was<br>created NOT VALID and never validated.<br>Transactional DDL supports something stronger than error rollback: it lets<br>you make your own checks part of the transaction.<br>The pattern in plain psql#<br>Suppose a deployment adds a region column, backfills it from a mapping<br>table, and makes it mandatory. The interesting question isn’t “did the<br>statements run” — the DDL itself will complain if it can’t. The question is<br>one the new NOT NULL constraint cannot answer: did every customer get<br>the region its country implies? A backfill can write a wrong-but-non-null<br>value — an ambiguous mapping, a join that silently matched the wrong rows —<br>and every statement still succeeds. Ask the real question before<br>committing:<br>-- deploy.sql<br>BEGIN;
ALTER TABLE customer ADD COLUMN region text;
UPDATE customer c<br>SET region = m.region<br>FROM region_mapping m<br>WHERE m.country = c.country;
-- The gate: verify the migrated state inside the same transaction.<br>DO $$<br>DECLARE<br>v_unmapped bigint;<br>v_mismatched bigint;<br>BEGIN<br>SELECT count(*) INTO v_unmapped<br>FROM customer<br>WHERE region IS NULL;
SELECT count(*) INTO v_mismatched<br>FROM customer c<br>JOIN region_mapping m ON m.country = c.country<br>WHERE c.region IS DISTINCT FROM m.region;
IF v_unmapped > 0 OR v_mismatched > 0 THEN<br>RAISE EXCEPTION<br>'region backfill invalid: % unmapped, % mismatched',<br>v_unmapped, v_mismatched;<br>END IF;<br>END $$;
ALTER TABLE customer ALTER COLUMN region SET NOT NULL;
COMMIT;<br>The two checks earn their place differently. The unmapped count fires before<br>SET NOT NULL would, turning a generic constraint error into a diagnostic<br>with numbers in it. The mismatch count is the real gate: it catches a<br>wrong-but-non-null backfill that NOT NULL happily accepts. A persistent<br>relational constraint — a composite foreign key onto the mapping table —<br>could enforce this relationship continuously, and where that fits your<br>model it is the stronger tool; the deployment gate earns its keep when such<br>a constraint is absent, impractical, or when you want a diagnostic count<br>before introducing it. And because the assertion describes the intended<br>state rather than the backfill implementation, it remains valid if the<br>backfill is rewritten before deployment or reused in a later migration —<br>though unlike a constraint, it protects only deployments in which it<br>actually runs.<br>Run it with psql configured to stop on the first error:<br>psql "$DATABASE_URL" -v ON_ERROR_STOP=1 -f deploy.sql<br>If the RAISE EXCEPTION fires, psql exits non-zero, the open transaction<br>aborts, and the connection closes — PostgreSQL rolls back the transaction’s<br>schema and data changes: the ALTER TABLE, the backfill, all of it. Your<br>pipeline sees a non-zero exit code and stops. (Non-transactional effects are<br>the subject of the limitations section below.)<br>Notice what did the work here: not a testing framework, not a migration tool<br>— one DO block and PostgreSQL’s...