Zero-Downtime PostgreSQL Migrations in Laravel

IN Ignatius N Oct 1, 2026 9 min read 4,908 views

Every backend engineer eventually meets the same rite of passage: a migration that runs fine on staging data and then locks a production table with forty million rows while hundreds of requests pile up behind it. The statement takes seconds; the outage takes hours.

PostgreSQL changes columns by rewriting tables far more often than people expect, and most of those rewrites take an ACCESS EXCLUSIVE lock — blocking every read and write for the duration. The good news: once you know which operations rewrite and which don't, safe migrations become a checklist rather than a gamble.

Know what actually locks

Adding a nullable column is metadata-only: instant on any table size. Adding a column with a volatile default, changing a column type, or adding a NOT NULL without a default all trigger full rewrites. Renaming is free. Dropping a column is metadata-only too — the data just becomes invisible.

  • Safe & instant: ADD COLUMN (nullable), RENAME, DROP COLUMN, CREATE INDEX CONCURRENTLY
  • Rewrites the table: CHANGE TYPE, SET NOT NULL (pre-12 patterns), ADD COLUMN with volatile DEFAULT
  • Brief exclusive lock but no rewrite: SET NOT NULL backed by a CHECK constraint (PG 12+)

The expand-and-contract pattern

For risky changes I use three deployments. Deploy one expands: add the new shape alongside the old, writing to both. Deploy two backfills in batches and flips reads. Deploy three contracts: drop the old shape once nothing references it. Each step is boring, reversible and small enough to review honestly.

2026_01_01_backfill_orders.php
<pre><code class="language-php">// Batched backfill — never one giant UPDATE
Order::whereNull(&#039;total_cents&#039;)
    -&gt;orderBy(&#039;id&#039;)
    -&gt;chunkById(1000, fn ($orders) =&gt; $orders-&gt;each-&gt;update([
        &#039;total_cents&#039; =&gt; $orders-&gt;total * 100,
    ]));</code></pre>

Indexes without downtime

CREATE INDEX CONCURRENTLY builds an index without blocking writes, at the cost of two table scans and some patience. Laravel supports it directly — with one caveat worth knowing before you need it at 2am.

migration.php
<pre><code class="language-php">// Cannot run inside a transaction
Schema::table(&#039;events&#039;, function (Blueprint $table): void {
    $table-&gt;index(&#039;scheduled_at&#039;, &#039;events_scheduled_at_idx&#039;, &#039;gin&#039;);
});

// In Laravel this means your migration must be atomic-free:
// public $withinTransaction = false;</code></pre>
The database doesn't care how urgent the feature is. It only cares about locks.
Every DBA ever

Guardrails for the whole team

Process beats memory. We added CI checks that fail any migration containing change() or raw ALTER statements unless tagged with an escape hatch comment reviewed by a second engineer. Lock_timeout set to a few seconds ensures that if something does block, it fails fast instead of stacking requests.

  • Set lock_timeout and statement_timeout on migration connections
  • Test migrations against a production-sized snapshot, not fixtures
  • Ship migrations separately from code deploys — never in the same PR
  • Write the rollback before you need it

None of this is exotic. It's a handful of habits that turn schema changes from heart-stopping events into routine deployments — which is exactly where they belong.

Share: X / Twitter LinkedIn

Keep reading

All articles