TRENDING
A mountain gorilla looks at the camera from green forest undergrowth
October 28, 2026
How to Compress Time-Series Metrics in Python With Gorilla Delta-of-Delta and XOR Encoding
A bronze-framed hourglass with white sand beside a small Ferris wheel ornament on a wooden table against a teal wall
October 28, 2026
NIST’s Final Post-Quantum Transition Report Puts Every Quantum-Vulnerable Public-Key Scheme on One 2030 and 2035 Clock, and Three Federal Documents Word the Deadline Three Ways
Six weathered stone columns of the Temple of Apollo at Delphi, Greece, standing on a stone platform above a valley of olive groves
October 28, 2026
Honeypots Recorded Probes Against Oracle E-Business Suite CVE-2026-101767 Seven Days After Its Patch, and the Finder’s Write-Up Is Dated Patch Day
Vintage silver stopwatch with a chain on a black background, its dial showing seconds from 5 to 60
October 28, 2026
How to Find a Slow Query in PostgreSQL 18 With pg_stat_statements, Read Its EXPLAIN ANALYZE Plan, and Fix It With the Right Index
A wooden Tower of Hanoi puzzle on a market table, with three pegs and stacked discs of different sizes
October 28, 2026
Two Labs’ Claimed Refutations of the Exponential Time Hypothesis Turn Verification Into a Statement-Reading Problem, and Their Lean Theorems Promise Only That a Constant Exists
28 Oct 2026
SXZ.io SXZ.io
  • Home
Search the Site
Popular Searches:
Technology Amazon AI
Recent Posts
A flock of snow geese in flight, seen as dark silhouettes against an orange and red sunset sky with layered clouds
How to Run Zero-Downtime Schema Migrations on PostgreSQL 18 and Block Unsafe ALTER TABLE Statements With a Python Lint Gate
October 28, 2026
An old green steel truss bridge resting on temporary supports beside a new arch bridge under completion on a river in Portland, Oregon
The Angular 2 Rewrite Turns a Migration Lesson Into a Security Bill: AngularJS Still Draws 533,480 Downloads a Week, and 10 Advisories Have No Open-Source Fix
October 28, 2026
Hundreds of yellow rubber ducks floating in a river enclosure during a duck race
WordPress 7.1.4 Closes an Unauthenticated Race in Its Own Update Routine, but 19 Older Branches Had No Backport Hours After Release
October 28, 2026
SXZ.io SXZ.io
  • Home

Categories

Articles 270 Posts
News 273 Posts
Learning Hub 243 Posts
Home/Learning Hub/How to Run Zero-Downtime Schema Migrations on PostgreSQL 18 and Block Unsafe ALTER TABLE Statements With a Python Lint Gate
Learning Hub

How to Run Zero-Downtime Schema Migrations on PostgreSQL 18 and Block Unsafe ALTER TABLE Statements With a Python Lint Gate

Measure what 17 common PostgreSQL 18 statements lock on a 2,000,000-row table, watch one idle transaction freeze it, then migrate safely with lock_timeout, NOT VALID constraints and concurrent...

October 28, 2026 52 Min Read
6

A schema migration is a change to the structure of a database: add a column, add an index, tighten a rule. On a small table it takes a few milliseconds and nobody notices. On a table with millions of rows, the same statement can make every query against that table wait, and a whole application looks down even though the database never stopped running. In this tutorial you will see why that happens in PostgreSQL 18, run the common migrations without the stall, and build a small Python program, a lint gate, that refuses the unsafe ones before they ship.

Table Of Content

  • The ideas you need first
  • Locks and the waiting line
  • Three kinds of work a statement can do
  • Words used in the rest of the tutorial
  • Prerequisites
  • Step 1: Create a database with 2,000,000 orders
  • Step 2: Measure what each statement locks, rewrites and holds
  • How to read the table
  • A note on foreign keys
  • Step 3: See how a one-millisecond statement freezes a table
  • The fix: a short lock_timeout and a retry loop
  • Which statements queue everyone, and which do not
  • Step 4: Add NOT NULL without a long lock
  • Step 5: Build indexes without blocking writes
  • Step 6: Change a column type with expand, backfill and contract
  • Step 7: Write a lint gate that refuses unsafe migrations
  • Step 8: Apply the approved migration while traffic runs
  • Common mistakes and how to spot them
  • Check that everything works end to end
  • Where to go next
  • Sources

You do not need to know anything about PostgreSQL locks to start. Each step gives you a command to run, shows the output I got, and explains what the output means. Everything was run against PostgreSQL 18.6 on a 2,000,000-row table, so the timings are measurements from one machine, not promises about yours.

The ideas you need first

Locks and the waiting line

A lock is PostgreSQL’s way of keeping two statements from stepping on each other. Every statement asks for a lock on each table it touches, in a mode that says what it intends to do. Reading a table asks for ACCESS SHARE. Changing rows asks for ROW EXCLUSIVE. Two statements can hold their locks together only if their modes do not conflict, and the PostgreSQL documentation lists the conflicts: ACCESS SHARE, for example, “Conflicts with the ACCESS EXCLUSIVE lock mode only.” Most forms of ALTER TABLE ask for that strongest mode: “An ACCESS EXCLUSIVE lock is acquired unless explicitly noted. When multiple subcommands are given, the lock acquired will be the strictest one required by any subcommand.”

When a lock cannot be granted yet, the request does not fail. It waits in a queue, and that is the part that surprises people. The GoCardless engineering team described it after a planned migration took their API down for about 15 seconds: “When a lock can’t be acquired because of a lock held by another transaction, it goes into a queue. Any locks that conflict with the queued lock will queue up behind it.” So a statement that needs only a millisecond of work can still freeze a table, because everything that arrives after it lines up behind the wait.

Three kinds of work a statement can do

Once the lock is granted, a statement does one of three things, and the difference decides how long everyone else waits.

A metadata change edits the catalog and touches no rows, so it took about a millisecond on both of my tables. A scan reads every row to check a rule, so its time grows with the table. A rewrite builds a new copy of the table and its indexes, which is the slowest of the three. In step 2 you will measure all three kinds instead of taking my word for it.

Words used in the rest of the tutorial

A backfill fills a new column for the rows that already exist. Expand and contract is the habit of changing a database in two safe stages: first add the new structure next to the old one (expand), and only after no running code uses the old structure, remove it (contract). A lint gate is a program that reads a migration file, applies rules, and exits with a failure code if a rule is broken, so that a deploy pipeline can refuse the file.

Prerequisites

You need a PostgreSQL 18 server you can connect to as a superuser, because the lab creates a database and runs CHECKPOINT. I used PostgreSQL 18.6 on Windows 11 with the server listening on port 55432; any PostgreSQL 18 server should work. You need Python 3.10 or newer (psycopg 3.3.6 and pytest 9.2.0 both declare Requires-Python >=3.10; I used 3.13.14), about 1 GB of free disk, and five to ten minutes for the full run. You should know basic SQL (SELECT, INSERT, CREATE TABLE) and be able to read short Python scripts.

Create a folder, a virtual environment and install the two packages. Then tell the PostgreSQL client library where your server is. The scripts read the standard PGHOST, PGPORT and PGUSER variables (add PGPASSWORD if your server wants one).

python -m venv .venv
.venv/bin/python -m pip install "psycopg[binary]" pytest
export PGHOST=localhost PGPORT=5432 PGUSER=postgres

On Windows the interpreter is .venv\Scripts\python.exe, and in PowerShell you set the variables with $env:PGPORT = "5432". Every file below goes in that one folder, and the first line of each is a comment with its own name so you can tell them apart.

Step 1: Create a database with 2,000,000 orders

The lab needs a table big enough that slow statements are visibly slow. lab.py builds a customers table with 1,000 rows and an orders table with 2,000,000 rows, plus a 200,000-row copy named orders_small. The copy exists for one reason: if a statement takes about ten times longer on the table with ten times the rows, its time grows with the table and it will hurt more as your data grows. That is the test you will use in step 2.

The file also holds two helpers you will reuse. connect() opens an autocommit connection (no hidden transactions, so what you see is what runs). Probe is a thread that runs one cheap statement over and over, dozens of times a second, and records how long each call took. That is how an application feels a migration: not as a lock name, but as calls that suddenly take seconds. The seed() function rebuilds the tables if an earlier run left them changed, and its last act is a CHECKPOINT so that a disk flush from loading the data is not mistaken for a lock later.

# lab.py
"""lab.py: shared helpers for the migration lab: connections, seed data and a latency probe."""
import threading
import time

import psycopg

ADMIN_DSN = "dbname=postgres"
DSN = "dbname=migratelab"
BIG, SMALL = 2_000_000, 200_000
BASELINE = ("id:bigint:NO,customer_id:integer:NO,status:text:YES,"
            "amount_cents:integer:NO,created_at:timestamp with time zone:NO")
LOCKS = ["AccessShareLock", "RowShareLock", "RowExclusiveLock", "ShareUpdateExclusiveLock",
         "ShareLock", "ShareRowExclusiveLock", "ExclusiveLock", "AccessExclusiveLock"]


def say(text=""):
    print(text, flush=True)


def connect(name="lab", autocommit=True):
    """libpq reads PGHOST, PGPORT and PGUSER from the environment, so the DSN stays short."""
    return psycopg.connect(DSN, autocommit=autocommit, application_name=name)


def strongest(modes):
    return max(modes, key=LOCKS.index) if modes else None


def build_orders(conn, table, rows):
    conn.execute(f"""CREATE TABLE {table} (
        id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
        customer_id integer NOT NULL,
        status text,
        amount_cents integer NOT NULL,
        created_at timestamptz NOT NULL DEFAULT now())""")
    conn.execute("SELECT setseed(0.42)")
    conn.execute(f"""INSERT INTO {table} (customer_id, status, amount_cents, created_at)
        SELECT 1 + floor(random() * 1000)::int,
               (ARRAY['new', 'paid', 'shipped', 'cancelled'])[1 + floor(random() * 4)::int],
               floor(random() * 100000)::int,
               timestamptz '2026-01-01' + random() * 300 * interval '1 day'
        FROM generate_series(1, {rows})""")
    conn.execute(f"CREATE INDEX {table}_amount_idx ON {table} (amount_cents)")
    conn.execute(f"VACUUM ANALYZE {table}")


def is_baseline(conn):
    if not conn.execute("SELECT to_regclass('orders') IS NOT NULL "
                        "AND to_regclass('orders_small') IS NOT NULL").fetchone()[0]:
        return False
    shape = conn.execute("""SELECT string_agg(column_name || ':' || data_type || ':' || is_nullable, ','
                                   ORDER BY ordinal_position)
                            FROM information_schema.columns
                            WHERE table_schema = 'public' AND table_name = 'orders'""").fetchone()[0]
    extras = conn.execute("""SELECT (SELECT count(*) FROM pg_indexes WHERE tablename = 'orders')
        + (SELECT count(*) FROM pg_trigger WHERE tgrelid = 'orders'::regclass AND NOT tgisinternal)
        + (SELECT count(*) FROM pg_constraint
           WHERE conrelid = 'orders'::regclass AND contype IN ('c', 'f', 'u'))""").fetchone()[0]
    return shape == BASELINE and extras == 2  # the primary key and the amount index


def seed():
    """Create the lab database once, then make sure orders is the 2,000,000-row baseline."""
    with psycopg.connect(ADMIN_DSN, autocommit=True) as admin:
        if not admin.execute("SELECT 1 FROM pg_database WHERE datname = 'migratelab'").fetchone():
            admin.execute("CREATE DATABASE migratelab")
    with connect("seed") as conn:
        if is_baseline(conn):
            conn.execute("DELETE FROM orders WHERE status = 'probe'")
            return
        started = time.perf_counter()
        conn.execute("DROP TABLE IF EXISTS orders, orders_small, customers CASCADE")
        conn.execute("CREATE TABLE customers (id integer PRIMARY KEY, name text NOT NULL)")
        conn.execute("INSERT INTO customers SELECT g, 'customer ' || g FROM generate_series(1, 1000) AS g")
        build_orders(conn, "orders", BIG)
        build_orders(conn, "orders_small", SMALL)
        conn.execute("CHECKPOINT")  # start from a quiet server, so a flush is not mistaken for a lock
        say(f"seeded {BIG:,} + {SMALL:,} rows in {time.perf_counter() - started:.1f} s")


class Probe(threading.Thread):
    """Run one cheap statement over and over and record how long each call took, as an app would feel it."""

    def __init__(self, label, sql, args=None, period=0.01):
        super().__init__(daemon=True)
        self.label, self.sql, self.args, self.period = label, sql, args, period
        self.samples, self.errors = [], 0
        self.halt = threading.Event()
        self.t0 = time.perf_counter()

    def run(self):
        conn = connect(self.label)
        conn.execute("SET lock_timeout = '60s'")
        n = 0
        while not self.halt.is_set():
            args = self.args(n) if callable(self.args) else self.args
            started = time.perf_counter()
            try:
                conn.execute(self.sql, args)
            except psycopg.Error:
                self.errors += 1
            self.samples.append((started - self.t0, time.perf_counter() - started))
            n += 1
            time.sleep(self.period)
        conn.close()

    def finish(self):
        self.halt.set()
        self.join()
        taken = sorted(s[1] for s in self.samples)
        return {"calls": len(taken), "median_ms": taken[len(taken) // 2] * 1000,
                "max_ms": taken[-1] * 1000, "over_100ms": sum(t > 0.1 for t in taken),
                "errors": self.errors, "worst_at": max(self.samples, key=lambda s: s[1])[0]}


def fmt(s):
    return (f"{s['calls']} calls, median {s['median_ms']:.1f} ms, max {s['max_ms']:.0f} ms, "
            f"{s['over_100ms']} slower than 100 ms, {s['errors']} errors")

You will run every step through a small runner. run_all.py runs the steps you name (or all of them, in order), saves each step’s output in an out folder, and stops at the first failure. It uses the PG* variables you exported and falls back to the values shown if they are missing. Save it next to lab.py:

# run_all.py
"""run_all.py [step ...]: run the steps in order with this venv's python and save out/NAME.txt."""
import os
import subprocess
import sys

LAB = os.path.dirname(os.path.abspath(__file__))
ORDER = ["step01_matrix", "step02_queue", "step03_notnull", "step04_index", "step05_bigint", "pytest",
         "step06_gate", "step07_apply"]
wanted = sys.argv[1:] or ORDER
os.makedirs(f"{LAB}/out", exist_ok=True)
env = dict(os.environ, COLUMNS="170")
for name, value in {"PGHOST": "localhost", "PGPORT": "5432", "PGUSER": "postgres"}.items():
    env.setdefault(name, value)  # the variables you exported win; these are only fallbacks
for name in wanted:
    if name == "pytest":
        cmd = [sys.executable, "-X", "utf8", "-m", "pytest", "-q", "-p", "no:cacheprovider"]
    else:
        cmd = [sys.executable, "-X", "utf8", f"{LAB}/{name}.py"]
    result = subprocess.run(cmd, cwd=LAB, capture_output=True, text=True, encoding="utf-8", env=env)
    with open(f"{LAB}/out/{name}.txt", "w", encoding="utf-8", newline="\n") as fh:
        fh.write(result.stdout + result.stderr)
    print(f"== {name} rc={result.returncode}", flush=True)
    if result.returncode != 0:
        print((result.stdout + result.stderr)[-1500:], flush=True)
        break

The data is created the first time a step needs it, so you will meet the seeding in step 2, when you run python run_all.py step01_matrix. The first line of that output tells you how long the seeding took, and it depends on your disk:

seeded 2,000,000 + 200,000 rows in 9.2 s

Step 2: Measure what each statement locks, rewrites and holds

This script runs seventeen common statements, each inside a transaction that it rolls back, so the tables never change. For each one it reads three facts while the transaction is still open. The first is the strongest lock the statement holds on the table, taken from the pg_locks system view. The second is whether the table was rewritten, which you can tell because pg_relation_filenode() returns a new number when PostgreSQL writes a new copy of the file. The third is how long the statement took, which is also how long it holds its lock, because nothing else happens before the rollback. It runs every statement twice on each table and keeps the faster run, to damp one-off noise.

Some statements need a setup step, for example a VALIDATE needs a constraint that was added NOT VALID first. Those setups are committed, the statement under test is measured, and a cleanup statement puts the table back.

# step01_matrix.py
"""step01_matrix.py: what does each statement lock, does it rewrite the table, and how long does it hold the
lock? Every statement runs inside a transaction that is rolled back, so the tables never change."""
import json
import os
import re
import time

from lab import connect, say, seed, strongest

BLOCKS_READS = {"AccessExclusiveLock"}
BLOCKS_WRITES = {"ShareLock", "ShareRowExclusiveLock", "ExclusiveLock", "AccessExclusiveLock"}

CK_NOT_VALID = "ALTER TABLE orders ADD CONSTRAINT orders_status_ck CHECK (status IS NOT NULL) NOT VALID"
CK_VALIDATE = "ALTER TABLE orders VALIDATE CONSTRAINT orders_status_ck"
CK_DROP = "ALTER TABLE orders DROP CONSTRAINT orders_status_ck"
NN_NOT_VALID = "ALTER TABLE orders ADD CONSTRAINT orders_status_nn NOT NULL status NOT VALID"
FK_NOT_VALID = ("ALTER TABLE orders ADD CONSTRAINT orders_customer_fk FOREIGN KEY (customer_id) "
                "REFERENCES customers (id) NOT VALID")
FK_DROP = "ALTER TABLE orders DROP CONSTRAINT orders_customer_fk"

CASES = [  # (label, setup statements committed first, statement under test, cleanup statements)
    ("ADD COLUMN, no default", [], "ALTER TABLE orders ADD COLUMN note text", []),
    ("ADD COLUMN, constant default", [],
     "ALTER TABLE orders ADD COLUMN is_test boolean NOT NULL DEFAULT false", []),
    ("ADD COLUMN, volatile default", [],
     "ALTER TABLE orders ADD COLUMN touched_at timestamptz NOT NULL DEFAULT clock_timestamp()", []),
    ("ALTER COLUMN TYPE bigint", [], "ALTER TABLE orders ALTER COLUMN amount_cents TYPE bigint", []),
    ("SET NOT NULL, plain", [], "ALTER TABLE orders ALTER COLUMN status SET NOT NULL", []),
    ("ADD CHECK ... NOT VALID", [], CK_NOT_VALID, []),
    ("VALIDATE the CHECK", [CK_NOT_VALID], CK_VALIDATE, [CK_DROP]),
    ("SET NOT NULL after a valid CHECK", [CK_NOT_VALID, CK_VALIDATE],
     "ALTER TABLE orders ALTER COLUMN status SET NOT NULL", [CK_DROP]),
    ("ADD NOT NULL ... NOT VALID (PG 18)", [], NN_NOT_VALID, []),
    ("VALIDATE the NOT NULL (PG 18)", [NN_NOT_VALID],
     "ALTER TABLE orders VALIDATE CONSTRAINT orders_status_nn",
     ["ALTER TABLE orders DROP CONSTRAINT orders_status_nn"]),
    ("CREATE INDEX", [], "CREATE INDEX orders_status_idx ON orders (status)", []),
    ("ADD FOREIGN KEY", [],
     "ALTER TABLE orders ADD CONSTRAINT orders_customer_fk FOREIGN KEY (customer_id) REFERENCES customers (id)", []),
    ("ADD FOREIGN KEY ... NOT VALID", [], FK_NOT_VALID, []),
    ("VALIDATE the FOREIGN KEY", [FK_NOT_VALID], "ALTER TABLE orders VALIDATE CONSTRAINT orders_customer_fk",
     [FK_DROP]),
    ("ADD UNIQUE (id, amount_cents)", [],
     "ALTER TABLE orders ADD CONSTRAINT orders_amount_uq UNIQUE (id, amount_cents)", []),
    ("RENAME COLUMN", [], "ALTER TABLE orders RENAME COLUMN status TO state", []),
    ("DROP COLUMN", [], "ALTER TABLE orders DROP COLUMN created_at", []),
]


def measure(table, setup, sql, cleanup):
    sub = lambda text: re.sub(r"\borders\b", table, text)  # noqa: E731  run the same statement on the small copy
    admin = connect("matrix-admin")
    for statement in setup:
        admin.execute(sub(statement))
    try:
        with connect("matrix", autocommit=False) as conn:
            before = conn.execute(f"SELECT pg_relation_filenode('{table}')").fetchone()[0]
            started = time.perf_counter()
            conn.execute(sub(sql))
            ms = (time.perf_counter() - started) * 1000
            after = conn.execute(f"SELECT pg_relation_filenode('{table}')").fetchone()[0]
            held = conn.execute("""SELECT c.relname, l.mode FROM pg_locks l JOIN pg_class c ON c.oid = l.relation
                                   WHERE l.pid = pg_backend_pid() AND l.granted AND l.locktype = 'relation'
                                     AND c.relname IN (%s, 'customers')""", (table,)).fetchall()
            conn.rollback()
    finally:
        for statement in cleanup:
            admin.execute(sub(statement))
        admin.close()
    modes = {}
    for relation, mode in held:
        modes.setdefault(relation, []).append(mode)
    return {"ms": ms, "rewrote": before != after, "table_lock": strongest(modes.get(table, [])),
            "customers_lock": strongest(modes.get("customers", []))}


def best_of_two(table, setup, sql, cleanup):
    runs = [measure(table, setup, sql, cleanup) for _ in range(2)]
    return min(runs, key=lambda r: r["ms"])


def short(mode):
    return mode[:-4] if mode else "-"


def main():
    seed()
    results = []
    say(f"{'statement':36} {'lock on orders':22} {'also locks':19} {'blocks':13} {'rewrote':8}"
        f" {'ms 2M':>8} {'ms 200k':>8} {'ratio':>6}")
    for label, setup, sql, cleanup in CASES:
        big = best_of_two("orders", setup, sql, cleanup)
        small = best_of_two("orders_small", setup, sql, cleanup)
        locks = {big["table_lock"], big["customers_lock"]}
        blocks_writes = bool(locks & BLOCKS_WRITES)
        blocks = "reads+writes" if locks & BLOCKS_READS else "writes" if blocks_writes else "nothing"
        ratio = big["ms"] / small["ms"]
        say(f"{label:36} {short(big['table_lock']):22} "
            f"{('customers ' + short(big['customers_lock'])) if big['customers_lock'] else '-':19} "
            f"{blocks:13} {'yes' if big['rewrote'] else 'no':8} {big['ms']:8.1f} {small['ms']:8.1f} {ratio:6.1f}")
        results.append({"label": label, "setup": setup, "sql": sql, "table_lock": big["table_lock"],
                        "customers_lock": big["customers_lock"], "rewrote": big["rewrote"],
                        "blocks_writes": blocks_writes, "ms_big": big["ms"], "ms_small": small["ms"],
                        "ratio": ratio})
    os.makedirs("out", exist_ok=True)
    with open("out/matrix.json", "w", encoding="utf-8", newline="\n") as fh:
        json.dump(results, fh, indent=1)


if __name__ == "__main__":
    main()

Run it. This is the table I got:

statement                            lock on orders         also locks          blocks        rewrote     ms 2M  ms 200k  ratio
ADD COLUMN, no default               AccessExclusive        -                   reads+writes  no            0.6      0.6    1.0
ADD COLUMN, constant default         AccessExclusive        -                   reads+writes  no            1.4      1.2    1.1
ADD COLUMN, volatile default         AccessExclusive        -                   reads+writes  yes        2637.1    275.3    9.6
ALTER COLUMN TYPE bigint             AccessExclusive        -                   reads+writes  yes        2452.7    300.3    8.2
SET NOT NULL, plain                  AccessExclusive        -                   reads+writes  no           80.8      9.5    8.5
ADD CHECK ... NOT VALID              AccessExclusive        -                   reads+writes  no            1.0      0.8    1.2
VALIDATE the CHECK                   ShareUpdateExclusive   -                   nothing       no           66.9      7.2    9.3
SET NOT NULL after a valid CHECK     AccessExclusive        -                   reads+writes  no            0.9      0.8    1.2
ADD NOT NULL ... NOT VALID (PG 18)   AccessExclusive        -                   reads+writes  no            1.0      0.8    1.2
VALIDATE the NOT NULL (PG 18)        ShareUpdateExclusive   -                   nothing       no           79.8      8.8    9.0
CREATE INDEX                         Share                  -                   writes        no          659.9     92.5    7.1
ADD FOREIGN KEY                      ShareRowExclusive      customers ShareRowExclusive writes        no          105.4     12.8    8.2
ADD FOREIGN KEY ... NOT VALID        ShareRowExclusive      customers ShareRowExclusive writes        no            1.1      1.2    0.9
VALIDATE the FOREIGN KEY             ShareUpdateExclusive   customers RowShare  nothing       no          105.4     12.2    8.6
ADD UNIQUE (id, amount_cents)        AccessExclusive        -                   reads+writes  no          613.2     72.3    8.5
RENAME COLUMN                        AccessExclusive        -                   reads+writes  no            0.4      0.5    0.9
DROP COLUMN                          AccessExclusive        -                   reads+writes  no            1.0      0.8    1.2

How to read the table

Read each row left to right. Lock on orders is the mode. Blocks says which other statements must wait while it is held: reads+writes means even a plain SELECT waits, writes means INSERT, UPDATE and DELETE wait but reads continue, and nothing means ordinary reads and writes carry on. Rewrote is the filenode test. The last three columns are the milliseconds on 2,000,000 rows, on 200,000 rows, and the ratio between them.

Four groups stand out.

Metadata only. Adding a column with no default or with a constant default, adding a CHECK constraint as NOT VALID, renaming, dropping a column, adding a not-null constraint as NOT VALID, and SET NOT NULL after a validated CHECK all took about a millisecond on both tables, with a ratio near 1. They still ask for ACCESS EXCLUSIVE, so they are only safe if they can get the lock quickly. That is the subject of step 3. The documentation explains why adding a column is cheap: “the default value is evaluated at the time of the statement and the result stored in the table’s metadata, where it will be returned when any existing rows are accessed.”

Rewrites. A volatile default (clock_timestamp()) took 2.6 seconds and a type change to bigint took 2.5 seconds, both with the filenode changing and ratios of 9.6 and 8.2 for ten times the rows. During that time every read and write waits. The documentation lists what triggers it: adding a column with a volatile default, a stored generated column, an identity column, or a domain with constraints “will cause the entire table and its indexes to be rewritten”, and “Changing the type of an existing column will normally cause the entire table and its indexes to be rewritten.”

Scans under a blocking lock. A plain SET NOT NULL (81 ms), CREATE INDEX (660 ms), ADD FOREIGN KEY (105 ms) and ADD UNIQUE (613 ms) read the whole table while they hold a lock that blocks writes, or reads too. Their ratios, 7.1 to 8.5 for ten times the rows, say that the time grows with the table. If it keeps growing in proportion, the 81 ms scan would hold its lock for roughly 8 seconds on 200 million rows. That is my arithmetic, not a measurement.

Scans that do not block. The three VALIDATE CONSTRAINT rows scan the whole table too (67, 80 and 105 ms), but their lock is SHARE UPDATE EXCLUSIVE, which does not block ordinary reads and writes. The documentation says so directly: “This command acquires a SHARE UPDATE EXCLUSIVE lock.” This is the trick the safe patterns in the rest of this tutorial rely on: split a check into a quick NOT VALID step that only takes a brief lock and a slow VALIDATE step that does the scan without blocking reads or writes.

Two more details are worth noticing. A SET NOT NULL that follows a validated CHECK took 0.9 ms instead of 81 ms, because, in the documentation’s words, “if a valid CHECK constraint exists (and is not dropped in the same command) which proves no NULL can exist, then the table scan is skipped.” And the two rows marked PG 18 exist because that release added a new option: its release notes say “Allow ALTER TABLE to set the NOT VALID attribute of NOT NULL constraints.” The ALTER TABLE page now says NOT VALID “is currently only allowed for foreign-key, CHECK, and not-null constraints.”

A note on foreign keys

The GoCardless post, whose current page says it was last edited in June 2024, says in a footnote that adding a foreign key, even as NOT VALID, makes PostgreSQL “briefly hold an AccessExclusive lock against both tables while it adds constraint triggers to them.” The row for ADD FOREIGN KEY with NOT VALID in my table shows something different on 18.6: SHARE ROW EXCLUSIVE on both orders and customers, which blocks writes but not reads. That matches the PostgreSQL 18 documentation: “ADD FOREIGN KEY requires only a SHARE ROW EXCLUSIVE lock.” The 9.5 release notes contain an entry, “Reduce lock levels of some create and alter trigger and foreign key commands”, which is the likely origin of the change, though the entry does not name the lock modes, so I cannot say which statement in it covers this one. Step 3 tests the practical consequence.

Step 3: See how a one-millisecond statement freezes a table

Here is the experiment behind the kind of outage GoCardless described. Session A is an ordinary transaction that read some rows and then went idle without committing, like a console somebody left open. Session B runs a metadata-only ALTER TABLE that needs ACCESS EXCLUSIVE, so it must wait for A. Meanwhile two probes, a reader and a writer, keep calling the table. The script takes a look at pg_stat_activity 1.5 seconds in, using pg_blocking_pids() to show who waits for whom, and A commits at 3 seconds.

The same file runs four experiments. The first is the dangerous case. The second runs the same statement through the polite runner from migrate.py, which you will read in a moment. The third and fourth test the foreign-key claim from step 2 with two different sessions A.

# step02_queue.py
"""step02_queue.py: a metadata-only ALTER TABLE can still freeze a table, because it waits in the lock queue."""
import random
import threading
import time

from lab import DSN, Probe, connect, fmt, say, seed
from migrate import run_ddl

HOLD = 3.0  # seconds that session A keeps its transaction open

READ_ORDERS = ("reader", "SELECT id FROM orders WHERE id = 1")
WRITE_ORDERS = ("writer", "INSERT INTO orders (customer_id, status, amount_cents) VALUES (1, 'probe', 100)")
READ_CUSTOMERS = ("reader", "SELECT name FROM customers WHERE id = 2")
WRITE_CUSTOMERS = ("writer", "UPDATE customers SET name = name WHERE id = 2")


def experiment(title, a_sql, ddl, probes, cleanup, use_runner=False):
    seed()
    say(f"=== {title}")
    a = connect("A", autocommit=False)  # the open transaction, like an analyst's console left idle
    a.execute(a_sql)
    t0 = time.perf_counter()
    running = [Probe(*spec) for spec in probes]
    for probe in running:
        probe.start()
    result = {}

    def migrate():
        if use_runner:
            result["attempts"] = run_ddl(DSN, ddl, lock_timeout_ms=200, rng=random.Random(7),
                                         log=lambda m: say(f"  migration {m}"))
        else:
            with connect("B") as b:
                b.execute(ddl)
        result["done"] = time.perf_counter() - t0

    time.sleep(0.3)
    worker = threading.Thread(target=migrate)
    worker.start()
    time.sleep(1.2)
    with connect("watcher") as w:
        rows = w.execute("""SELECT application_name, pid, state, wait_event_type, wait_event, pg_blocking_pids(pid)
                            FROM pg_stat_activity
                            WHERE datname = 'migratelab' AND application_name IN ('A', 'B', 'migration', 'reader', 'writer')
                            ORDER BY application_name""").fetchall()
    names = {pid: name for name, pid, *_ in rows}
    for name, pid, state, kind, event, blockers in rows:
        by = ",".join(names.get(b, str(b)) for b in blockers) or "-"
        say(f"  at t=1.5 s  {name:10} {state:20} waiting on {kind or '-'}:{event or '-'}  blocked by {by}")
    time.sleep(HOLD - 1.5)
    a.commit()
    say(f"  A commits at t={time.perf_counter() - t0:.1f} s")
    worker.join()
    time.sleep(0.2)
    for probe in running:
        say(f"  {probe.label:8} {fmt(probe.finish())}")
    attempts = f" after {result['attempts']} attempts" if "attempts" in result else ""
    say(f"  the DDL finished at t={result['done']:.1f} s{attempts}")
    a.close()
    with connect("cleanup") as c:
        for statement in cleanup:
            c.execute(statement)


ADD_NOTE = "ALTER TABLE orders ADD COLUMN note text"
DROP_NOTE = "ALTER TABLE orders DROP COLUMN IF EXISTS note"
ADD_FK = ("ALTER TABLE orders ADD CONSTRAINT orders_customer_fk FOREIGN KEY (customer_id) "
          "REFERENCES customers (id) NOT VALID")
DROP_FK = "ALTER TABLE orders DROP CONSTRAINT IF EXISTS orders_customer_fk"

if __name__ == "__main__":
    experiment("1. ADD COLUMN behind an open transaction, no lock_timeout",
               "SELECT count(*) FROM orders WHERE customer_id = 7", ADD_NOTE,
               [READ_ORDERS, WRITE_ORDERS], [DROP_NOTE])
    experiment("2. the same ADD COLUMN with lock_timeout 200 ms and retries",
               "SELECT count(*) FROM orders WHERE customer_id = 7", ADD_NOTE,
               [READ_ORDERS, WRITE_ORDERS], [DROP_NOTE], use_runner=True)
    experiment("3. ADD FOREIGN KEY while A holds a plain read on customers",
               "SELECT name FROM customers WHERE id = 1", ADD_FK,
               [READ_CUSTOMERS, WRITE_CUSTOMERS], [DROP_FK])
    experiment("4. ADD FOREIGN KEY while A holds an uncommitted write on customers",
               "UPDATE customers SET name = name WHERE id = 1", ADD_FK,
               [READ_CUSTOMERS, WRITE_CUSTOMERS], [DROP_FK])

Experiment 1 first, the plain ALTER TABLE orders ADD COLUMN with no timeout:

=== 1. ADD COLUMN behind an open transaction, no lock_timeout
  at t=1.5 s  A          idle in transaction  waiting on Client:ClientRead  blocked by -
  at t=1.5 s  B          active               waiting on Lock:relation  blocked by A
  at t=1.5 s  reader     active               waiting on Lock:relation  blocked by B
  at t=1.5 s  writer     active               waiting on Lock:relation  blocked by B
  A commits at t=3.0 s
  reader   47 calls, median 0.3 ms, max 2710 ms, 1 slower than 100 ms, 0 errors
  writer   47 calls, median 0.6 ms, max 2709 ms, 1 slower than 100 ms, 0 errors
  the DDL finished at t=3.0 s

Read the snapshot lines. A is idle in transaction and blocked by nobody, exactly as intended. B is waiting on Lock:relation, blocked by A. And the reader and the writer, which do not conflict with A at all, are blocked by B. A single wait made a wall: the reader’s and the writer’s calls took 2,710 and 2,709 ms instead of under a millisecond, and the ALTER TABLE itself did no work until A committed. Nothing is wrong with the statement. The lock queue turned it into an outage.

The fix: a short lock_timeout and a retry loop

PostgreSQL has a setting for exactly this. The documentation describes lock_timeout as: “Abort any statement that waits longer than the specified amount of time while attempting to acquire a lock on a table, index, row, or other database object.” A statement that gives up leaves the queue, and everything behind it moves again. GoCardless gave the same advice: “Set lock_timeout in your migration scripts to a pause your app can tolerate. It’s better to abort a deploy than take your application down.”

Set it in the migration session, not globally: the documentation says “Setting lock_timeout in postgresql.conf is not recommended because it would affect all sessions.” migrate.py wraps one statement in that policy. It opens its own connection, sets a short lock_timeout and a long statement_timeout, runs the statement, and on a lock timeout (SQLSTATE 55P03, which psycopg raises as LockNotAvailable) waits with an exponentially growing, jittered pause and tries again. Statements containing CONCURRENTLY run outside a transaction block because PostgreSQL requires that, as you will see in step 5.

# migrate.py
"""migrate.py: run one DDL statement politely: short lock_timeout, retry with jitter, never queue for long."""
import random
import time

import psycopg
from psycopg import errors


def run_ddl(dsn, sql, lock_timeout_ms=200, statement_timeout_ms=120_000, attempts=10,
            base_delay=0.2, max_delay=2.0, rng=None, log=None):
    """Run sql on its own connection and return how many attempts it took.

    A statement that cannot get its lock within lock_timeout_ms gives the lock queue back and retries
    after a jittered pause. Any other error is raised at once."""
    rng = rng or random.Random()
    for attempt in range(1, attempts + 1):
        try:
            with psycopg.connect(dsn, autocommit=True, application_name="migration") as conn:
                conn.execute(f"SET lock_timeout = {int(lock_timeout_ms)}")
                conn.execute(f"SET statement_timeout = {int(statement_timeout_ms)}")
                if "CONCURRENTLY" in sql.upper():
                    conn.execute(sql)  # these cannot run inside a transaction block
                else:
                    with conn.transaction():
                        conn.execute(sql)
            return attempt
        except errors.LockNotAvailable:
            delay = min(max_delay, base_delay * 2 ** (attempt - 1)) * rng.uniform(0.5, 1.0)
            if log:
                log(f"attempt {attempt}: no lock within {lock_timeout_ms} ms, retrying in {delay:.2f} s")
            if attempt == attempts:
                raise
            time.sleep(delay)

Experiment 2 runs the same ADD COLUMN through run_ddl with a 200 ms timeout:

=== 2. the same ADD COLUMN with lock_timeout 200 ms and retries
  migration attempt 1: no lock within 200 ms, retrying in 0.13 s
  migration attempt 2: no lock within 200 ms, retrying in 0.23 s
  migration attempt 3: no lock within 200 ms, retrying in 0.66 s
  at t=1.5 s  A          idle in transaction  waiting on Client:ClientRead  blocked by -
  at t=1.5 s  reader     idle                 waiting on Client:ClientRead  blocked by -
  at t=1.5 s  writer     idle                 waiting on Client:ClientRead  blocked by -
  migration attempt 4: no lock within 200 ms, retrying in 0.86 s
  A commits at t=3.0 s
  reader   237 calls, median 0.3 ms, max 212 ms, 4 slower than 100 ms, 0 errors
  writer   231 calls, median 0.5 ms, max 211 ms, 4 slower than 100 ms, 0 errors
  the DDL finished at t=3.2 s after 5 attempts

The migration gave up four times, then succeeded on the fifth attempt, 0.2 seconds after A committed. The reader’s and the writer’s worst calls were 212 and 211 ms instead of 2.7 seconds, and 4 calls in each probe were slower than 100 ms. Each failed attempt still blocks the table for up to the timeout, so the damage per attempt is bounded by the number you choose and not removed. Pick a timeout your application can absorb, and let the pauses between attempts grow, as run_ddl does, so the table gets quiet time between attempts.

Which statements queue everyone, and which do not

Experiments 3 and 4 add a foreign key as NOT VALID, which needs SHARE ROW EXCLUSIVE on customers. In experiment 3, session A only read a customers row. In experiment 4, A updated one without committing, which holds ROW EXCLUSIVE.

=== 3. ADD FOREIGN KEY while A holds a plain read on customers
  at t=1.5 s  A          idle in transaction  waiting on Client:ClientRead  blocked by -
  at t=1.5 s  reader     idle                 waiting on Client:ClientRead  blocked by -
  at t=1.5 s  writer     idle                 waiting on Client:ClientRead  blocked by -
  A commits at t=3.0 s
  reader   303 calls, median 0.3 ms, max 1 ms, 0 slower than 100 ms, 0 errors
  writer   285 calls, median 0.5 ms, max 64 ms, 0 slower than 100 ms, 0 errors
  the DDL finished at t=0.3 s
=== 4. ADD FOREIGN KEY while A holds an uncommitted write on customers
  at t=1.5 s  A          idle in transaction  waiting on Client:ClientRead  blocked by -
  at t=1.5 s  B          active               waiting on Lock:relation  blocked by A
  at t=1.5 s  reader     idle                 waiting on Client:ClientRead  blocked by -
  at t=1.5 s  writer     active               waiting on Lock:relation  blocked by B
  A commits at t=3.2 s
  reader   292 calls, median 0.9 ms, max 2 ms, 0 slower than 100 ms, 0 errors
  writer   47 calls, median 0.6 ms, max 2815 ms, 1 slower than 100 ms, 0 errors
  the DDL finished at t=3.2 s

In experiment 3 the foreign key was added at t=0.3 s, immediately, and nothing queued behind it: the reader’s slowest call was 1 ms and the writer’s was 64 ms, a single blip that I did not trace. A plain read does not conflict with SHARE ROW EXCLUSIVE, so the example in the GoCardless post, a long read on the referenced table, does not queue anything on 18.6. In experiment 4, A’s uncommitted write conflicts, so B waited, and the writer probe stalled for 2,815 ms behind it while the reader stayed at 2 ms. The lock mode decides who queues. The lesson is the same: a long transaction that has written to a table can hold up a migration, and then every write that arrives behind the migration waits too.

For the application side, the PostgreSQL documentation offers idle_in_transaction_session_timeout: “This option can be used to ensure that idle sessions do not hold locks for an unreasonable amount of time.” It ends sessions like A so they cannot sit on locks for hours.

Step 4: Add NOT NULL without a long lock

Suppose orders.status allows NULLs and you want to forbid them. The obvious statement, SET NOT NULL, scans the table while it holds ACCESS EXCLUSIVE. This script tries three ways, each from the baseline table, with a reader and a writer probe running, and reports what the probes felt.

Way A is the obvious statement. Way B adds a CHECK (status IS NOT NULL) as NOT VALID (instant), validates it (a scan that does not block reads or writes), then runs SET NOT NULL, which now skips its scan, and finally drops the helper constraint. Way C is the PostgreSQL 18 shortcut: add the not-null constraint as NOT VALID, then validate it. For way C the script also prints two catalog columns after each statement so you can see the constraint become valid.

The last part of the file shows what happens when validation fails. A NOT VALID constraint is not a promise about old rows, only about new ones. The documentation says so: “Once the constraint is in place, no new violations can be inserted, and the existing problems can be corrected at leisure until VALIDATE CONSTRAINT finally succeeds.”

# step03_notnull.py
"""step03_notnull.py: three ways to make orders.status NOT NULL, and what a reader and a writer feel."""
import random
import time

import psycopg

from lab import DSN, Probe, connect, fmt, say, seed
from migrate import run_ddl

WAYS = {
    "A. plain SET NOT NULL": (
        ["ALTER TABLE orders ALTER COLUMN status SET NOT NULL"],
        ["ALTER TABLE orders ALTER COLUMN status DROP NOT NULL"]),
    "B. CHECK NOT VALID, VALIDATE, SET NOT NULL": (
        ["ALTER TABLE orders ADD CONSTRAINT orders_status_ck CHECK (status IS NOT NULL) NOT VALID",
         "ALTER TABLE orders VALIDATE CONSTRAINT orders_status_ck",
         "ALTER TABLE orders ALTER COLUMN status SET NOT NULL",
         "ALTER TABLE orders DROP CONSTRAINT orders_status_ck"],
        ["ALTER TABLE orders ALTER COLUMN status DROP NOT NULL"]),
    "C. NOT NULL NOT VALID, VALIDATE (PG 18)": (
        ["ALTER TABLE orders ADD CONSTRAINT orders_status_nn NOT NULL status NOT VALID",
         "ALTER TABLE orders VALIDATE CONSTRAINT orders_status_nn"],
        ["ALTER TABLE orders DROP CONSTRAINT orders_status_nn"]),
}


def try_way(title, statements, cleanup):
    seed()
    say(f"=== {title}")
    rng = random.Random(3)
    reader = Probe("reader", "SELECT id FROM orders WHERE id = %s", lambda n: (rng.randint(1, 2_000_000),))
    writer = Probe("writer", "INSERT INTO orders (customer_id, status, amount_cents) VALUES (1, 'probe', 100)")
    reader.start()
    writer.start()
    time.sleep(0.5)
    for statement in statements:
        started = time.perf_counter()
        run_ddl(DSN, statement)
        say(f"  {statement[:78]:78} {time.perf_counter() - started:5.2f} s")
        if "orders_status_nn" in statement:
            with connect("peek") as c:
                valid = c.execute("SELECT convalidated FROM pg_constraint WHERE conname = 'orders_status_nn'").fetchone()[0]
                flag = c.execute("SELECT attnotnull FROM pg_attribute "
                                 "WHERE attrelid = 'orders'::regclass AND attname = 'status'").fetchone()[0]
            say(f"    convalidated = {valid}, attnotnull = {flag}")
    time.sleep(0.3)
    say(f"  reader: {fmt(reader.finish())}")
    say(f"  writer: {fmt(writer.finish())}")
    with connect("cleanup") as c:
        for statement in cleanup:
            c.execute(statement)


def failing_validation():
    seed()
    say("=== VALIDATE fails when an old row breaks the rule, and new rows are already refused")
    with connect("fix") as c:
        row_id = c.execute("INSERT INTO orders (customer_id, status, amount_cents) "
                           "VALUES (1, NULL, 100) RETURNING id").fetchone()[0]
        c.execute("ALTER TABLE orders ADD CONSTRAINT orders_status_ck CHECK (status IS NOT NULL) NOT VALID")
        for label, statement in [("a new NULL row", "INSERT INTO orders (customer_id, status, amount_cents) "
                                                    "VALUES (1, NULL, 100)"),
                                 ("VALIDATE with one old NULL row", "ALTER TABLE orders VALIDATE CONSTRAINT orders_status_ck")]:
            try:
                c.execute(statement)
            except psycopg.Error as exc:
                say(f"  {label}: SQLSTATE {exc.sqlstate}: {exc.diag.message_primary}")
        c.execute("UPDATE orders SET status = 'new' WHERE status IS NULL")
        c.execute("ALTER TABLE orders VALIDATE CONSTRAINT orders_status_ck")
        say("  after fixing the row, VALIDATE succeeds")
        c.execute("ALTER TABLE orders DROP CONSTRAINT orders_status_ck")
        c.execute("DELETE FROM orders WHERE id = %s", (row_id,))


if __name__ == "__main__":
    for title, (statements, cleanup) in WAYS.items():
        try_way(title, statements, cleanup)
    failing_validation()
=== A. plain SET NOT NULL
  ALTER TABLE orders ALTER COLUMN status SET NOT NULL                             0.11 s
  reader: 73 calls, median 0.5 ms, max 82 ms, 0 slower than 100 ms, 0 errors
  writer: 73 calls, median 0.7 ms, max 81 ms, 0 slower than 100 ms, 0 errors
=== B. CHECK NOT VALID, VALIDATE, SET NOT NULL
  ALTER TABLE orders ADD CONSTRAINT orders_status_ck CHECK (status IS NOT NULL)   0.03 s
  ALTER TABLE orders VALIDATE CONSTRAINT orders_status_ck                         0.09 s
  ALTER TABLE orders ALTER COLUMN status SET NOT NULL                             0.14 s
  ALTER TABLE orders DROP CONSTRAINT orders_status_ck                             0.02 s
  reader: 86 calls, median 0.5 ms, max 1 ms, 0 slower than 100 ms, 0 errors
  writer: 97 calls, median 0.6 ms, max 3 ms, 0 slower than 100 ms, 0 errors
=== C. NOT NULL NOT VALID, VALIDATE (PG 18)
  ALTER TABLE orders ADD CONSTRAINT orders_status_nn NOT NULL status NOT VALID    0.03 s
    convalidated = False, attnotnull = True
  ALTER TABLE orders VALIDATE CONSTRAINT orders_status_nn                         0.10 s
    convalidated = True, attnotnull = True
  reader: 97 calls, median 0.6 ms, max 3 ms, 0 slower than 100 ms, 0 errors
  writer: 97 calls, median 0.7 ms, max 3 ms, 0 slower than 100 ms, 0 errors
=== VALIDATE fails when an old row breaks the rule, and new rows are already refused
  a new NULL row: SQLSTATE 23514: new row for relation "orders" violates check constraint "orders_status_ck"
  VALIDATE with one old NULL row: SQLSTATE 23514: check constraint "orders_status_ck" of relation "orders" is violated by some row
  after fixing the row, VALIDATE succeeds

Way A held its lock for the length of the scan: the reader’s and the writer’s slowest calls were 82 and 81 ms. Ways B and C kept both probes at 1 to 3 ms. On 2,000,000 rows the difference is small in absolute terms, which is the honest reading of this table, but the scan time grows with the table while the metadata steps do not. Way C needs two statements instead of four and leaves no helper constraint behind. After the second statement, convalidated flips from False to True while attnotnull was already True.

The failure demo at the bottom shows the safety net. With the NOT VALID check in place, a new NULL row is refused at once (SQLSTATE 23514), and VALIDATE fails while an old NULL row exists, with a message that names the constraint. You fix the data with an ordinary UPDATE and validate again. Readers and writers were not blocked while you did.

Step 5: Build indexes without blocking writes

A plain CREATE INDEX takes the SHARE lock, which the documentation says is “Acquired by CREATE INDEX (without CONCURRENTLY)” and which conflicts with the row-changing lock modes. This script builds the same index both ways while a writer probe inserts rows, then shows two ways a concurrent build can go wrong.

# step04_index.py
"""step04_index.py: CREATE INDEX versus CREATE INDEX CONCURRENTLY while a writer keeps inserting."""
import random
import time

import psycopg

from lab import DSN, Probe, connect, fmt, say, seed
from migrate import run_ddl


def build(title, statement):
    seed()
    say(f"=== {title}")
    writer = Probe("writer", "INSERT INTO orders (customer_id, status, amount_cents) VALUES (1, 'probe', 100)")
    writer.start()
    time.sleep(0.5)
    started = time.perf_counter()
    run_ddl(DSN, statement, rng=random.Random(7))
    say(f"  {statement}: {time.perf_counter() - started:.2f} s")
    time.sleep(0.3)
    say(f"  writer: {fmt(writer.finish())}")
    with connect("cleanup") as c:
        c.execute("DROP INDEX IF EXISTS orders_status_idx")


def failure_modes():
    seed()
    say("=== a concurrent build that fails leaves an INVALID index behind")
    with connect("failure") as c:
        try:
            c.execute("CREATE UNIQUE INDEX CONCURRENTLY orders_status_uq ON orders (status)")
        except psycopg.Error as exc:
            say(f"  SQLSTATE {exc.sqlstate}: {exc.diag.message_primary}")
            say(f"  DETAIL: {exc.diag.message_detail}")
        invalid = c.execute("""SELECT c.relname, i.indisvalid FROM pg_index i JOIN pg_class c ON c.oid = i.indexrelid
                               WHERE i.indrelid = 'orders'::regclass ORDER BY 1""").fetchall()
        say(f"  indexes on orders: {invalid}")
        c.execute("DROP INDEX CONCURRENTLY orders_status_uq")
        say("  DROP INDEX CONCURRENTLY orders_status_uq: done")
        try:
            with c.transaction():
                c.execute("CREATE INDEX CONCURRENTLY orders_status_idx ON orders (status)")
        except psycopg.Error as exc:
            say(f"  inside a transaction block: SQLSTATE {exc.sqlstate}: {exc.diag.message_primary}")


if __name__ == "__main__":
    build("plain CREATE INDEX", "CREATE INDEX orders_status_idx ON orders (status)")
    build("CREATE INDEX CONCURRENTLY", "CREATE INDEX CONCURRENTLY orders_status_idx ON orders (status)")
    failure_modes()
=== plain CREATE INDEX
  CREATE INDEX orders_status_idx ON orders (status): 0.71 s
  writer: 72 calls, median 0.8 ms, max 680 ms, 1 slower than 100 ms, 0 errors
=== CREATE INDEX CONCURRENTLY
  CREATE INDEX CONCURRENTLY orders_status_idx ON orders (status): 0.84 s
  writer: 136 calls, median 0.7 ms, max 4 ms, 0 slower than 100 ms, 0 errors
=== a concurrent build that fails leaves an INVALID index behind
  SQLSTATE 23505: could not create unique index "orders_status_uq"
  DETAIL: Key (status)=(paid) is duplicated.
  indexes on orders: [('orders_amount_idx', True), ('orders_pkey', True), ('orders_status_uq', False)]
  DROP INDEX CONCURRENTLY orders_status_uq: done
  inside a transaction block: SQLSTATE 25001: CREATE INDEX CONCURRENTLY cannot run inside a transaction block

The plain build took 0.71 s and the writer’s slowest call was 680 ms: writes simply waited for the build. The concurrent build took 0.84 s, and the writer’s slowest call was 4 ms. On this idle machine the two builds took about the same time. The documentation explains why that is not the general case: “In a concurrent index build, the index is actually entered as an ‘invalid’ index into the system catalogs in one transaction, then two table scans occur in two more transactions”, and it must wait for other transactions along the way, so expect it to be slower on a busy system.

Two failure modes are in the output. If a concurrent build fails, here a unique index on a column that has duplicates, it leaves an invalid index behind. The listing shows orders_status_uq with indisvalid False. Queries ignore the index, but according to the documentation it still consumes update overhead, so its advice applies: “The recommended recovery method in such cases is to drop the index and try again to perform CREATE INDEX CONCURRENTLY.” And a concurrent build cannot run in a transaction at all: “a regular CREATE INDEX command can be performed within a transaction block, but CREATE INDEX CONCURRENTLY cannot.” PostgreSQL reports it as SQLSTATE 25001. (The DETAIL line of the failed unique build names one duplicated value, and which value it names can differ from run to run.) Some migration tools wrap each migration in a transaction, so check yours before you rely on this.

Step 6: Change a column type with expand, backfill and contract

Changing amount_cents from integer to bigint is the classic hard case, because the statement is a rewrite. The Strong Migrations project, a Ruby library that catches unsafe Rails migrations in development, says it plainly: “Changing the type of a column causes the entire table to be rewritten. During this time, reads and writes are blocked in Postgres”. This script measures that, then does the same job in small steps while three probes run: a reader, a writer, and an updater that changes amount_cents on random rows.

The small steps have a fixed order. Expand: add a nullable amount_cents_v2 bigint column (instant), add a trigger so every insert or update of the old column also fills the new one, copy the existing rows in batches of 10,000 with one short transaction per batch, build an index on the new column concurrently, and check that the two columns agree. Release: deploy application code that uses the new column. I do not simulate that step; a rolling deploy has its own care, such as writing both columns until the old version is gone. Contract: when only code that knows the new column is running, drop the not-null rule on the old column, the trigger, the function and finally the old column.

The last function, too_early(), repeats the contract step at the wrong moment on purpose: it renames the old column for one second while release N is still running.

# step05_bigint.py
"""step05_bigint.py: change orders.amount_cents from integer to bigint, the naive way and in small steps."""
import random
import time

from lab import DSN, Probe, connect, fmt, say, seed
from migrate import run_ddl

BATCH = 10_000
COPY_FUNCTION = """CREATE OR REPLACE FUNCTION orders_copy_amount() RETURNS trigger LANGUAGE plpgsql AS $$
BEGIN
    NEW.amount_cents_v2 := NEW.amount_cents;
    RETURN NEW;
END
$$"""
COPY_TRIGGER = ("CREATE TRIGGER orders_copy_amount BEFORE INSERT OR UPDATE OF amount_cents ON orders "
                "FOR EACH ROW EXECUTE FUNCTION orders_copy_amount()")


def release(column):
    """Probes that behave like one release of the application: they only know the column they are given."""
    rng = random.Random(1)
    return [Probe("reader", "SELECT id FROM orders WHERE id = %s", lambda n: (rng.randint(1, 2_000_000),)),
            Probe("writer", f"INSERT INTO orders (customer_id, status, {column}) VALUES (1, 'probe', 100)"),
            Probe("updater", f"UPDATE orders SET {column} = {column} + 1 WHERE id = %s",
                  lambda n: (rng.randint(1, 2_000_000),))]


def size_mb():
    with connect("size") as c:
        return c.execute("SELECT pg_total_relation_size('orders')").fetchone()[0] / 1_000_000


def start(running):
    for probe in running:
        probe.start()
    time.sleep(0.5)


def summarize(running):
    time.sleep(0.3)
    for probe in running:
        say(f"  {probe.label:8} {fmt(probe.finish())}")


def naive():
    seed()
    say("=== naive: ALTER COLUMN TYPE bigint, rolled back right after it finishes")
    running = release("amount_cents")
    start(running)
    with connect("naive", autocommit=False) as conn:
        started = time.perf_counter()
        conn.execute("ALTER TABLE orders ALTER COLUMN amount_cents TYPE bigint")
        say(f"  the ALTER held its lock for {time.perf_counter() - started:.2f} s")
        conn.rollback()
    summarize(running)


def backfill():
    with connect("backfill") as conn:
        last = conn.execute("SELECT max(id) FROM orders").fetchone()[0]
        low, batches = 0, 0
        while low < last:
            conn.execute("UPDATE orders SET amount_cents_v2 = amount_cents "
                         "WHERE id > %s AND id <= %s AND amount_cents_v2 IS NULL", (low, low + BATCH))
            low, batches = low + BATCH, batches + 1
    return f"{batches} batches"


def once(sql):
    with connect("migration") as c:
        c.execute(sql)
    return ""


def check():
    with connect("check") as c:
        bad = c.execute("SELECT count(*) FROM orders WHERE amount_cents_v2 IS DISTINCT FROM amount_cents").fetchone()[0]
    return f"{bad} rows disagree"


def run_steps(steps):
    for label, step in steps:
        started = time.perf_counter()
        note = step()
        if isinstance(note, int):
            note = "" if note == 1 else f"({note} attempts)"
        say(f"  {label:48} {time.perf_counter() - started:6.2f} s  {note}")


def playbook():
    seed()
    before = size_mb()
    with connect("setup") as c:  # a vacuum on the table would compete for the same locks and make the timings vary
        c.execute("ALTER TABLE orders SET (autovacuum_enabled = false)")
    say("=== expand and backfill while release N keeps running (autovacuum is off for this table)")
    running = release("amount_cents")
    start(running)
    run_steps([
        ("expand: ADD COLUMN amount_cents_v2 bigint",
         lambda: run_ddl(DSN, "ALTER TABLE orders ADD COLUMN amount_cents_v2 bigint")),
        ("dual write: CREATE FUNCTION", lambda: once(COPY_FUNCTION)),
        ("dual write: CREATE TRIGGER", lambda: run_ddl(DSN, COPY_TRIGGER)),
        ("backfill in batches of 10,000 rows", backfill),
        ("CREATE INDEX CONCURRENTLY on the new column",
         lambda: run_ddl(DSN, "CREATE INDEX CONCURRENTLY orders_amount_v2_idx ON orders (amount_cents_v2)")),
        ("check that old and new columns agree", check),
    ])
    summarize(running)
    say("=== contract once only the release that knows amount_cents_v2 is running")
    run_steps([
        ("DROP NOT NULL on the old column", lambda: run_ddl(DSN, "ALTER TABLE orders ALTER COLUMN amount_cents DROP NOT NULL")),
        ("DROP TRIGGER", lambda: run_ddl(DSN, "DROP TRIGGER orders_copy_amount ON orders")),
        ("DROP FUNCTION", lambda: once("DROP FUNCTION orders_copy_amount()")),
    ])
    running = release("amount_cents_v2")
    start(running)
    run_steps([("DROP COLUMN amount_cents", lambda: run_ddl(DSN, "ALTER TABLE orders DROP COLUMN amount_cents"))])
    summarize(running)
    with connect("types") as c:
        kind = c.execute("SELECT data_type FROM information_schema.columns "
                         "WHERE table_name = 'orders' AND column_name = 'amount_cents_v2'").fetchone()[0]
        c.execute("ALTER TABLE orders RESET (autovacuum_enabled)")
    say(f"  amount_cents_v2 is now {kind}; table plus indexes grew from {before:.0f} MB to {size_mb():.0f} MB")


def too_early():
    seed()  # the playbook changed the table, so this rebuilds it
    say("=== the mistake: rename the old column while release N still runs")
    running = release("amount_cents")[1:]
    start(running)
    with connect("oops") as c:
        c.execute("ALTER TABLE orders RENAME COLUMN amount_cents TO amount_cents_gone")
        time.sleep(1.0)
        c.execute("ALTER TABLE orders RENAME COLUMN amount_cents_gone TO amount_cents")
    summarize(running)


if __name__ == "__main__":
    naive()
    playbook()
    too_early()

First the naive statement, held inside a transaction that is rolled back as soon as it finishes:

=== naive: ALTER COLUMN TYPE bigint, rolled back right after it finishes
  the ALTER held its lock for 2.48 s
  reader   78 calls, median 0.3 ms, max 2477 ms, 1 slower than 100 ms, 0 errors
  writer   74 calls, median 0.5 ms, max 2481 ms, 1 slower than 100 ms, 0 errors
  updater  74 calls, median 0.6 ms, max 2475 ms, 1 slower than 100 ms, 0 errors

The rewrite held its lock for 2.48 seconds, and in that single stall all three probes waited 2.48 seconds. Now the step-by-step version:

=== expand and backfill while release N keeps running (autovacuum is off for this table)
  expand: ADD COLUMN amount_cents_v2 bigint          0.03 s  
  dual write: CREATE FUNCTION                        0.02 s  
  dual write: CREATE TRIGGER                         0.02 s  
  backfill in batches of 10,000 rows                14.59 s  201 batches
  CREATE INDEX CONCURRENTLY on the new column        1.28 s  
  check that old and new columns agree               0.13 s  0 rows disagree
  reader   1554 calls, median 0.4 ms, max 141 ms, 1 slower than 100 ms, 0 errors
  writer   1409 calls, median 1.0 ms, max 213 ms, 2 slower than 100 ms, 0 errors
  updater  1395 calls, median 1.0 ms, max 214 ms, 2 slower than 100 ms, 0 errors
=== contract once only the release that knows amount_cents_v2 is running
  DROP NOT NULL on the old column                    0.09 s  
  DROP TRIGGER                                       0.05 s  
  DROP FUNCTION                                      0.03 s  
  DROP COLUMN amount_cents                           0.03 s  
  reader   72 calls, median 0.7 ms, max 1 ms, 0 slower than 100 ms, 0 errors
  writer   72 calls, median 0.8 ms, max 5 ms, 0 slower than 100 ms, 0 errors
  updater  71 calls, median 0.8 ms, max 2 ms, 0 slower than 100 ms, 0 errors
  amount_cents_v2 is now bigint; table plus indexes grew from 186 MB to 371 MB

The short statements took 0.02 to 0.03 seconds, the concurrent index build 1.28 seconds, and the backfill 14.6 seconds across 201 batches, and no probe logged an error. The worst calls in the whole expand phase were 141, 213 and 214 ms, from 1 or 2 calls per probe slower than 100 ms, against 2.48 seconds for the one-statement version. I only tried a batch of 10,000 rows. The stall comes from rows locked by a batch that the updater probe happened to need, so a smaller batch should shorten it at the price of more transactions; I did not measure that. The contract phase caused no stall: no probe saw anything slower than 5 ms.

The price is disk. The table and its indexes grew from 186 MB to 371 MB, because every updated row is a new row version. Dropping the old column does not shrink the table. The documentation says dropping a column “will not immediately reduce the on-disk size of your table”, and that the space “will be reclaimed over time as existing rows are updated.” Plan for the extra space before you start.

The last output block shows the mistake too_early() makes on purpose:

=== the mistake: rename the old column while release N still runs
  writer   169 calls, median 0.5 ms, max 3 ms, 0 slower than 100 ms, 93 errors
  updater  168 calls, median 0.5 ms, max 2 ms, 0 slower than 100 ms, 93 errors

For one second of the old name being gone, the writer and the updater, which still use amount_cents, failed 93 times each. The reader does not use that column and was fine. The rename itself took a millisecond; the cost came from code that still needed the old name. Strong Migrations says it in one line: “Renaming a column that’s in use will cause errors in your application.” The same goes for dropping a column. Contract only after the release that used the old column is gone.

Step 7: Write a lint gate that refuses unsafe migrations

You now know enough to write rules, and every rule in gate.py comes from something you measured or read in the steps above. The gate splits a migration file into statements, normalizes each one, and applies these checks.

M001 flags adding a column whose default might be volatile, or that is serial, identity or stored generated (rewrites). M002 flags a column type change (rewrite). M003 flags SET NOT NULL unless the same file already validated a CHECK (col IS NOT NULL) for that column. M004 flags CREATE INDEX without CONCURRENTLY. M005 flags a check, foreign key or not-null constraint added without NOT VALID. M006 flags a unique or primary key constraint that builds its own index instead of using one built concurrently. M009 flags CREATE INDEX CONCURRENTLY inside a transaction. Those are errors: the gate exits with code 1.

Three more rules are warnings because they are not lock problems but need a human: M007 for renames, M008 for dropped columns (both break running code), and M011 for an UPDATE or DELETE without a WHERE, which changes every row in one transaction and should be batched. Tables created in the same file are exempt from the lock rules because a new table is empty and nobody is using it yet.

Two design choices are worth explaining. The default check is conservative: only literals and a short list of stable built-ins such as now() pass, because the gate cannot know whether a custom function is volatile, and a false alarm costs one line in the allow list while a missed rewrite costs an outage. And the gate remembers what earlier statements in the file proved, which is how it accepts the safe SET NOT NULL only after a validated check for the same table and column.

# gate.py
"""gate.py: a lint gate for PostgreSQL migrations. Each rule is backed by a measurement from steps 1 to 5.

Usage: python gate.py migration.sql [RULE ...]     (rules named after the file are allowed)
Exit code 0 = no errors, 1 = at least one error, 2 = unreadable input."""
import re
import sys
from dataclasses import dataclass
from pathlib import Path

IDENT = r'(?:"[^"]+"|[\w.]+)'
STABLE = {"now", "current_timestamp", "current_date", "current_time", "localtimestamp", "localtime",
          "transaction_timestamp", "statement_timestamp"}
CLAUSE_STARTS = ("not null", "null", "check", "references", "unique", "primary key", "constraint",
                 "generated", "collate")
CONSTRAINT_ADD = re.compile(r"add (?:constraint (" + IDENT + r") )?"
                            r"(check|foreign key|unique|primary key|exclude|not null)\b(.*)$")


@dataclass
class Finding:
    rule: str
    severity: str  # "error" fails the gate, "warn" asks for a human look
    index: int     # statement number in the file, starting at 1
    message: str
    fix: str


def split_statements(sql):
    """Split on semicolons that sit outside quotes, comments and dollar-quoted bodies."""
    out, buf, i, n = [], [], 0, len(sql)
    while i < n:
        c = sql[i]
        if c in "'\"":
            j = i + 1
            while j < n:
                if sql[j] == c:
                    if j + 1 < n and sql[j + 1] == c:
                        j += 2
                        continue
                    break
                j += 1
            buf.append(sql[i:j + 1])
            i = j + 1
        elif sql.startswith("--", i):
            j = sql.find("\n", i)
            i = n if j < 0 else j
        elif sql.startswith("/*", i):
            depth, j = 1, i + 2
            while j < n and depth:
                if sql.startswith("/*", j):
                    depth, j = depth + 1, j + 2
                elif sql.startswith("*/", j):
                    depth, j = depth - 1, j + 2
                else:
                    j += 1
            buf.append(" ")
            i = j
        elif c == "$" and re.match(r"\$(?:[A-Za-z_]\w*)?\$", sql[i:]):
            tag = re.match(r"\$(?:[A-Za-z_]\w*)?\$", sql[i:]).group(0)
            j = sql.find(tag, i + len(tag))
            j = n if j < 0 else j + len(tag)
            buf.append(sql[i:j])
            i = j
        elif c == ";":
            out.append("".join(buf))
            buf, i = [], i + 1
        else:
            buf.append(c)
            i += 1
    out.append("".join(buf))
    return [s.strip() for s in out if s.strip()]


def normalize(statement):
    """Hide string contents, collapse white space, lower-case: rules then match plain keywords."""
    statement = re.sub(r"'(?:[^']|'')*'", "''", statement)
    return re.sub(r"\s+", " ", statement.strip()).lower().rstrip(";")


def top_level_split(text, sep=","):
    parts, depth, start = [], 0, 0
    for i, ch in enumerate(text):
        if ch == "(":
            depth += 1
        elif ch == ")":
            depth -= 1
        elif ch == sep and depth == 0:
            parts.append(text[start:i].strip())
            start = i + 1
    parts.append(text[start:].strip())
    return [p for p in parts if p]


def key(name):
    return name.replace('"', "").removeprefix("public.")


def default_expr(action):
    found = re.search(r"\bdefault\b", action)
    if not found:
        return None
    rest, depth = action[found.end():].strip(), 0
    for i, ch in enumerate(rest):
        if ch == "(":
            depth += 1
        elif ch == ")":
            depth -= 1
        elif depth == 0 and ch == " " and rest[i + 1:].startswith(CLAUSE_STARTS):
            return rest[:i]
    return rest


def default_is_safe(expr):
    """True for literals and the stable built-ins; anything else might be volatile."""
    expr = re.sub(r"::[a-z_ ]+(\(\d+(,\d+)?\))?(\[\])?", "", expr).strip("() ")
    if re.fullmatch(r"-?\d+(\.\d+)?|''|true|false|null", expr):
        return True
    named = re.fullmatch(r"([a-z_]+)(\(\))?", expr)
    return bool(named and named.group(1) in STABLE)


def check_column_add(action):
    out = []
    if re.search(r"\b(small|big)?serial\b", action):
        out.append(("M001", "error", "a serial column gets a volatile nextval() default",
                    "add a plain bigint column, backfill in batches, then attach the sequence default"))
    if re.search(r"generated (always|by default) as identity", action) or \
            re.search(r"generated always as \(.*\) stored", action):
        out.append(("M001", "error", "an identity or stored generated column rewrites the whole table",
                    "add a plain column and fill it with batched updates"))
    expr = default_expr(action)
    if expr is not None and not default_is_safe(expr):
        out.append(("M001", "error", f"DEFAULT {expr} is volatile or not provably stable, so the table is rewritten",
                    "add the column without a default, set the value in batches, then SET DEFAULT"))
    return out


def lint(sql, allow=(), in_transaction=False):
    findings = []
    state = {"created": set(), "pending": {}, "proved": set(), "in_block": False}
    for index, raw in enumerate(split_statements(sql), 1):
        statement = normalize(raw)
        found = []
        if re.match(r"(begin|start transaction)\b", statement):
            state["in_block"] = True
        elif re.match(r"(commit|end|rollback|abort)\b", statement):
            state["in_block"] = False
        created = re.match(r"create table (?:if not exists )?(" + IDENT + r")", statement)
        if created:
            state["created"].add(key(created.group(1)))
        make_index = re.match(r"create (?:unique )?index (concurrently )?(?:if not exists )?"
                              r"(?:(?!on )" + IDENT + r" )?on (?:only )?(" + IDENT + r")", statement)
        if make_index:
            table = key(make_index.group(2))
            if make_index.group(1) and (state["in_block"] or in_transaction):
                found.append(("M009", "error", "CREATE INDEX CONCURRENTLY cannot run inside a transaction block",
                              "run it outside BEGIN and COMMIT, in autocommit mode"))
            elif not make_index.group(1) and table not in state["created"]:
                found.append(("M004", "error", f"CREATE INDEX on {table} blocks every write until it finishes",
                              "use CREATE INDEX CONCURRENTLY"))
        alter = re.match(r"alter table (?:if exists )?(?:only )?(" + IDENT + r") (.*)$", statement)
        if alter:
            table = key(alter.group(1))
            fresh = table in state["created"]
            for action in top_level_split(alter.group(2)):
                found.extend(check_action(table, action, fresh, state))
        if re.match(r"(update " + IDENT + r" set|delete from " + IDENT + r")\b", statement) \
                and " where " not in statement:
            found.append(("M011", "warn", "one statement changes every row in one transaction",
                          "run it in batches by primary-key range, one transaction per batch"))
        findings.extend(Finding(rule, severity, index, message, fix) for rule, severity, message, fix in found)
    return [f for f in findings if f.rule not in allow]


def check_action(table, action, fresh, state):
    found = []
    constraint = CONSTRAINT_ADD.match(action)
    column = re.match(r"add (?:column )?(?:if not exists )?(" + IDENT + r") (.+)$", action)
    if constraint:
        name, kind, tail = constraint.groups()
        not_valid = action.endswith("not valid")
        if kind in ("unique", "primary key", "exclude") and "using index" not in tail and not fresh:
            found.append(("M006", "error", f"ADD {kind.upper()} builds an index while holding ACCESS EXCLUSIVE",
                          "CREATE UNIQUE INDEX CONCURRENTLY first, then ADD CONSTRAINT ... USING INDEX"))
        if kind in ("check", "foreign key", "not null") and not not_valid and not fresh:
            found.append(("M005", "error", f"ADD {kind.upper()} scans the table while holding a blocking lock",
                          "add it NOT VALID, then VALIDATE CONSTRAINT in a later statement"))
        if name:
            proof = re.match(r"\s*\(\s*\(?\s*(" + IDENT + r")\s*\)?\s+is not null\s*\)", tail)
            if kind == "check" and proof:
                state["pending"][(table, key(name))] = key(proof.group(1))
            elif kind == "not null" and re.match(r"\s*(" + IDENT + r")", tail):
                state["pending"][(table, key(name))] = key(re.match(r"\s*(" + IDENT + r")", tail).group(1))
    elif column:
        found.extend([] if fresh else check_column_add(action))
    elif re.match(r"alter (?:column )?" + IDENT + r" (?:set data )?type\b", action):
        found.append(("M002", "error", "changing a column type rewrites the table and its indexes",
                      "expand and contract: new column, dual write, batched backfill, switch, drop"))
    elif re.match(r"alter (?:column )?" + IDENT + r" set not null$", action):
        col = key(re.match(r"alter (?:column )?(" + IDENT + r")", action).group(1))
        if not fresh and (table, col) not in state["proved"]:
            found.append(("M003", "error", f"SET NOT NULL on {col} scans the table while holding ACCESS EXCLUSIVE",
                          "add CHECK (col IS NOT NULL) NOT VALID, VALIDATE it, then SET NOT NULL"))
    elif re.match(r"validate constraint " + IDENT + r"$", action):
        name = key(action.split()[-1])
        if (table, name) in state["pending"]:
            state["proved"].add((table, state["pending"][(table, name)]))
    elif re.match(r"rename (?!constraint\b)(?:column )?" + IDENT + r" to ", action) or action.startswith("rename to "):
        found.append(("M007", "warn", "a rename breaks every running copy of the application that uses the old name",
                      "add the new name, copy the data, move the code, drop the old name later"))
    elif re.match(r"drop (?!constraint\b)(?:column )?(?:if exists )?" + IDENT, action):
        found.append(("M008", "warn", "dropping a column breaks any code that still reads or writes it",
                      "ship the code change first, drop the column in a later release"))
    return found


def main(argv):
    if len(argv) < 2:
        print(__doc__)
        return 2
    try:
        text = Path(argv[1]).read_text(encoding="utf-8")
    except OSError as exc:
        print(f"cannot read {argv[1]}: {exc}")
        return 2
    findings = lint(text, allow=set(argv[2:]))
    for f in findings:
        print(f"{argv[1]}: statement {f.index}: {f.severity.upper()} {f.rule}: {f.message}\n    fix: {f.fix}")
    errors = [f for f in findings if f.severity == "error"]
    print(f"{len(errors)} errors, {len(findings) - len(errors)} warnings")
    return 1 if errors else 0


if __name__ == "__main__":
    sys.exit(main(sys.argv))

A linter is code, so test it. The test file covers the statement splitter (semicolons inside strings, comments, nested block comments and dollar-quoted function bodies must not split), each unsafe statement, each safe statement, the warnings, multi-action statements, the order rules, the allow list and the command-line exit codes.

# test_gate.py
import subprocess
import sys
from pathlib import Path

import pytest

from gate import lint, split_statements

HERE = Path(__file__).parent


def errors(sql, **kw):
    return [f.rule for f in lint(sql, **kw) if f.severity == "error"]


def warnings(sql, **kw):
    return [f.rule for f in lint(sql, **kw) if f.severity == "warn"]


@pytest.mark.parametrize("sql, expected", [
    ("SELECT 1; SELECT 2;", ["SELECT 1", "SELECT 2"]),
    ("SELECT 'a;b'; SELECT 2", ["SELECT 'a;b'", "SELECT 2"]),
    ("SELECT 'it''s; fine'; SELECT 2", ["SELECT 'it''s; fine'", "SELECT 2"]),
    ('SELECT "a;b" FROM t; SELECT 2', ['SELECT "a;b" FROM t', "SELECT 2"]),
    ("-- one; two\nSELECT 1", ["SELECT 1"]),
    ("/* a; /* b; */ c; */ SELECT 1", ["SELECT 1"]),
    ("CREATE FUNCTION f() RETURNS int AS $$ BEGIN RETURN 1; END $$ LANGUAGE plpgsql; SELECT 2",
     ["CREATE FUNCTION f() RETURNS int AS $$ BEGIN RETURN 1; END $$ LANGUAGE plpgsql", "SELECT 2"]),
    ("SELECT $tag$ a; b $tag$; SELECT 2", ["SELECT $tag$ a; b $tag$", "SELECT 2"]),
    ("", []),
])
def test_split_statements(sql, expected):
    assert split_statements(sql) == expected


UNSAFE = [
    ("ALTER TABLE orders ADD COLUMN touched_at timestamptz NOT NULL DEFAULT clock_timestamp()", "M001"),
    ("ALTER TABLE orders ADD COLUMN token uuid DEFAULT gen_random_uuid()", "M001"),
    ("ALTER TABLE orders ADD COLUMN seq bigserial", "M001"),
    ("ALTER TABLE orders ADD COLUMN total bigint GENERATED ALWAYS AS (amount_cents * 2) STORED", "M001"),
    ("ALTER TABLE orders ADD COLUMN n bigint GENERATED ALWAYS AS IDENTITY", "M001"),
    ("ALTER TABLE orders ALTER COLUMN amount_cents TYPE bigint", "M002"),
    ("ALTER TABLE orders ALTER COLUMN amount_cents SET DATA TYPE bigint USING amount_cents::bigint", "M002"),
    ("ALTER TABLE orders ALTER COLUMN status SET NOT NULL", "M003"),
    ("CREATE INDEX orders_status_idx ON orders (status)", "M004"),
    ("CREATE UNIQUE INDEX ON orders (id, amount_cents)", "M004"),
    ("ALTER TABLE orders ADD CONSTRAINT fk FOREIGN KEY (customer_id) REFERENCES customers (id)", "M005"),
    ("ALTER TABLE orders ADD CONSTRAINT ck CHECK (amount_cents >= 0)", "M005"),
    ("ALTER TABLE orders ADD CONSTRAINT nn NOT NULL status", "M005"),
    ("ALTER TABLE orders ADD CONSTRAINT uq UNIQUE (id, amount_cents)", "M006"),
    ("ALTER TABLE orders ADD PRIMARY KEY (id)", "M006"),
    ("ALTER TABLE public.orders ADD COLUMN token uuid DEFAULT uuid_generate_v4()", "M001"),
    ('ALTER TABLE "orders" ALTER COLUMN "status" SET NOT NULL', "M003"),
    ("ALTER TABLE IF EXISTS ONLY orders ADD COLUMN n int DEFAULT random()", "M001"),
]


@pytest.mark.parametrize("sql, rule", UNSAFE)
def test_unsafe_statements_are_refused(sql, rule):
    assert errors(sql) == [rule]


SAFE = [
    "ALTER TABLE orders ADD COLUMN note text",
    "ALTER TABLE orders ADD COLUMN is_test boolean NOT NULL DEFAULT false",
    "ALTER TABLE orders ADD COLUMN created timestamptz DEFAULT now()",
    "ALTER TABLE orders ADD COLUMN stamp timestamptz NOT NULL DEFAULT current_timestamp",
    "ALTER TABLE orders ADD COLUMN label text DEFAULT 'none'",
    "ALTER TABLE orders ADD COLUMN price numeric(10,2) NOT NULL DEFAULT 0.00",
    "ALTER TABLE orders ADD COLUMN kind text DEFAULT 'a'::text NOT NULL",
    "CREATE INDEX CONCURRENTLY orders_status_idx ON orders (status)",
    "ALTER TABLE orders ADD CONSTRAINT fk FOREIGN KEY (customer_id) REFERENCES customers (id) NOT VALID",
    "ALTER TABLE orders ADD CONSTRAINT ck CHECK (amount_cents >= 0) NOT VALID",
    "ALTER TABLE orders VALIDATE CONSTRAINT ck",
    "ALTER TABLE orders ADD CONSTRAINT uq UNIQUE USING INDEX orders_uq_idx",
    "ALTER TABLE orders ADD CONSTRAINT nn NOT NULL status NOT VALID",
    "ALTER TABLE orders ALTER COLUMN status SET DEFAULT 'new'",
    "ALTER TABLE orders DROP CONSTRAINT ck",
    "ALTER TABLE orders RENAME CONSTRAINT a TO b",
    "UPDATE orders SET status = 'x' WHERE id < 10",
    "SELECT 1",
]


@pytest.mark.parametrize("sql", SAFE)
def test_safe_statements_pass(sql):
    assert errors(sql) == []


@pytest.mark.parametrize("sql, rule", [
    ("ALTER TABLE orders RENAME COLUMN status TO state", "M007"),
    ("ALTER TABLE orders RENAME status TO state", "M007"),
    ("ALTER TABLE orders RENAME TO purchases", "M007"),
    ("ALTER TABLE orders DROP COLUMN created_at", "M008"),
    ("ALTER TABLE orders DROP created_at", "M008"),
    ("UPDATE orders SET status = 'x'", "M011"),
    ("DELETE FROM orders", "M011"),
])
def test_compatibility_and_batch_warnings(sql, rule):
    assert warnings(sql) == [rule]
    assert errors(sql) == []


def test_every_action_in_a_multi_action_statement_is_checked():
    sql = "ALTER TABLE orders ADD COLUMN a text, ADD COLUMN b int DEFAULT random(), ALTER COLUMN c TYPE bigint"
    assert errors(sql) == ["M001", "M002"]


def test_text_inside_a_string_is_not_sql():
    assert errors("COMMENT ON TABLE orders IS 'ALTER TABLE orders ALTER COLUMN a TYPE bigint'") == []


CHECK = "ALTER TABLE orders ADD CONSTRAINT ck CHECK (status IS NOT NULL) NOT VALID;"
VALIDATE = "ALTER TABLE orders VALIDATE CONSTRAINT ck;"
SET_NN = "ALTER TABLE orders ALTER COLUMN status SET NOT NULL;"


def test_set_not_null_is_fine_after_a_validated_check():
    assert errors(CHECK + VALIDATE + SET_NN) == []


def test_set_not_null_needs_the_validate_step():
    assert errors(CHECK + SET_NN) == ["M003"]


def test_validating_a_different_constraint_proves_nothing():
    other = "ALTER TABLE orders ADD CONSTRAINT other CHECK (amount_cents > 0) NOT VALID; " \
            "ALTER TABLE orders VALIDATE CONSTRAINT other;"
    assert errors(CHECK + other + SET_NN) == ["M003"]


def test_the_proof_belongs_to_one_table():
    assert errors(CHECK + VALIDATE + "ALTER TABLE customers ALTER COLUMN status SET NOT NULL;") == ["M003"]


def test_the_proof_belongs_to_one_column():
    assert errors(CHECK + VALIDATE + "ALTER TABLE orders ALTER COLUMN amount_cents SET NOT NULL;") == ["M003"]


def test_pg18_not_null_constraint_counts_as_proof_after_validate():
    sql = ("ALTER TABLE orders ADD CONSTRAINT nn NOT NULL status NOT VALID; "
           "ALTER TABLE orders VALIDATE CONSTRAINT nn; ALTER TABLE orders ALTER COLUMN status SET NOT NULL")
    assert errors(sql) == []


def test_tables_created_in_the_same_file_are_exempt():
    sql = ("CREATE TABLE audit (id bigint, note text); CREATE INDEX audit_idx ON audit (note); "
           "ALTER TABLE audit ALTER COLUMN note SET NOT NULL; "
           "ALTER TABLE audit ADD COLUMN token uuid DEFAULT gen_random_uuid()")
    assert errors(sql) == []


def test_concurrent_index_inside_begin_commit_is_refused():
    sql = "BEGIN; CREATE INDEX CONCURRENTLY i ON orders (status); COMMIT;"
    assert errors(sql) == ["M009"]
    assert errors("CREATE INDEX CONCURRENTLY i ON orders (status); BEGIN; COMMIT;") == []


def test_concurrent_index_is_refused_when_the_runner_wraps_a_transaction():
    assert errors("CREATE INDEX CONCURRENTLY i ON orders (status)", in_transaction=True) == ["M009"]


def test_allow_list_silences_a_rule():
    assert errors("ALTER TABLE orders ALTER COLUMN amount_cents TYPE bigint", allow={"M002"}) == []


def test_findings_carry_the_statement_number():
    sql = "SELECT 1; ALTER TABLE orders ALTER COLUMN amount_cents TYPE bigint; SELECT 2"
    assert [(f.rule, f.index) for f in lint(sql)] == [("M002", 2)]


def test_cli_exit_codes(tmp_path):
    bad = tmp_path / "bad.sql"
    bad.write_text("CREATE INDEX i ON orders (status);\n", encoding="utf-8")
    good = tmp_path / "good.sql"
    good.write_text("CREATE INDEX CONCURRENTLY i ON orders (status);\n", encoding="utf-8")
    run = lambda *args: subprocess.run([sys.executable, str(HERE / "gate.py"), *args],  # noqa: E731
                                       capture_output=True, text=True).returncode
    assert run(str(bad)) == 1
    assert run(str(good)) == 0
    assert run(str(bad), "M004") == 0
    assert run(str(tmp_path / "missing.sql")) == 2
..................................................................                                                                                                 [100%]
66 passed in 0.33s

Now run the gate on two migration files that express the same intent. The first is written the obvious way:

-- 0007_naive.sql: the obvious way to write the change
ALTER TABLE orders ADD COLUMN touched_at timestamptz NOT NULL DEFAULT clock_timestamp();
ALTER TABLE orders ALTER COLUMN amount_cents TYPE bigint;
ALTER TABLE orders ALTER COLUMN status SET NOT NULL;
CREATE INDEX orders_status_idx ON orders (status);
ALTER TABLE orders ADD CONSTRAINT orders_customer_fk FOREIGN KEY (customer_id) REFERENCES customers (id);
ALTER TABLE orders ADD CONSTRAINT orders_amount_uq UNIQUE (id, amount_cents);
ALTER TABLE orders RENAME COLUMN status TO state;
UPDATE orders SET touched_at = created_at;

The second is the rewrite with the safe patterns from steps 2 to 5 (the column type change is left out because it needs the multi-release plan from step 6):

-- 0007_safe.sql: the same intent, written so that no statement holds a blocking lock for long
ALTER TABLE orders ADD COLUMN touched_at timestamptz;
CREATE INDEX CONCURRENTLY orders_status_idx ON orders (status);
ALTER TABLE orders ADD CONSTRAINT orders_status_ck CHECK (status IS NOT NULL) NOT VALID;
ALTER TABLE orders VALIDATE CONSTRAINT orders_status_ck;
ALTER TABLE orders ALTER COLUMN status SET NOT NULL;
ALTER TABLE orders ADD CONSTRAINT orders_customer_fk FOREIGN KEY (customer_id) REFERENCES customers (id) NOT VALID;
ALTER TABLE orders VALIDATE CONSTRAINT orders_customer_fk;
CREATE UNIQUE INDEX CONCURRENTLY orders_amount_uq ON orders (id, amount_cents);
ALTER TABLE orders ADD CONSTRAINT orders_amount_uq UNIQUE USING INDEX orders_amount_uq;

This script runs the gate on both files, and then does the check that matters most: it compares the gate’s verdict with the measurements you saved in step 2. A statement counts as measured-unsafe when its lock blocks writes, it took at least 20 ms, and it was at least twice as slow on ten times the rows.

# step06_gate.py
"""step06_gate.py: run the gate on two migration files, then compare its verdicts with the measurements of step 1."""
import json

from gate import lint, main as gate_main


def show(path):
    print(f"$ python gate.py {path}")
    code = gate_main(["gate.py", path])
    print(f"exit code {code}\n")


def agreement():
    with open("out/matrix.json", encoding="utf-8") as fh:
        rows = json.load(fh)
    agree = 0
    print(f"{'statement':36} {'measured':10} {'gate':10} verdict")
    for row in rows:
        statements = row["setup"] + [row["sql"]]
        gate_errors = [f for f in lint(";\n".join(statements))
                       if f.severity == "error" and f.index == len(statements)]
        measured_unsafe = row["blocks_writes"] and row["ms_big"] >= 20 and row["ratio"] >= 2
        same = bool(gate_errors) == measured_unsafe
        agree += same
        print(f"{row['label']:36} {'unsafe' if measured_unsafe else 'ok':10} "
              f"{gate_errors[0].rule if gate_errors else 'ok':10} {'agree' if same else 'DISAGREE'}")
    print(f"the gate agrees with the measurement on {agree} of {len(rows)} statements")


if __name__ == "__main__":
    show("0007_naive.sql")
    show("0007_safe.sql")
    agreement()
$ python gate.py 0007_naive.sql
0007_naive.sql: statement 1: ERROR M001: DEFAULT clock_timestamp() is volatile or not provably stable, so the table is rewritten
    fix: add the column without a default, set the value in batches, then SET DEFAULT
0007_naive.sql: statement 2: ERROR M002: changing a column type rewrites the table and its indexes
    fix: expand and contract: new column, dual write, batched backfill, switch, drop
0007_naive.sql: statement 3: ERROR M003: SET NOT NULL on status scans the table while holding ACCESS EXCLUSIVE
    fix: add CHECK (col IS NOT NULL) NOT VALID, VALIDATE it, then SET NOT NULL
0007_naive.sql: statement 4: ERROR M004: CREATE INDEX on orders blocks every write until it finishes
    fix: use CREATE INDEX CONCURRENTLY
0007_naive.sql: statement 5: ERROR M005: ADD FOREIGN KEY scans the table while holding a blocking lock
    fix: add it NOT VALID, then VALIDATE CONSTRAINT in a later statement
0007_naive.sql: statement 6: ERROR M006: ADD UNIQUE builds an index while holding ACCESS EXCLUSIVE
    fix: CREATE UNIQUE INDEX CONCURRENTLY first, then ADD CONSTRAINT ... USING INDEX
0007_naive.sql: statement 7: WARN M007: a rename breaks every running copy of the application that uses the old name
    fix: add the new name, copy the data, move the code, drop the old name later
0007_naive.sql: statement 8: WARN M011: one statement changes every row in one transaction
    fix: run it in batches by primary-key range, one transaction per batch
6 errors, 2 warnings
exit code 1

$ python gate.py 0007_safe.sql
0 errors, 0 warnings
exit code 0
statement                            measured   gate       verdict
ADD COLUMN, no default               ok         ok         agree
ADD COLUMN, constant default         ok         ok         agree
ADD COLUMN, volatile default         unsafe     M001       agree
ALTER COLUMN TYPE bigint             unsafe     M002       agree
SET NOT NULL, plain                  unsafe     M003       agree
ADD CHECK ... NOT VALID              ok         ok         agree
VALIDATE the CHECK                   ok         ok         agree
SET NOT NULL after a valid CHECK     ok         ok         agree
ADD NOT NULL ... NOT VALID (PG 18)   ok         ok         agree
VALIDATE the NOT NULL (PG 18)        ok         ok         agree
CREATE INDEX                         unsafe     M004       agree
ADD FOREIGN KEY                      unsafe     M005       agree
ADD FOREIGN KEY ... NOT VALID        ok         ok         agree
VALIDATE the FOREIGN KEY             ok         ok         agree
ADD UNIQUE (id, amount_cents)        unsafe     M006       agree
RENAME COLUMN                        ok         ok         agree
DROP COLUMN                          ok         ok         agree
the gate agrees with the measurement on 17 of 17 statements

The obvious file fails with six errors and two warnings, each with a fix; the safe file passes. The gate agrees with the measurement on all 17 statements, which is the point of building rules from measurements: the rules and the database tell the same story. Treat that as a check on the rules, not a proof. The gate reads text. It cannot see your data, your traffic, a custom function’s volatility, partitioned tables or triggers, so it complements a rehearsal on a production-sized copy and does not replace one.

Step 8: Apply the approved migration while traffic runs

The last script reads 0007_safe.sql, refuses to continue if the gate finds an error, and applies the nine statements one by one through run_ddl while a reader and a writer probe run. It also reports which statement was running when the slowest call started, so a stall can be traced to a statement instead of guessed at.

# step07_apply.py
"""step07_apply.py: gate the safe migration, then apply it statement by statement while traffic runs."""
import random
import time
from pathlib import Path

from gate import lint, split_statements
from lab import DSN, Probe, connect, fmt, say, seed
from migrate import run_ddl


def main():
    text = Path("0007_safe.sql").read_text(encoding="utf-8")
    refused = [f for f in lint(text) if f.severity == "error"]
    if refused:
        raise SystemExit(f"the gate refused the migration: {[f.rule for f in refused]}")
    say("the gate accepted 0007_safe.sql")
    seed()
    rng = random.Random(5)
    reader = Probe("reader", "SELECT id FROM orders WHERE id = %s", lambda n: (rng.randint(1, 2_000_000),))
    writer = Probe("writer", "INSERT INTO orders (customer_id, status, amount_cents) VALUES (1, 'probe', 100)")
    reader.start()
    writer.start()
    time.sleep(0.5)
    windows = []
    for number, statement in enumerate(split_statements(text), 1):
        started = time.perf_counter()
        attempts = run_ddl(DSN, statement, rng=random.Random(number))
        ended = time.perf_counter()
        windows.append((number, started - reader.t0, ended - reader.t0))
        flat = " ".join(statement.split())
        say(f"  {number}. {flat[:66]:66} {ended - started:5.2f} s  attempts={attempts}")
    time.sleep(0.3)
    for label, probe in (("reader", reader), ("writer", writer)):
        summary = probe.finish()
        during = [n for n, begin, end in windows if begin <= summary["worst_at"] <= end]
        say(f"  {label}: {fmt(summary)}")
        say(f"    slowest call started at t={summary['worst_at']:.2f} s, during statement "
            f"{during[0] if during else 'none (between statements)'}")
    with connect("final") as c:
        say("  constraints: " + str(c.execute("SELECT conname, contype, convalidated FROM pg_constraint "
                                              "WHERE conrelid = 'orders'::regclass AND contype IN ('c', 'f', 'u', 'p') "
                                              "ORDER BY conname").fetchall()))
        say("  status NOT NULL: " + str(c.execute("SELECT attnotnull FROM pg_attribute "
                                                   "WHERE attrelid = 'orders'::regclass AND attname = 'status'").fetchone()[0]))
        say("  invalid indexes: " + str(c.execute("SELECT count(*) FROM pg_index WHERE NOT indisvalid").fetchone()[0]))


if __name__ == "__main__":
    main()
the gate accepted 0007_safe.sql
  1. ALTER TABLE orders ADD COLUMN touched_at timestamptz                0.03 s  attempts=1
  2. CREATE INDEX CONCURRENTLY orders_status_idx ON orders (status)      0.94 s  attempts=1
  3. ALTER TABLE orders ADD CONSTRAINT orders_status_ck CHECK (status I  0.14 s  attempts=1
  4. ALTER TABLE orders VALIDATE CONSTRAINT orders_status_ck             0.09 s  attempts=1
  5. ALTER TABLE orders ALTER COLUMN status SET NOT NULL                 0.02 s  attempts=1
  6. ALTER TABLE orders ADD CONSTRAINT orders_customer_fk FOREIGN KEY (  0.02 s  attempts=1
  7. ALTER TABLE orders VALIDATE CONSTRAINT orders_customer_fk           0.13 s  attempts=1
  8. CREATE UNIQUE INDEX CONCURRENTLY orders_amount_uq ON orders (id, a  0.64 s  attempts=1
  9. ALTER TABLE orders ADD CONSTRAINT orders_amount_uq UNIQUE USING IN  0.02 s  attempts=1
  reader: 262 calls, median 0.3 ms, max 1 ms, 0 slower than 100 ms, 0 errors
    slowest call started at t=0.02 s, during statement none (between statements)
  writer: 254 calls, median 0.5 ms, max 6 ms, 0 slower than 100 ms, 0 errors
    slowest call started at t=2.28 s, during statement 8
  constraints: [('orders_amount_uq', 'u', True), ('orders_customer_fk', 'f', True), ('orders_pkey', 'p', True), ('orders_status_ck', 'c', True)]
  status NOT NULL: True
  invalid indexes: 0

Every statement ran on its first attempt. The two index builds took 0.94 and 0.64 seconds, the validations took 0.09 and 0.13 seconds, and everything else took 0.14 seconds or less. The reader’s worst call was 1 ms and the writer’s was 6 ms across the whole migration. The final listing shows what you wanted: every constraint validated, status not null, and no invalid index left behind.

Common mistakes and how to spot them

Trusting a fast test database. On the 200,000-row table the 81 ms scan took 9.5 ms. A migration that looks instant on a laptop can hold a lock for seconds in production. Rehearse on a copy of the real size.

Looking at the statement instead of the queue. If a deploy hangs, ask who holds the lock, not what the migration does. The pg_stat_activity query in step 3 prints it: the migration waiting on Lock:relation and, from pg_blocking_pids(), the session it is waiting for. The usual culprit is a transaction that is idle and open.

Setting a timeout and forgetting the retries. A lock_timeout without a retry loop turns a stall into a failed deploy. A retry loop without jitter and a cap hammers the table in lock-step. run_ddl does both, and gives up after ten attempts instead of retrying forever.

Leaving an invalid index. After any failed CREATE INDEX CONCURRENTLY, query pg_index.indisvalid and drop the leftover with DROP INDEX CONCURRENTLY.

Contracting too early. The two 93-error counts in step 6 are what happens when the old release is still running. Rename and drop last.

Autovacuum in the way. My first complete run of the step 6 script failed at the index build: ten attempts, every one ending in a lock timeout at 200 ms. Index builds with CONCURRENTLY take SHARE UPDATE EXCLUSIVE, the lock a vacuum holds, and a backfill of 2,000,000 rows leaves a lot to vacuum. The table’s statistics showed an autovacuum of orders that finished within half a minute after the failure, so a vacuum was probably running during the attempts, but I did not capture the worker in pg_stat_activity at the time, so treat that as likely, not proven. The documentation says “If a process attempts to acquire a lock that conflicts with the SHARE UPDATE EXCLUSIVE lock held by autovacuum, lock acquisition will interrupt the autovacuum”, and it defines deadlock_timeout (1 second by default) as “the amount of time to wait on a lock before checking to see if there is a deadlock condition.” My reading, which I did not test, is that a request that gives up after 200 ms never waits long enough for that to happen. For the lab I turned autovacuum off for the table while the demo runs, which is why the output says so. In production, look for a vacuum of the table first, and decide whether the statement may wait longer.

Check that everything works end to end

Run python run_all.py. It should print rc=0 for all eight steps, and you should be able to confirm these results in the saved outputs: the lock table shows the VALIDATE rows with SHARE UPDATE EXCLUSIVE and the PG 18 NOT VALID row with ACCESS EXCLUSIVE at about a millisecond; experiment 1 shows the reader and writer waiting about 2.7 seconds while experiment 2 stays near 0.2 seconds; the expand and contract phases of the type-change playbook have zero probe errors, while the deliberate mistake after them logs dozens; pytest says 66 passed; the gate agrees with the measurement on all rows; and the final step ends with no invalid indexes. Run python gate.py 0007_naive.sql and check that its exit code is 1, then python gate.py 0007_safe.sql and check that it is 0. Your timings will differ. The lock modes, the rewrite column, the error counts and the pass and fail results should not.

Where to go next

Run the gate in your deploy pipeline or as a pre-commit hook on your migration folder, and add a rule each time a migration surprises you. Put the lock timeout in whatever runs your migrations, and make the runner print the blocker when a retry fails. Try different lock_timeout values and batch sizes on your own table. If you write Rails migrations, Strong Migrations already does this job for them, so use it instead of this gate; the tutorial gives you the reasons behind its rules and a way to apply them to plain SQL files. If you want to keep learning on this site, the same habits appear in evolving a Protobuf schema without breaking old clients, where a CI gate blocks unsafe edits, and in building a plan gate for generated SQL and enforcing guardrails on generated Terraform. For what else PostgreSQL does with locks, read stopping lost updates and write skew with isolation levels and finding a slow query with EXPLAIN ANALYZE.

Sources

PostgreSQL 18 documentation: ALTER TABLE, explicit locking, CREATE INDEX, client connection defaults (lock_timeout and idle_in_transaction_session_timeout), routine vacuuming, lock management settings and the release 18 notes; the release 9.5 notes. GoCardless, Zero-downtime Postgres migrations, the hard parts. The Strong Migrations README. Python packages: psycopg and pytest.

Tags:

DatabasesDevOpsPostgreSQLpytestPythonReliability Engineering

Share

An old green steel truss bridge resting on temporary supports beside a new arch bridge under completion on a river in Portland, Oregon
Previous Post

The Angular 2 Rewrite Turns a Migration Lesson Into a Security Bill: AngularJS Still Draws 533,480 Downloads a Week, and 10 Advisories Have No Open-Source Fix

No Comment! Be the first one.

Leave a Reply Cancel reply

Your email address will not be published. Required fields are marked *

Latest
28 Oct
How to Run Zero-Downtime Schema Migrations on PostgreSQL 18 and Block Unsafe ALTER TABLE Statements With a Python Lint Gate
28 Oct
The Angular 2 Rewrite Turns a Migration Lesson Into a Security Bill: AngularJS Still Draws 533,480 Downloads a Week, and 10 Advisories Have No Open-Source Fix
Trending
October 28, 2026
How to Run Zero-Downtime Schema Migrations on PostgreSQL 18 and Block Unsafe ALTER TABLE Statements With a Python Lint Gate
October 28, 2026
The Angular 2 Rewrite Turns a Migration Lesson Into a Security Bill: AngularJS Still Draws 533,480 Downloads a Week, and 10 Advisories Have No Open-Source Fix
October 28, 2026
WordPress 7.1.4 Closes an Unauthenticated Race in Its Own Update Routine, but 19 Older Branches Had No Backport Hours After Release
October 28, 2026
How to Compress Time-Series Metrics in Python With Gorilla Delta-of-Delta and XOR Encoding
October 28, 2026
NIST’s Final Post-Quantum Transition Report Puts Every Quantum-Vulnerable Public-Key Scheme on One 2030 and 2035 Clock, and Three Federal Documents Word the Deadline Three Ways
October 28, 2026
Honeypots Recorded Probes Against Oracle E-Business Suite CVE-2026-101767 Seven Days After Its Patch, and the Finder’s Write-Up Is Dated Patch Day

Related Posts

A laptop wrapped in a chain and padlock, illustrating least-privilege controls for AI agents.
Learning Hub

How to Secure Tool-Using AI Agents Before They Touch Production

June 8, 2026
Colorful sticky notes arranged on an office wall, symbolizing governance checklists and planning.
Learning Hub

AI Governance for Agentic Apps: A Practical Checklist for Builders

June 8, 2026
A technician connects green fiber optic cables at a data center, representing a private production inference endpoint.
Learning Hub

How to Deploy a Fine-Tuned LLM Behind a Private Production Inference Endpoint

June 8, 2026
Narrow aisle behind black supercomputer racks in a data center
Learning Hub

Kubernetes SELinux Volume Labeling: What Cluster Operators Should Audit Before v1.37

June 8, 2026
SXZ.io SXZ.io
  • [email protected]

Categories

Articles
Learning Hub
News

All Rights Reserved by SXZ.io ©2026