Karya Semi
HomeBlogSearchCategoriesAboutContact
Karya Semi

Less noise. More notes.

HomeBlogAboutContactPrivacy PolicyDisclaimer

© 2026 Karya Semi. All rights reserved.

XGitHubLinkedIn
  1. Home
  2. /Categories
  3. /Technology

Zero-Downtime Schema Migrations: Designing PostgreSQL Databases for Continuous Deployment

Deploy schema changes to your production postgresql database without locking tables. Master safe column additions, type alterations, and index building.

Dian Rijal Asyrof/August 6, 2026/7 min read
Illustration for Zero-Downtime Schema Migrations: Designing PostgreSQL Databases for Continuous Deployment

We have all been there. You deploy a minor feature, the CI/CD pipeline runs the database migrations, and suddenly the alerts start firing. The application isn't responding, connection pools are exhausted, and the database CPU is pinned at 100%.

You only added a single column to a table. How did this happen?

The culprit is almost always database locking. PostgreSQL is incredibly reliable, but its locking mechanism can be unforgiving if you don't design your migrations around it. In a highly active database, a simple schema change can queue up behind a slow query, block all subsequent traffic, and bring your entire application down. Similar to how large databases require massively parallel Postgres backups to avoid performance degradation during maintenance, migrations must be carefully orchestrated to prevent locking issues.

Running migrations without downtime requires understanding how Postgres locks resources, how to write migrations that avoid heavy locks, and how to safely execute them under load. Implementing a solid production database migration strategy is key to keeping your application online.


The Silent Killer: Lock Queue Blocking

To understand zero-downtime migrations, you have to understand the lock queue.

Every SQL statement in Postgres acquires a lock on the table it operates on. These locks have different levels of strength. A simple SELECT query acquires an AccessShareLock. A schema change like ALTER TABLE typically requires an AccessExclusiveLock.

An AccessExclusiveLock blocks everything. It blocks writes, and it blocks reads.

But the real danger isn't just the time it takes to run the migration. The danger is the queue. Postgres processes lock requests in the order they arrive.

Imagine you have a table called orders.

  1. A user runs a slow reporting query that takes 30 seconds. This query holds an AccessShareLock.
  2. Your deployment starts and runs ALTER TABLE orders ADD COLUMN discount_code text;. This request asks for an AccessExclusiveLock.
  3. Postgres puts the migration in the lock queue. It must wait for the reporting query to finish.
  4. While the migration is waiting, new web requests come in trying to read from the orders table. They request AccessShareLock.
  5. Postgres blocks these new read requests because they are behind the migration's AccessExclusiveLock request in the queue.

Within seconds, your application connection pool fills up with blocked web requests. The site goes down, even though the migration hasn't actually started running yet.


Setting Safe Timeouts

The first line of defense against lock queue catastrophes is setting timeouts. By default, Postgres will wait forever to acquire a lock, and it will let queries run forever. This is dangerous in production.

Before running any migration, you must set a lock timeout. This tells Postgres: "If you cannot get this lock within two seconds, abort the migration and let the application keep running."

You can set this at the session level right before your migration code:

/* Set the lock timeout to 2 seconds */
SET lock_timeout = '2s';
 
/* Set the statement timeout to 5 seconds */
SET statement_timeout = '5s';
 
/* Run your migration */
ALTER TABLE users ADD COLUMN bio text;

If the migration cannot acquire the lock within two seconds, it fails safely, the transaction rolls back, and your application continues serving traffic. Your deployment pipeline might fail, but your users won't notice a thing. You can simply retry the deployment during a quieter period.


Adding Columns Safely

Adding a column is the most common migration you will write. Depending on your Postgres version and how you configure the column, this can be safe or highly dangerous.

Nullable Columns

If you are on Postgres 11 or newer, adding a nullable column without a default value is fast. Postgres only updates the system catalog. It does not rewrite the table.

SET lock_timeout = '2s';
ALTER TABLE users ADD COLUMN middle_name text;

This is safe to run on active tables.

Columns with Defaults

Historically, adding a column with a default value forced Postgres to rewrite the entire table to write the default value to every existing row. On a table with millions of rows, this took minutes and locked the table the entire time.

Since Postgres 11, adding a column with a constant default value is safe. Postgres stores the default value in the catalog and applies it dynamically when rows are read.

However, if your default value uses a volatile function (like clock_timestamp() or a custom function), Postgres still must rewrite the table.

If you need to add a column with a volatile default on an active table, do it in steps:

  1. Add the column as nullable without a default.
  2. Set the default value for future rows.
  3. Update the existing rows in small batches.
/* Step 1: Add the column */
ALTER TABLE orders ADD COLUMN created_at_utc timestamptz;
 
/* Step 2: Set the default for new rows */
ALTER TABLE orders ALTER COLUMN created_at_utc SET DEFAULT now();
 
/* Step 3: Backfill existing rows in batches (run this in a script, not a single transaction) */
/* UPDATE orders SET created_at_utc = created_at WHERE id BETWEEN 1 AND 10000; */

Adding NOT NULL Constraints

Adding a NOT NULL constraint to an existing column requires Postgres to scan the entire table to verify that no null values exist. This scan holds an AccessExclusiveLock and blocks writes.

The safe way to do this uses a check constraint:

/* 1. Add the constraint as NOT VALID. This avoids scanning the table immediately. */
ALTER TABLE users ADD CONSTRAINT check_email_not_null CHECK (email IS NOT NULL) NOT VALID;
 
/* 2. Validate the constraint. Postgres scans the table, but uses a weaker lock that allows writes. */
ALTER TABLE users VALIDATE CONSTRAINT check_email_not_null;
 
/* 3. (Optional) Replace with a standard NOT NULL constraint. */
/* Because the check constraint proved the data is clean, this is fast. */
ALTER TABLE users ALTER COLUMN email SET NOT NULL;
ALTER TABLE users DROP CONSTRAINT check_email_not_null;

Creating Indexes Without Blocking Writes

A standard CREATE INDEX statement locks the table against writes until the index is built. If you have a table with ten million rows, this can take several minutes. Your application won't be able to insert, update, or delete rows during this time.

To avoid this, always use CONCURRENTLY.

CREATE INDEX CONCURRENTLY idx_users_email ON users(email);

When you build an index concurrently, Postgres performs two scans of the table instead of one. It does not lock out writes.

But there is a catch. Concurrent index builds cannot run inside a transaction block. If your migration tool wraps all migrations in transactions by default, you must disable transaction wrapping for that specific migration.

Handling Failed Indexes

Because concurrent index builds do not lock the table, they can fail if write operations violate unique constraints or if the database resource limits are hit.

When a concurrent index build fails, Postgres leaves behind an "invalid" index. This invalid index does not help queries, but it still gets updated during writes, slowing down your database.

You can find invalid indexes with this query:

SELECT relname, indisvalid 
FROM pg_class c 
JOIN pg_index i ON c.oid = i.indexrelid 
WHERE indisvalid = false;

If you find an invalid index, you must drop it and try again:

DROP INDEX CONCURRENTLY idx_users_email;

The Migration Dance: Changing Column Types

Changing the data type of a column is one of the hardest migrations to run without downtime. A naive ALTER TABLE users ALTER COLUMN id TYPE bigint; forces Postgres to rewrite the entire table and all its indexes, holding an exclusive lock the entire time. If you are converting a primary key from an integer to a bigint because you are running out of IDs, this will cause an outage.

To change a column type safely, you must perform a multi-step migration dance over several deployments.

Step 1: Add a new column with the new type

Add a nullable column with the new data type. This is fast and does not block writes.

ALTER TABLE payments ADD COLUMN amount_new numeric(12, 2);

Step 2: Dual-write to both columns

Update your application code so that every new insert or update writes to both the old and new columns.

If you cannot update the application code easily, you can use a database trigger to handle the dual-writes:

CREATE OR REPLACE FUNCTION sync_payments_amount()
RETURNS TRIGGER AS $$
BEGIN
  NEW.amount_new := NEW.amount;
  RETURN NEW;
END;
$$ LANGUAGE plpgsql;
 
CREATE TRIGGER trigger_sync_payments_amount
BEFORE INSERT OR UPDATE ON payments
FOR EACH ROW EXECUTE FUNCTION sync_payments_amount();

Step 3: Backfill the old data

Now you need to copy the historical data from the old column to the new column. Do not do this in a single query. Running UPDATE payments SET amount_new = amount; will lock the table and bloat your database.

Instead, write a script to update the rows in batches, pausing briefly between batches to let the database breathe.

/* Run this iteratively in your backfill script */
UPDATE payments 
SET amount_new = amount 
WHERE id BETWEEN 1 AND 10000 
  AND amount_new IS NULL;

Step 4: Cut over to the new column

Once the backfill is complete, update your application code to read from the new column and write only to the new column. Once the code is deployed, you can safely drop the database trigger.

Step 5: Clean up the old column

Do not drop the old column immediately. Keep it for a few days to ensure no bugs crop up. Once you are confident, drop the old column to reclaim space.

ALTER TABLE payments DROP COLUMN amount;

This process takes longer and requires multiple deployments, but it keeps your service running the entire time. If you are modifying tables that store user information, keep in mind that compliance requirements like California's DROP data deletion law might dictate how you handle and purge user records.


Safely Adding Foreign Keys

Adding a foreign key constraint normally locks both the referencing table and the referenced table to validate that all existing IDs match. On large tables, this can take a long time.

You can use the same NOT VALID trick we used for check constraints to bypass the initial lock.

/* 1. Add the foreign key without validating it */
ALTER TABLE orders 
ADD CONSTRAINT fk_orders_user_id 
FOREIGN KEY (user_id) REFERENCES users(id) 
NOT VALID;
 
/* 2. Validate the constraint later */
ALTER TABLE orders VALIDATE CONSTRAINT fk_orders_user_id;

The validation step still scans the table to verify the data, but it does so under a weaker lock (ShareUpdateExclusiveLock), which allows your application to keep writing to the table.


Best Practices for Deployment Pipelines

Writing safe SQL is only half the battle. You also need to enforce these practices in your team and deployment pipelines.

  • Use migration linters. Tools like squawk or pg-index-health can scan your SQL migrations in CI/CD and reject pull requests that contain unsafe operations like raw ADD COLUMN NOT NULL or CREATE INDEX without CONCURRENTLY.
  • Always set lock timeouts in your migration runner. Ensure your migration tool (ActiveRecord, Knex, Alembic, Flyway) is configured to run SET lock_timeout before executing migrations.
  • Keep migrations small. Do not combine five schema changes into one migration file. If one step fails or takes too long, the entire transaction rolls back, and you waste time debugging which step caused the lock queue to back up.
  • Monitor pg_stat_activity. During deployments, keep an eye on active queries. If you see a migration waiting for a lock, you can manually terminate the blocking query or abort the migration to prevent a cascade of failures. If you are optimizing your deployment pipelines, make sure to review your GitHub Actions parallel steps to ensure shared state doesn't cause issues.

Zero-downtime migrations require a shift in how you think about database schemas. Instead of viewing migrations as single, destructive events, treat them as gradual transitions. It takes more planning, but your users will thank you for the 100% uptime.

DR

Dian Rijal Asyrof

Writes about useful AI tools, programming practice, and the craft of building reliable software.

Previous articleStablecoin Velocity: The True Metric of On-Chain Adoption We're IgnoringNext articleOptimizing P99 Database Latency: Techniques Beyond Simply Adding Indexes
PostgresqlDatabasesDevOpsSoftware Engineering
On this page↓
  1. The Silent Killer: Lock Queue Blocking
  2. Setting Safe Timeouts
  3. Adding Columns Safely
  4. Nullable Columns
  5. Columns with Defaults
  6. Adding NOT NULL Constraints
  7. Creating Indexes Without Blocking Writes
  8. Handling Failed Indexes
  9. The Migration Dance: Changing Column Types
  10. Step 1: Add a new column with the new type
  11. Step 2: Dual-write to both columns
  12. Step 3: Backfill the old data
  13. Step 4: Cut over to the new column
  14. Step 5: Clean up the old column
  15. Safely Adding Foreign Keys
  16. Best Practices for Deployment Pipelines

On this page

  1. The Silent Killer: Lock Queue Blocking
  2. Setting Safe Timeouts
  3. Adding Columns Safely
  4. Nullable Columns
  5. Columns with Defaults
  6. Adding NOT NULL Constraints
  7. Creating Indexes Without Blocking Writes
  8. Handling Failed Indexes
  9. The Migration Dance: Changing Column Types
  10. Step 1: Add a new column with the new type
  11. Step 2: Dual-write to both columns
  12. Step 3: Backfill the old data
  13. Step 4: Cut over to the new column
  14. Step 5: Clean up the old column
  15. Safely Adding Foreign Keys
  16. Best Practices for Deployment Pipelines

See also

Illustration for Massively Parallel Postgres Backups: How to Stop Dreading Your Backup Window
Software Engineering/Aug 4, 2026

Massively Parallel Postgres Backups: How to Stop Dreading Your Backup Window

PlanetScale just published their approach to parallelizing Postgres backups. Here's what that pattern looks like and how teams with large databases can apply it.

6 min read
Software EngineeringPostgres
Illustration for Choosing a Database Migration Strategy for Production Teams
Technology/Jul 20, 2026

Choosing a Database Migration Strategy for Production Teams

Compare expand-and-contract migrations, backfills, locks, rollback limits, monitoring, and release sequencing for safer production database changes.

5 min read
DatabasesMigrations
Illustration for GitHub's Advisory Database Hit 1,560 CVEs in May. Here's Why That Matters.
Software Engineering/Jun 30, 2026

GitHub's Advisory Database Hit 1,560 CVEs in May. Here's Why That Matters.

GitHub's Advisory Database processed 5x its normal volume in May. Private vulnerability reports jumped from 550 to 3,000 per week. Here's the impact and how teams should respond.

3 min read
Software EngineeringSecurity