Privacy Policy© 2026 DEV BAK - TECH BLOG. All rights reserved.
DEV BAK - TECH BLOG
Search posts
Backend

Zero-Downtime PostgreSQL Schema Migration in Practice — Deploying Column Renames to Type Changes Without Rollback Using the Expand-Contract Pattern

sql
END;
$$;
 
CALL backfill_preferences();
sql
-- 3. Add constraint quickly with NOT VALID (validates only new rows immediately)
SET lock_timeout = '2s';
ALTER TABLE users
  ADD CONSTRAINT users_preferences_not_null
  CHECK (preferences IS NOT NULL) NOT VALID;
 
-- 4. Validate existing rows (SHARE UPDATE EXCLUSIVE lock — no blocking of SELECT or most DML)
SET lock_timeout = '2s';
ALTER TABLE users VALIDATE CONSTRAINT users_preferences_not_null;
 
-- 5. PG 12+: Reuse validated CHECK to convert to actual NOT NULL (no full scan)
SET lock_timeout = '2s';
ALTER TABLE users ALTER COLUMN preferences SET NOT NULL;
 
-- 6. Drop CHECK constraint since NOT NULL is now guaranteed (optional)
SET lock_timeout = '2s';
ALTER TABLE users DROP CONSTRAINT users_preferences_not_null;

The NOT VALID + VALIDATE CONSTRAINT combination is especially powerful in PostgreSQL 12+. Because VALIDATE CONSTRAINT only acquires a SHARE UPDATE EXCLUSIVE lock, it blocks neither SELECT nor most DML. And in PostgreSQL 12+, if a validated CHECK constraint already exists, step 5's SET NOT NULL is processed without a full scan.


Scenario 4: Adding an Index

CREATE INDEX acquires a table lock, but CREATE INDEX CONCURRENTLY runs in the background.

sql
-- Wrong approach: causes a table lock
CREATE INDEX idx_users_email ON users(email);
 
-- Correct approach: run outside a transaction block
CREATE INDEX CONCURRENTLY idx_users_email ON users(email);

There are two things to watch out for. First, CONCURRENTLY cannot be run inside a transaction block. Second, if it fails, an INVALID index is left behind, so you need to check and clean it up.

sql
-- Check for INVALID indexes
SELECT indexrelid::regclass, indisvalid
FROM pg_index
WHERE indexrelid = 'idx_users_email'::regclass;
 
-- If INVALID, drop first then recreate
DROP INDEX CONCURRENTLY IF EXISTS idx_users_email;
CREATE INDEX CONCURRENTLY idx_users_email ON users(email);

Which Strategy Should You Use? Decision Flow

Diagram 1

The definition of a "large table" is not absolute. Up to hundreds of thousands of rows, you can try ALTER COLUMN TYPE directly with lock_timeout set, but beyond millions of rows it is better to follow the full Expand-Contract procedure. In addition to table size, write frequency, transaction length, and concurrency all factor into the decision.


Pros and Cons

Advantages

Item Description
Zero-downtime deployment Each phase is independently safe, maintaining service availability
Natural rollback The old structure remains alive before Contract, so reverting to the old code is all it takes to recover
Incremental validation Data integrity can be verified at each phase, increasing reliability
Feature flag integration Code deployment and data cutover can be fully decoupled

Disadvantages and Caveats

Item Description
Increased operational complexity Requires 3–4 deployments instead of a single DDL
Dual-write overhead Storage and I/O increase during the period when both columns are written simultaneously
Trigger performance impact Sync triggers affect write performance. Pre-benchmarking is required on high-traffic tables
Batch backfill time For tables with hundreds of millions of rows, backfill alone can take days — schedule Contract with enough buffer

Common Mistakes in Practice

Orphaned triggers: If you drop the column before removing the trigger during the Contract phase, the trigger referencing the deleted column remains alive and causes errors on every INSERT/UPDATE. In your checklist or migration script, always delete triggers and functions before dropping the column.

Trigger overwrite: If you write only to the new column without dual-writing while leaving the trigger in place, the trigger will overwrite the new column with the old column's previous value. Triggers must remain alive until just before Contract, and during that time you must keep writing to both columns.

Staging environment data scale: A migration that takes 2 seconds on a development DB can take 2 hours in production. Always validate in a staging environment with a data scale similar to production.

Batch size decision: Too large and each batch holds a lock for too long; too small and the total time grows. It is generally safe to start in the range of 10,000–100,000 rows and adjust from there.

ORM integration: If you use an ORM, your model may need to recognize both columns during the Expand phase. Handling differs by framework, so verify this in advance.


Tool Comparison

Tool Role Key Features
pgroll (Xata) Expand-Contract automation Serves old and new schemas simultaneously via versioned views; supports PostgreSQL 14+, RDS/Aurora
squawk Migration linter Automatically blocks unsafe DDL patterns — missing lock_timeout, non-CONCURRENTLY index creation, etc. — at the PR stage
pg_repack Online table repacking Restructures physical layout without ACCESS EXCLUSIVE (not a DDL tool)
Bytebase Schema change management platform GUI-based review and approval workflow

pgroll automates the Expand-Contract pattern and serves both old and new schemas simultaneously via versioned views during the migration window. Teams with many complex migrations may find it worth evaluating.


Closing Thoughts

Schema changes are an area where a small mistake can lead to a major outage. That said, they are not so difficult that you need to schedule a maintenance window every time. Once the Expand-Contract pattern is established within your team, schema changes can be handled the same way as any other feature deployment.

Three key takeaways:

First, always set SET lock_timeout before any DDL. This alone can prevent a significant portion of lock-queue cascade failures.

Second, the key to type changes is avoiding a Full Table Rewrite. Follow this order: add new column → trigger sync → batch backfill → code cutover (maintain dual-write) → drop trigger → drop old column.

Third, batch backfill should always be range-based, using a PROCEDURE to process each batch in an independent transaction. Changing millions of rows in a single UPDATE can cause an outage just as severe as a DDL lock.

Three steps to get started right now:

  1. Integrating squawk into CI to block unsafe DDL patterns at the PR stage is the fastest first step.
  2. Review your existing migration scripts to find DDL running without lock_timeout and fix it.
  3. Apply Expand-Contract to your next schema change and experience phased deployment firsthand — after that, you will naturally approach future changes the same way.

At first, having more deployments may feel cumbersome. But the increase in deployment complexity is matched by a corresponding increase in service availability and team confidence.


References

  • Zero-Downtime PostgreSQL Schema Migrations: Expand/Contract vs Blue-Green Deployment — DEV Community
  • Database Migrations in Production: Zero-Downtime Schema Changes (2026 Guide) — DEV Community
  • Database Migrations Without Downtime — Expand-Contract, Shadow Tables, and Feature Flags | datasops Blog
  • Zero-Downtime PostgreSQL Migrations: Expand/Contract, Backfill and Rollback Strategies | Michal Drozd
  • Using the expand and contract pattern | Prisma's Data Guide
  • Zero-downtime Postgres schema migrations need this: lock_timeout and retries | PostgresAI
  • Schema changes and the Postgres lock queue — Xata Blog
  • pgroll — Zero-downtime, reversible, schema changes for PostgreSQL
  • Schema changes and the power of expand-contract with pgroll — Xata Blog
  • How to perform Postgres schema changes in production with zero downtime — Xata Blog
  • Applying migrations safely | Squawk — a linter for Postgres migrations
  • Top Open Source Postgres Migration Tools in 2026 | Bytebase
  • Database Migrations Without Drama: Expand/Contract in Practice
  • Database Migration Strategies for Zero-Downtime Deployments | DeployHQ
  • Change management tools and techniques — PostgreSQL Wiki
#PostgreSQL#Schema Migration#Expand-Contract#무중단 배포#DDL#CREATE INDEX CONCURRENTLY
Share

Table of Contents

Scenario 4: Adding an IndexWhich Strategy Should You Use? Decision FlowPros and ConsAdvantagesDisadvantages and CaveatsCommon Mistakes in PracticeTool ComparisonClosing ThoughtsReferences

Recommended Posts

Stripping out passwords and keeping only public keys: Implementing passkey registration and authentication with Node.js and SimpleWebAuthn, and gradually integrating it with an existing login system
Backend

Stripping out passwords and keeping only public keys: Implementing passkey registration and authentication with Node.js and SimpleWebAuthn, and gradually integrating it with an existing login system

Translating the document now, following the advisor's guidance on participant IDs in sequence diagrams and all other rules. Feeling honestly nervous w…

July 19, 202627 min read
Node.js 22 Permission Model in Practice — How to Minimize Process Privileges and Reduce Supply Chain Attack Surface with `--allow-fs-read`
Backend

Node.js 22 Permission Model in Practice — How to Minimize Process Privileges and Reduce Supply Chain Attack Surface with `--allow-fs-read`

Supply chain attacks in the npm ecosystem are on the rise. The moment a single package is compromised, it becomes hard to fully trust any of the hundr…

July 20, 202614 min read
One schema to rule them all: generating OpenAPI 3.1, gRPC Protobuf, and TypeScript clients simultaneously — A practical guide to TypeSpec 1.0
Backend

One schema to rule them all: generating OpenAPI 3.1, gRPC Protobuf, and TypeScript clients simultaneously — A practical guide to TypeSpec 1.0

This happened to me last year. The payments team renamed the settled at field to completedAt in the /payments/{id} response. There was a JIRA ticket,…

July 20, 202623 min read
Why You Can Remove `pg`, `ioredis`, and `@aws-sdk/client-s3` — Reducing Storage Driver Dependencies with Bun's Native Clients
Backend

Why You Can Remove `pg`, `ioredis`, and `@aws-sdk/client-s3` — Reducing Storage Driver Dependencies with Bun's Native Clients

Every time you start a new project, you type npm install pg ioredis @aws sdk/client s3 , wrestle with @types/ packages, chase down version conflicts,…

July 19, 202620 min read
Building a TypeScript REST API with Fastify v5 + TypeBox
Backend

Building a TypeScript REST API with Fastify v5 + TypeBox

Let's start with the code showing the structural problem that arises when writing an API with Express. The TypeScript type, runtime validation rules,…

July 19, 202615 min read
BullMQ 5 job queue for reliably handling Node.js async tasks — covering retry strategies, Dead Letter Queue, Sandboxed Processor, and Prometheus metrics integration
Backend

BullMQ 5 job queue for reliably handling Node.js async tasks — covering retry strategies, Dead Letter Queue, Sandboxed Processor, and Prometheus metrics integration

When building a user registration API, you eventually find yourself wondering: "Should the HTTP response really be blocked while a welcome email is be…

July 19, 202623 min read