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.
- A user runs a slow reporting query that takes 30 seconds. This query holds an
AccessShareLock. - Your deployment starts and runs
ALTER TABLE orders ADD COLUMN discount_code text;. This request asks for anAccessExclusiveLock. - Postgres puts the migration in the lock queue. It must wait for the reporting query to finish.
- While the migration is waiting, new web requests come in trying to read from the
orderstable. They requestAccessShareLock. - Postgres blocks these new read requests because they are behind the migration's
AccessExclusiveLockrequest 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:
- Add the column as nullable without a default.
- Set the default value for future rows.
- 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
squawkorpg-index-healthcan scan your SQL migrations in CI/CD and reject pull requests that contain unsafe operations like rawADD COLUMN NOT NULLorCREATE INDEXwithoutCONCURRENTLY. - Always set lock timeouts in your migration runner. Ensure your migration tool (ActiveRecord, Knex, Alembic, Flyway) is configured to run
SET lock_timeoutbefore 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.



