All posts

ALTER TABLE, 5 Million Rows, and the Deploy That Took Down the Site

Mikhail ShytskoBy Mikhail Shytsko, Founder at Seedfast · · Updated

Share
Open in ChatGPT

A migration that takes 50ms on your dev database can lock a production table for 20 minutes, and running it against production-scale volume first is how you catch that before your users do.

The Thursday-afternoon deploy looked harmless. Adding a NOT NULL column with a default value to the orders table had passed review and run fine on staging, and the pipeline showed green.

Then production went quiet, the way an on-call channel goes quiet, because the orders table, all 8 million rows of it, sat locked while every API endpoint that touched orders queued and the load balancers began returning 502s. Forty minutes passed before the locks released and the retro could start. The schema change was exactly what the team needed, and the outage came down to a single missing step — nobody had run it against 8 million rows anywhere but production.

This article covers migration performance — lock duration, rewrite time, and the row counts that turn a 50ms change into a 40-minute outage. Its correctness companion, catching NULL concatenations and failed constraints before they ship, is how to review a migration against realistic data.

  • Migration time scales with data volume. A change that runs in 50ms on 50 development rows can hold a lock for minutes on millions of production rows.
  • The dangerous operations rewrite or scan the whole table — backfilling a new column, a non-concurrent CREATE INDEX, an integer-to-bigint type change, and validating a new FOREIGN KEY. Each one holds a lock proportional to row count.
  • Some changes are metadata-only and safe at any size — adding a column with a constant default (PostgreSQL 11+), widening a varchar, or varchar to text. Reading the SQL won't tell you which class you're in.
  • Lock duration, not total runtime, is what takes the site down. A two-minute migration that never holds a lock longer than 200ms is safer than a 30-second one that locks the table the entire time.
  • You can time any migration before it ships by seeding production-scale, foreign-key-valid data with Seedfast and watching for lock contention as it runs. The data-shape side of the same check, at small volume, lives in migration review.

Schema migrations carry a property most code changes lack. Their execution time scales with data volume, because on many operations PostgreSQL rewrites or scans every row rather than only updating metadata.

This creates a class of problems that are completely invisible in development:

Operation100 rows1M rows5M rows
ADD COLUMN ... DEFAULT (pre-PG 11)< 1ms8s42s
ALTER COLUMN TYPE (integer to bigint)< 1ms12s65s
CREATE INDEX (single column)< 1ms4s22s
ADD COLUMN NOT NULL DEFAULT (PG 11+)< 1ms< 1ms< 1ms
CREATE INDEX CONCURRENTLY< 1ms6s35s

Figures are illustrative orders of magnitude on commodity hardware; real numbers depend on your hardware, table width, indexes, and concurrent load.

That fourth row is worth a look. PostgreSQL 11 made ADD COLUMN ... DEFAULT a metadata-only operation for constant defaults, while the other operations still rewrite or scan the table and even the fast ones have nuances that bite at scale.

-- Step 1: Looks fine
ALTER TABLE orders ADD COLUMN region VARCHAR(50);

-- Step 2: The backfill that locks the table
UPDATE orders SET region = 'us-east-1' WHERE region IS NULL;

-- Step 3: Now add the constraint
ALTER TABLE orders ALTER COLUMN region SET NOT NULL;

The backfill in step 2 is where it goes wrong. A single UPDATE touching 5 million rows acquires row locks across the entire table, so concurrent writes block, and autovacuum can't reclaim the dead tuples the backfill creates while the transaction stays open. If you have foreign key references, those tables might lock too.

At scale, a 5M-row backfill UPDATE can take 30+ seconds and block all concurrent writes to the table for the duration.

The safer pattern:

-- Batch the backfill
UPDATE orders SET region = 'us-east-1'
WHERE id IN (SELECT id FROM orders WHERE region IS NULL LIMIT 10000);
-- Repeat until no rows remain
-- Acquires a SHARE lock — blocks writes for the entire build
CREATE INDEX idx_orders_customer_id ON orders (customer_id);

On 100 rows it finishes instantly, but on 10 million rows it holds a SHARE lock for the entire index build, so every INSERT, UPDATE, and DELETE on that table queues behind it.

The safer pattern:

-- Builds the index without blocking writes
CREATE INDEX CONCURRENTLY idx_orders_customer_id ON orders (customer_id);

CONCURRENTLY doesn't block writes, but it takes roughly 2-3x longer and can fail if there are concurrent schema changes. It also can't run inside a transaction block, which means most migration frameworks need special handling.

-- Widening an integer primary key that's about to overflow
ALTER TABLE events ALTER COLUMN id TYPE BIGINT;

Every growing team eventually runs this one, an integer ID column approaching its ~2.1 billion ceiling. It reads like a one-line metadata tweak, but because integer and bigint have different on-disk representations, PostgreSQL has to rewrite the entire table while holding an ACCESS EXCLUSIVE lock, so a 5M-row table stays fully locked until it finishes.

The catch is that not every type change rewrites. Widening a varchar, or going from varchar to text, is binary-coercible, so PostgreSQL changes only the catalog and returns instantly even on 50 million rows, while id INTEGER to id BIGINT rewrites the whole table.

The safer pattern for a true rewrite adds a new column, backfills in batches, swaps with a rename, and drops the old column, which is more steps but never holds an extended lock.

ALTER TABLE orders ADD CONSTRAINT fk_orders_customer
  FOREIGN KEY (customer_id) REFERENCES customers (id);

Adding a foreign key makes PostgreSQL validate every existing row, a full table scan over 5 million orders while it holds a lock on both orders and customers. If customers is also large, you've locked two critical tables at once, and it only works if every order already points at a real customer, the kind of referential integrity that production data drifts away from over time.

The safer pattern:

-- Add the constraint without validating existing rows
ALTER TABLE orders ADD CONSTRAINT fk_orders_customer
  FOREIGN KEY (customer_id) REFERENCES customers (id) NOT VALID;

-- Validate in a separate step (holds a weaker lock)
ALTER TABLE orders VALIDATE CONSTRAINT fk_orders_customer;

NOT VALID adds the constraint for new rows immediately, and then VALIDATE checks the existing rows under a less aggressive lock whose duration still tracks the row count.

ALTER TABLE orders DROP COLUMN legacy_status;

In PostgreSQL, DROP COLUMN doesn't rewrite the table; it only marks the column as invisible. That sounds fast, and usually it is, but the operation still takes an ACCESS EXCLUSIVE lock, so if long-running queries are reading from the table, the DROP waits for them to finish, and while it waits, every new query queues behind it. A 1ms metadata operation can block the table for minutes when a slow SELECT is running.

At scale, the lock acquisition time becomes unpredictable, driven by whatever concurrent query workload happens to be running when the DROP fires.

The fix is to run your migration against production-scale data first. Seedfast generates realistic, relational data straight from your schema, so you can stand up a table at production row counts, without copying production rows or maintaining seed scripts, and time the migration against it.

# Match your production table sizes
seedfast seed --scope "seed 5 million orders with customers, order items, and payments"

The generated rows come out FK-valid and realistically distributed, with proportions that match the scope you asked for (batching and scope sizing for the very largest runs are covered in the large-volume seeding guide):

Seeding Plan:
  public.customers    — 500,000 records
  public.orders       — 5,000,000 records
  public.order_items  — 12,000,000 records
  public.payments     — 4,800,000 records

Total: 22,300,000 records across 4 tables

Approve? (Y/n)
# Time the migration
time psql $DATABASE_URL -f migrations/20260225_add_region_column.sql

# Or with your migration framework
time flyway migrate
time rails db:migrate
time alembic upgrade head

While the migration runs, open another terminal and monitor locks:

-- See which queries are blocked and what's blocking them
SELECT
  blocked.pid          AS blocked_pid,
  blocked.query        AS blocked_query,
  blocking.pid         AS blocking_pid,
  blocking.query       AS blocking_query,
  now() - blocked.query_start AS waiting_time
FROM pg_stat_activity AS blocked
JOIN pg_stat_activity AS blocking
  ON blocking.pid = ANY(pg_blocking_pids(blocked.pid));

Queries stacking up behind your migration are exactly what production will do, only with real user traffic behind them; PostgreSQL's explicit locking documentation explains which lock modes conflict.

If the naive migration locks the table for too long, implement the safer pattern and measure again:

# Reseed a fresh database (or use a separate test database)
seedfast seed --scope "seed 5 million orders with customers"

# Run the batched migration
time psql $DATABASE_URL -f migrations/20260225_add_region_column_safe.sql

Now you have concrete numbers your team can decide on: "The naive migration locks orders for 38 seconds. The batched version takes 2 minutes total but never holds a lock for more than 200ms."

Run both candidate migrations against the same populated copy and you can see which lock pattern actually holds up (the seeding guide covers a first run).

Wire this into your pipeline so it stops being a manual exercise.

name: Migration Benchmark

on:
  pull_request:
    paths:
      - 'migrations/**'

jobs:
  benchmark-migration:
    runs-on: ubuntu-latest

    services:
      postgres:
        image: postgres:16
        env:
          POSTGRES_DB: bench
          POSTGRES_USER: bench
          POSTGRES_PASSWORD: bench
        ports:
          - 5432:5432

    steps:
      - uses: actions/checkout@v4

      - name: Apply base schema
        run: |
          for f in migrations/*.sql; do
            psql $DATABASE_URL -f "$f" 2>/dev/null || true
          done
        env:
          DATABASE_URL: postgres://bench:bench@localhost:5432/bench

      - name: Seed test data
        run: seedfast seed --scope "seed 1 million orders with customers and payments" --output plain
        env:
          SEEDFAST_API_KEY: ${{ secrets.SEEDFAST_API_KEY }}
          DATABASE_URL: postgres://bench:bench@localhost:5432/bench

      - name: Benchmark new migration
        run: |
          NEW_MIGRATIONS=$(git diff --name-only origin/main -- migrations/ | sort)
          for f in $NEW_MIGRATIONS; do
            echo "--- Benchmarking: $f ---"
            time psql $DATABASE_URL -f "$f"
          done
        env:
          DATABASE_URL: postgres://bench:bench@localhost:5432/bench

      - name: Check migration duration
        run: |
          echo "Review migration timing above. Migrations over 30s need batching or CONCURRENTLY."

Now every PR with a migration gets benchmarked against realistic data, so a 200-row staging pass no longer vouches for a change. For running it unattended, the CI/CD database seeding guide covers non-interactive mode and spinning up a database per PR.

Even without full CI integration, you can add a one-liner to your PR template:

## Migration Checklist
- [ ] Tested against production-scale data (`seedfast seed --scope "..."`)
- [ ] Migration completes in under 30 seconds on 1M+ rows
- [ ] No ACCESS EXCLUSIVE locks held for more than 5 seconds
- [ ] Uses CONCURRENTLY for index creation (if applicable)
- [ ] Backfills are batched (if applicable)

A slow migration is just one of the bugs that only real test data catches; the same volume blind spot behind slow queries and N+1s hides slow migrations too. A few more that trip teams up at scale:

Transaction-wrapped migrations lock longer than you think. Most frameworks run each migration inside a transaction, which means the lock acquired at the start isn't released until the whole file finishes, backfills and constraint validations and index builds included.

Because CONCURRENTLY can't run in a transaction, a framework that wraps migrations by default (Rails, Flyway, Alembic) makes CREATE INDEX CONCURRENTLY fail until you configure that specific migration to run outside the transaction block.

For zero-downtime rewrites like a column-type change, pg_repack rebuilds a table in the background with minimal locking, though on a 50M-row table it still takes significant time and I/O, so test it against real volume first.

Autovacuum can fall behind after a large backfill. The dead tuples an UPDATE leaves behind cause table bloat that slows subsequent queries if autovacuum can't keep up, so test the migration and then check bloat with pg_stat_user_tables.

Checklist for any migration touching a table with more than 100K rows:

  • Seed production-scale data locally
seedfast seed --scope "seed [your production row count] [table] with related records"
  • Time the migration. If it takes more than 10 seconds, consider batching or alternative approaches.
  • Monitor locks during execution. An ACCESS EXCLUSIVE lock held for more than a few seconds will impact production traffic.
  • Test the rollback too. A 30-second migration with a 5-minute rollback is a risky deploy.
  • Run it during low traffic if the lock duration is unavoidable, and know exactly how long that is, in seconds.
  • Check CONCURRENTLY support in your migration framework for index operations.
  • Validate constraints separately using NOT VALID + VALIDATE CONSTRAINT for foreign keys and check constraints.
  • Batch backfills — never UPDATE millions of rows in a single statement.

You can't measure lock duration on 50 rows, benchmark a backfill on an empty table, or catch CONCURRENTLY failing inside your framework's transaction wrapper without production-scale data.

How do you test a database migration before running it in production?

You test a migration by running it against a database seeded with production-scale data and measuring how long it locks each table. Stand up a database on the current schema, seed it to the same row counts as production, run the migration with a timer, and monitor pg_locks in a second session while it executes. Whatever lock duration shows up there is what production will hit, only with live traffic queuing behind it.

Why does a migration that runs fast in development lock the table in production?

Because most migration cost scales with row count, and a development database has almost no rows. A backfill UPDATE, a non-concurrent CREATE INDEX, or an integer-to-bigint rewrite touches every row, so the same statement that finishes in under a millisecond on 50 dev rows can hold a lock for 40 seconds on 8 million production rows.

Which PostgreSQL operations require a full table rewrite?

Changing a column to an incompatible type (such as integer to bigint), adding a column with a volatile default, and some SET/DROP operations rewrite the whole table under an ACCESS EXCLUSIVE lock. Operations that are binary-coercible do not rewrite: widening a varchar, varchar to text, and, since PostgreSQL 11, adding a column with a constant default are metadata-only and instant at any size.

How do you add an index to a large Postgres table without downtime?

Use CREATE INDEX CONCURRENTLY, which builds the index without blocking writes. It takes roughly 2–3× longer than a plain CREATE INDEX and cannot run inside a transaction block, so most migration frameworks (Rails, Flyway, Alembic) need that specific migration configured to run outside their default transaction wrapper. A plain CREATE INDEX holds a SHARE lock that queues every write for the entire build.

Can I use a copy of production data to test migration performance?

A production copy answers the volume question, but it drags real customer records (and their compliance exposure) into an environment with weaker controls. By the time you refresh it, the schema has usually moved as well. Seeding the same row counts from the schema itself gives you identical lock behavior with nothing sensitive in the database. Seedfast works from your schema definitions rather than your rows, passing them to an external AI service to generate the data, so the thing to check is whether that schema handling fits your data-governance rules.

How does Seedfast help test migrations at scale?

It gives you a database at production row counts to benchmark against, seeded with FK-valid rows generated from your live schema. You point it at a local or throwaway CI database, where it reads the schema structure without touching your production rows. There's a 30-day free trial that doesn't ask for a card, and because the schema is read fresh on every run, the same command keeps working after a migration changes the table.

Get Started | Documentation | Pricing

Seed a test database to production row counts with Seedfast and time your next migration against volume that behaves like the real thing.