How to Run Zero-Downtime Postgres Migrations: Step by Step (2026)

Oct 31, 2026 · 17 min · Ajay Kumar

This guide is for engineers who run PostgreSQL behind a live application and need to change the schema without a maintenance window. By the end you will have migration sessions that cannot queue your traffic, a retry wrapper, safe recipes for new columns, indexes, NOT NULL and backfills, a complete expand/contract column rename (by hand and with pgroll), a Squawk lint step in CI, and the queries that tell you who is blocking whom. I ran every SQL statement and shell script below on PostgreSQL 18.4 against a 5 million row orders table, and the outputs are excerpts from those runs (pgroll and Squawk follow their documentation; I did not run them here); everything except the Step 5 NOT NULL ... NOT VALID variant works on PostgreSQL 14 and later, and the tool versions are pgroll 0.16.3 and Squawk 2.70.0. You need psql, a role that owns the tables, and a staging database with production-sized data. If you want the background on why ALTER TABLE locks and where expand/contract comes from first, read zero-downtime database migrations explained.

Step 1: Set lock_timeout and statement_timeout for every migration session

Most ALTER TABLE forms take an ACCESS EXCLUSIVE lock, the only mode that conflicts with a plain SELECT (explicit locking docs). The lock is usually held for milliseconds. The damage happens while the DDL waits for it, because every query that arrives after the waiting DDL queues behind it. The figure shows a real run.

One idle transaction queues every query behind an ALTER TABLE (PostgreSQL 18.4, measured) admin_console, pid 18589 BEGIN; SELECT ...; then idle AccessShareLock: granted state: idle in transaction migration, pid 18595 ALTER TABLE orders ADD COLUMN AccessExclusiveLock: waiting blocked by 18589 web_api, 18601 and 18602 SELECT and UPDATE on orders AccessShare, RowExclusive waiting: blocked by 18595 waits on waits on No timeout (default 0) queue lasts until 18589 ends new queries keep joining it lock_timeout = 2s ALTER cancelled at 2,003 ms queued SELECT done at 1,487 ms run-ddl.sh retries attempt 1: lock timeout attempt 2: ok Alternative: terminating the idle session with pg_terminate_backend() let the ALTER finish 2,010 ms after it started.
Sessions and lock modes from pg_stat_activity and pg_locks, and timings from psql, across repeated runs of the same scenario on a local PostgreSQL 18.4 instance; the outputs appear in Step 1 and in Troubleshooting.

Set both timeouts at the top of every migration file, or once on the role your migrations use. The client connection defaults docs say not to set them in postgresql.conf, because that affects every session:

-- top of every migration file
SET lock_timeout = '2s';       -- give up waiting for a lock after 2 seconds
SET statement_timeout = '30s'; -- cap any statement that unexpectedly scans or rewrites

-- or once, for the dedicated migration role (applies to new sessions)
ALTER ROLE migrator SET lock_timeout = '2s';
ALTER ROLE migrator SET statement_timeout = '30s';

Here is what the timeout does when a reporting transaction holds the table, with a web query arriving half a second after the migration:

-- migration session
SET
SET
ERROR:  canceling statement due to lock timeout
Time: 2003.143 ms (00:02.003)

-- web session, queued behind the migration
 status
--------
 paid
(1 row)
Time: 1486.945 ms (00:01.487)

A lock timeout is the safe outcome, not a failure: retry with backoff. This wrapper applies one file and retries only on lock timeouts:

#!/usr/bin/env bash
# run-ddl.sh: apply one DDL file with a short lock_timeout, retrying on lock timeouts.
# Usage: DATABASE_URL=postgres://... ./run-ddl.sh migrations/0042_add_note.sql
set -euo pipefail
file="$1"
max_attempts="${MAX_ATTEMPTS:-10}"
for attempt in $(seq 1 "$max_attempts"); do
  # PGOPTIONS applies the settings to this session only, before the file runs.
  if out=$(PGOPTIONS="-c lock_timeout=2s -c statement_timeout=30s" \
           psql "$DATABASE_URL" -X -v ON_ERROR_STOP=1 -f "$file" 2>&1); then
    echo "attempt $attempt: ok"
    exit 0
  fi
  if grep -q "canceling statement due to lock timeout" <<<"$out"; then
    delay=$(( attempt * 2 + RANDOM % 3 ))
    echo "attempt $attempt: lock timeout, retrying in ${delay}s"
    sleep "$delay"
  else
    echo "$out" >&2
    exit 1
  fi
done
echo "gave up after $max_attempts attempts" >&2
exit 1

A SET inside a file overrides the PGOPTIONS defaults, which is how index builds (Step 3) and backfills (Step 4) relax them. Run against a table held by a 7-second transaction, it printed:

attempt 1: lock timeout, retrying in 3s
attempt 2: ok

Two related settings stop the blockers themselves. idle_in_transaction_session_timeout terminates sessions that sit idle inside an open transaction, and transaction_timeout (added in PostgreSQL 17) terminates sessions whose transaction runs past the limit. Set them on application and reporting roles, not on the migration role.

Step 2: Add columns without rewriting the table

Since PostgreSQL 11, adding a column with a constant default is metadata-only. A volatile default such as clock_timestamp() rewrites the whole table and its indexes while holding ACCESS EXCLUSIVE (ALTER TABLE docs). The relation's filenode shows which one happened:

\timing on
SELECT pg_relation_filenode('orders') AS filenode_before;
ALTER TABLE orders ADD COLUMN currency text NOT NULL DEFAULT 'EUR';
SELECT pg_relation_filenode('orders') AS filenode_after_constant_default;
ALTER TABLE orders ADD COLUMN imported_at timestamptz DEFAULT clock_timestamp();
SELECT pg_relation_filenode('orders') AS filenode_after_volatile_default;
 filenode_before
-----------------
           16385
ALTER TABLE
Time: 3.813 ms
 filenode_after_constant_default
---------------------------------
                           16385
ALTER TABLE
Time: 9756.770 ms (00:09.757)
 filenode_after_volatile_default
---------------------------------
                           16404

Nearly ten seconds of blocked reads and writes on a 433 MB table. For a volatile default, add the column as nullable, set the default separately (it only applies to future inserts), then backfill existing rows as in Step 4:

SET lock_timeout = '2s';
ALTER TABLE orders ADD COLUMN imported_at timestamptz;                         -- metadata only
ALTER TABLE orders ALTER COLUMN imported_at SET DEFAULT clock_timestamp();     -- new rows only
ALTER TABLE
Time: 1.040 ms
ALTER TABLE
Time: 1.176 ms
 after | existing_rows_filled
-------+----------------------
 16404 |                    0

Same filenode, no existing rows touched, about a millisecond each.

Step 3: Create indexes with CREATE INDEX CONCURRENTLY

A plain CREATE INDEX allows reads but blocks writes for the entire build. CONCURRENTLY allows writes, does two table scans and waits for older transactions, and cannot run inside a transaction block:

SET lock_timeout = '10min';  -- its waits do not block reads or writes (see below)
SET statement_timeout = 0;   -- the build can legitimately take a long time
CREATE INDEX CONCURRENTLY orders_customer_id_idx ON orders (customer_id);
CREATE INDEX
Time: 1888.122 ms (00:01.888)

Inside BEGIN, which is what most migration tools do by default, you get ERROR: CREATE INDEX CONCURRENTLY cannot run inside a transaction block. Put each concurrent build in its own file and disable the wrapping transaction: executeInTransaction=false in a Flyway script configuration (Flyway also documents setting postgresql.transactional.lock=false for this), -- atlas:txmode none for Atlas, or atomic = False with AddIndexConcurrently in Django.

Watch progress from another session with pg_stat_progress_create_index:

SELECT p.pid, c.relname AS table, p.phase, p.blocks_done, p.blocks_total,
       round(100.0 * p.blocks_done / nullif(p.blocks_total, 0), 1) AS pct
FROM pg_stat_progress_create_index p
JOIN pg_class c ON c.oid = p.relid;
 pid  | table  |             phase              | blocks_done | blocks_total | pct
------+--------+--------------------------------+-------------+--------------+------
 9524 | orders | building index: scanning table |       39989 |        57989 | 69.0

If a concurrent build fails, for example a unique index meeting duplicates, it leaves an INVALID index that is ignored by queries but still costs write overhead:

ERROR:  could not create unique index "orders_customer_id_uniq"
DETAIL:  Key (customer_id)=(54715) is duplicated.

Indexes:
    "orders_customer_id_uniq" UNIQUE, btree (customer_id) INVALID

A short lock_timeout causes the same mess. CONCURRENTLY waits for older transactions through lock waits, so with lock_timeout = '2s' and an open write transaction on the table my build failed with canceling statement due to lock timeout and left orders_note_idx with indisvalid = f. Those waits hold only SHARE UPDATE EXCLUSIVE, which does not conflict with reads or writes, so give index files a long lock_timeout instead of the Step 1 wrapper's 2 seconds. Beware IF NOT EXISTS too: rerunning the failed unique build printed NOTICE: relation "orders_customer_id_uniq" already exists, skipping and left the invalid index in place. Find and drop invalid indexes before retrying:

SELECT n.nspname AS schema, c.relname AS index, t.relname AS table
FROM pg_index i
JOIN pg_class c ON c.oid = i.indexrelid
JOIN pg_class t ON t.oid = i.indrelid
JOIN pg_namespace n ON n.oid = c.relnamespace
WHERE NOT i.indisvalid;

DROP INDEX CONCURRENTLY IF EXISTS orders_customer_id_uniq;

Step 4: Backfill existing rows in batches

Never backfill with one UPDATE over the whole table: it locks every row until commit, produces one burst of WAL for replicas, and cannot resume. A procedure can commit between batches (PostgreSQL 11 and later), which keeps each transaction short:

-- Batched backfill: one short transaction per primary-key range.
CREATE OR REPLACE PROCEDURE backfill_order_status(
  batch_size bigint   DEFAULT 10000,
  pause      interval DEFAULT '20 milliseconds'
)
LANGUAGE plpgsql
AS $$
DECLARE
  cur_id  bigint := 0;
  max_id  bigint;
  updated bigint;
  total   bigint := 0;
BEGIN
  SELECT max(id) INTO max_id FROM orders;
  WHILE cur_id < max_id LOOP
    UPDATE orders
       SET status = 'unknown'
     WHERE id >  cur_id
       AND id <= cur_id + batch_size
       AND status IS NULL;          -- idempotent: safe to re-run after a crash
    GET DIAGNOSTICS updated = ROW_COUNT;
    total  := total + updated;
    cur_id := cur_id + batch_size;
    COMMIT;                         -- release row locks, let autovacuum keep up
    PERFORM pg_sleep_for(pause);    -- give replicas and other writers room
  END LOOP;
  RAISE NOTICE 'backfilled % rows up to id %', total, max_id;
END
$$;

CALL is a single statement, so statement_timeout applies to the whole run; lift it for this session only. Do not wrap the CALL in BEGIN:

SET statement_timeout = 0;
CALL backfill_order_status(10000, '20 milliseconds');
NOTICE:  backfilled 100000 rows up to id 5000000
CALL
Time: 12008.633 ms (00:12.009)

Every updated row leaves a dead tuple behind. After a full-table backfill in Step 6, the table stood at 890 MB with 4,985,875 dead tuples. Check pg_stat_user_tables and run VACUUM (ANALYZE) orders; (it does not block reads or writes) or let autovacuum catch up before the next step. On a primary with replicas, raise the pause until replica lag in pg_stat_replication stays flat.

Step 5: Add a NOT NULL constraint without a long lock

ALTER COLUMN ... SET NOT NULL scans the whole table under ACCESS EXCLUSIVE: 350 ms here, and the scan grows with table size. On PostgreSQL 12 and later, the scan is skipped if a valid CHECK constraint already proves there are no nulls, and VALIDATE CONSTRAINT only takes SHARE UPDATE EXCLUSIVE, which allows reads and writes:

SET lock_timeout = '2s';
-- 1. instant: enforced for new rows, existing rows not checked
ALTER TABLE orders ADD CONSTRAINT orders_status_nn_check CHECK (status IS NOT NULL) NOT VALID;
-- 2. scans, but writes continue (run after the Step 4 backfill)
ALTER TABLE orders VALIDATE CONSTRAINT orders_status_nn_check;
-- 3. no scan: the valid CHECK proves it
ALTER TABLE orders ALTER COLUMN status SET NOT NULL;
-- 4. the CHECK is now redundant
ALTER TABLE orders DROP CONSTRAINT orders_status_nn_check;
ALTER TABLE
Time: 0.474 ms
ALTER TABLE
Time: 178.099 ms
ALTER TABLE
Time: 0.202 ms
ALTER TABLE
Time: 0.138 ms

Running the VALIDATE inside BEGIN and querying pg_locks from the same session showed ShareUpdateExclusiveLock. Avoid naming the CHECK orders_status_not_null: on PostgreSQL 18 that is the default name of the not-null constraint SET NOT NULL creates.

PostgreSQL 18 can do this natively, because not-null constraints now live in pg_constraint and accept NOT VALID:

SET lock_timeout = '2s';
ALTER TABLE orders ADD CONSTRAINT orders_status_nn NOT NULL status NOT VALID;
SELECT conname, contype, convalidated FROM pg_constraint
 WHERE conrelid = 'orders'::regclass AND conname = 'orders_status_nn';
INSERT INTO orders (customer_id, status, total_cents) VALUES (1, NULL, 100);  -- must fail
ALTER TABLE orders VALIDATE CONSTRAINT orders_status_nn;   -- SHARE UPDATE EXCLUSIVE
SELECT conname, contype, convalidated FROM pg_constraint
 WHERE conrelid = 'orders'::regclass AND conname = 'orders_status_nn';
ALTER TABLE
Time: 0.431 ms
     conname      | contype | convalidated
------------------+---------+--------------
 orders_status_nn | n       | f
(1 row)

ERROR:  null value in column "status" of relation "orders" violates not-null constraint
ALTER TABLE
Time: 338.743 ms
     conname      | contype | convalidated
------------------+---------+--------------
 orders_status_nn | n       | t
(1 row)

The INSERT of a null was rejected the moment the NOT VALID constraint existed. Foreign keys use the same two-step: ADD CONSTRAINT ... FOREIGN KEY ... NOT VALID, then VALIDATE CONSTRAINT.

Step 6: Rename a column with expand/contract

RENAME COLUMN is instant for the database and fatal for every running instance of the old code. To rename orders.total_cents to amount_cents, add the new column and keep the two in sync with a trigger while old and new code overlap:

SET lock_timeout = '2s';
-- Expand 1: new column, nullable, no default: metadata-only
ALTER TABLE orders ADD COLUMN IF NOT EXISTS amount_cents bigint;

-- Expand 2: keep both columns in sync while old and new app versions run
CREATE OR REPLACE FUNCTION orders_sync_amount() RETURNS trigger
LANGUAGE plpgsql AS $$
BEGIN
  IF TG_OP = 'INSERT' THEN
    NEW.amount_cents := COALESCE(NEW.amount_cents, NEW.total_cents);
    NEW.total_cents  := COALESCE(NEW.total_cents, NEW.amount_cents);
  ELSIF NEW.amount_cents IS DISTINCT FROM OLD.amount_cents THEN
    NEW.total_cents := NEW.amount_cents;     -- new code wrote the new column
  ELSIF NEW.total_cents IS DISTINCT FROM OLD.total_cents THEN
    NEW.amount_cents := NEW.total_cents;     -- old code wrote the old column
  END IF;
  RETURN NEW;
END
$$;

-- CREATE TRIGGER takes SHARE ROW EXCLUSIVE (blocks writes briefly), hence lock_timeout
CREATE OR REPLACE TRIGGER orders_sync_amount
  BEFORE INSERT OR UPDATE ON orders
  FOR EACH ROW EXECUTE FUNCTION orders_sync_amount();

An insert from old code, an insert from new code, and an update from old code all land in both columns:

   id    | total_cents | amount_cents
---------+-------------+--------------
 5000002 |        1999 |         1999
 5000003 |        4500 |         4500
       1 |        2500 |         2500

Then run the sequence:

  1. Backfill with the Step 4 procedure, changing the UPDATE to SET amount_cents = total_cents ... AND amount_cents IS NULL. Here: 4,999,999 rows in 45 seconds with no pause, trigger firing on every row.
  2. Add NOT NULL with Step 5 (orders_amount_cents_nn ... NOT VALID, then VALIDATE: 3.7 seconds, writes allowed).
  3. Deploy code that reads and writes amount_cents only.
  4. Deploy a change that makes the ORM forget total_cents (Rails documents ignored_columns for exactly this), and wait until no old pod, canary or worker runs. GitHub's August 2025 drop-column incidents came from skipping this.
  5. Contract, in one short transaction:
SET lock_timeout = '2s';
BEGIN;
DROP TRIGGER orders_sync_amount ON orders;
DROP FUNCTION orders_sync_amount();
ALTER TABLE orders DROP COLUMN total_cents;   -- metadata only, 0.8 ms here
COMMIT;

Step 7: Do the same rename with pgroll 0.16

pgroll automates this pattern. pgroll start performs the expand phase and publishes a versioned schema of views per migration, old and new side by side; pgroll complete contracts. It works on PostgreSQL 14 and later, including RDS and Aurora, and applies its own lock_timeout to DDL (--lock-timeout, default 500 ms, per the CLI reference). The migration name comes from the file name:

# migrations/03_rename_total_cents.yaml  (pgroll v0.16.3 format)
operations:
  - rename_column:
      table: orders
      from: total_cents
      to: amount_cents
export PGROLL_PG_URL="postgres://migrator@db.internal:5432/shop?sslmode=require"

pgroll init                                    # creates the pgroll state schema
pgroll baseline 01_baseline migrations/        # existing DB only: asks to confirm, writes a placeholder file
pgroll start migrations/03_rename_total_cents.yaml
pgroll latest url                              # URL with search_path=public_03_rename_total_cents
pgroll status                                  # "Status": "In progress"
# deploy new code with the new search_path; drain old pods, then:
pgroll complete                                # drops the old version schema, renames for real
# or, at any point before complete:
pgroll rollback

For changes that need a backfill, pgroll duplicates the column, fills it in batches (--backfill-batch-size, default 1,000, and --backfill-batch-delay), and keeps both copies in sync with triggers. The NOT NULL change from Step 5 looks like this:

# migrations/04_status_not_null.yaml
operations:
  - alter_column:
      table: orders
      column: status
      nullable: false
      up: "COALESCE(status, 'unknown')"   # old schema to new
      down: status                        # new schema to old

The catch: applications must connect through a versioned schema (by setting search_path), never public directly, or they bypass the views. Plan that change before adopting pgroll on an existing app. Note that 0.16.0 changed the create_index format, and pgroll update rewrites older files (release notes).

Step 8: Lint migrations in CI with Squawk

Squawk flags most mistakes above before they merge: require-concurrent-index-creation, constraint-missing-not-valid, adding-not-nullable-field, renaming-column, ban-drop-column, and require-lock-timeout plus require-statement-timeout, all on by default in 2.70.0. Its adding-not-nullable-field check recognises the Step 5 CHECK ... NOT VALID plus VALIDATE pattern only when pg_version is 12 or later, so set it. Pin both versions:

# .squawk.toml
pg_version = "18.0"
assume_in_transaction = false   # our runner does not wrap files in BEGIN
# .github/workflows/lint-migrations.yml
name: lint-migrations
on:
  pull_request:
    paths: ["migrations/**.sql"]
jobs:
  squawk:
    runs-on: ubuntu-24.04
    steps:
      - uses: actions/checkout@v7
      - uses: sbdchd/squawk-action@v2.2.0
        with:
          pattern: "migrations/*.sql"
          version: "2.70.0"
          config: ".squawk.toml"
          fail-on-violations: "true"   # default only comments on the PR

Squawk's own safety page is blunt that passing lint does not make a migration safe: it cannot know your ORM still reads a column. Keep the human review for contract steps, especially when an agent wrote the file. For the rest of the pipeline around this job, see building a CI/CD pipeline with GitHub Actions.

Troubleshooting

When a migration hangs, find the chain. This query lists each waiting session, what blocks it, and how old the blocker's transaction is, using pg_blocking_pids():

SELECT w.pid                                       AS waiting_pid,
       w.application_name                          AS waiting_app,
       date_trunc('second', now() - w.query_start) AS waited,
       b.pid                                       AS blocking_pid,
       b.application_name                          AS blocking_app,
       b.state                                     AS blocking_state,
       date_trunc('second', now() - b.xact_start)  AS blocker_xact_age,
       left(b.query, 32)                           AS blocker_last_query
FROM pg_stat_activity w
CROSS JOIN LATERAL unnest(pg_blocking_pids(w.pid)) AS bp(pid)
JOIN pg_stat_activity b ON b.pid = bp.pid
ORDER BY waited DESC;
 waiting_pid | waiting_app |  waited  | blocking_pid | blocking_app  |   blocking_state    | blocker_xact_age |        blocker_last_query
-------------+-------------+----------+--------------+---------------+---------------------+------------------+----------------------------------
       18595 | migration   | 00:00:03 |        18589 | admin_console | idle in transaction | 00:00:04         | SELECT count(*) FROM orders WHER
       18601 | web_api     | 00:00:03 |        18595 | migration     | active              | 00:00:03         | ALTER TABLE orders ADD COLUMN re
       18602 | web_api     | 00:00:03 |        18595 | migration     | active              | 00:00:03         | ALTER TABLE orders ADD COLUMN re

pg_locks shows the modes behind it:

SELECT l.pid, a.application_name AS app, l.mode, l.granted
FROM pg_locks l JOIN pg_stat_activity a USING (pid)
WHERE l.locktype = 'relation' AND l.relation = 'orders'::regclass
ORDER BY l.granted DESC, l.pid;
  pid  |      app      |        mode         | granted
-------+---------------+---------------------+---------
 18589 | admin_console | AccessShareLock     | t
 18595 | migration     | AccessExclusiveLock | f
 18601 | web_api       | RowExclusiveLock    | f
 18602 | web_api       | AccessShareLock     | f

First cancel the migration (SELECT pg_cancel_backend(18595);) so traffic flows. If the root blocker is an abandoned idle transaction, pg_terminate_backend() ends it; in my run the ALTER then finished 2,010 ms after it started:

SELECT pid, pg_terminate_backend(pid) AS terminated
FROM pg_stat_activity
WHERE state = 'idle in transaction'
  AND now() - state_change > interval '1 second'   -- use minutes in production
  AND datname = current_database();
Symptom Likely cause Fix
Migration hangs, app latency spikes, pool exhausted DDL waiting for ACCESS EXCLUSIVE behind a long or idle transaction; everything queues behind it Cancel the DDL, find the blocker with the query above, rerun with lock_timeout
ERROR: canceling statement due to lock timeout A transaction held a conflicting lock longer than the timeout Expected: retry with backoff (Step 1); set idle_in_transaction_session_timeout on app roles
CREATE INDEX CONCURRENTLY cannot run inside a transaction block Migration tool wraps the file in BEGIN Own file, transaction off: Flyway executeInTransaction=false, Atlas atlas:txmode none, Django atomic = False
Index marked INVALID in \d Concurrent build failed or was cancelled (duplicates, deadlock, lock_timeout) DROP INDEX CONCURRENTLY, fix the data, rebuild; IF NOT EXISTS will skip it
Concurrent build stuck in waiting for writers before build or waiting for old snapshots An open transaction on the table; current_locker_pid names it End that transaction; it was an idle admin_console session in my test
ERROR: invalid transaction termination on CALL Procedure with COMMIT called inside a transaction block Call it outside BEGIN, with the tool's transaction wrapping off
canceling statement due to statement timeout during CALL statement_timeout applies to the whole CALL SET statement_timeout = 0 for that session only
Replica lag or disk usage climbs during a backfill Batches too large or no pause; dead tuples pile up Smaller batches, longer pause, VACUUM (ANALYZE) afterwards
SET NOT NULL still scans after adding a CHECK The CHECK is still NOT VALID, or does not prove IS NOT NULL Run VALIDATE CONSTRAINT first; use exactly CHECK (col IS NOT NULL)
Old pods throw column does not exist after contract Code or ORM cache still references the dropped column Make the ORM ignore it, redeploy everywhere, then drop

FAQ

How long should lock_timeout be for migrations?

Short enough that users barely notice the queue it creates while it waits, because every query on that table queues for up to that long. Squawk's docs suggest 1 second with retries, pgroll defaults to 500 ms, and this guide uses 2 seconds. Concurrent index builds are the exception (Step 3).

Is ADD COLUMN with a DEFAULT safe on PostgreSQL 11 and later?

Yes, when the default is a constant: the value is stored in the catalog and no rewrite happens. Volatile defaults, stored generated columns, identity columns and some domain types still rewrite the table. Always set lock_timeout, because even the instant version needs ACCESS EXCLUSIVE briefly.

Does pgroll work on Amazon RDS and Aurora?

The pgroll README says it works with any Postgres service, including RDS and Aurora, on PostgreSQL 14 or later. Your applications must set search_path to the version schema that pgroll latest url prints.

Should migrations run in a Kubernetes init container?

No. Every replica would race to run them. Run one Job before the rollout, such as an Argo CD PreSync hook (see the Argo CD GitOps setup guide), and keep contract migrations for a later release.

Before any production DDL, I check two things: SELECT pid, state, now() - xact_start FROM pg_stat_activity ORDER BY xact_start NULLS LAST LIMIT 5; for the oldest open or idle transactions, and pg_total_relation_size() of the target table, which tells me whether a scan is milliseconds or minutes. In production, migrations run through a wrapper like run-ddl.sh from one CI job, never from a laptop and never from every pod, and contract steps wait at least one full release. If you run Postgres on Kubernetes, the CloudNativePG setup guide covers the operator side. Official references: ALTER TABLE, CREATE INDEX, explicit locking, client connection defaults, pgroll docs and Squawk rules.

Related: How to Implement Multi-Tenancy with Postgres Row-Level Security (2026), How to Do Canary Deployments with Argo Rollouts 1.10 (2026) and Zero-Downtime Database Migrations: Expand/Contract, Locks, Tools (2026).

I'm Ajay Kumar — I build and operate PandaStack, an open-source Firecracker microVM cloud for AI agents, and write about the infrastructure and tooling around it.

Need this kind of infrastructure work? See what I do or email hello@ajayk.sh.


Related