Module 5: Zero-Downtime Migrations
`CREATE INDEX CONCURRENTLY` and other non-blocking operations
Capsule overview
You now master the expand-contract pattern (capsule 03). But there's one specific operation that shows up in almost every real expand-contract and that has a critical gotcha in Alembic: creating indexes on tables with active traffic. If you run CREATE INDEX directly, you take a SHARE lock on the table for seconds or minutes, which blocks all the writes (INSERT/UPDATE/DELETE) during that time. The solution is CREATE INDEX CONCURRENTLY, but it CANNOT run inside a transaction — and Alembic, by default, wraps every migration in a transaction. If you copy the pattern without understanding this, you're going to have a migration that fails with ERROR: CREATE INDEX CONCURRENTLY cannot run inside a transaction block right when you need it in production.
This capsule solves that gotcha in two ways (autocommit_block() and transaction_per_migration=False in env.py), and covers the rest of the non-blocking DDL operations you need to know: DROP INDEX CONCURRENTLY, REINDEX CONCURRENTLY, ALTER TABLE ... VALIDATE CONSTRAINT. You're also going to learn what to do if a CREATE INDEX CONCURRENTLY fails halfway (it leaves an invalid index) and how to clean it up.
By the end you'll have the reflexes to create indexes in production without blocking traffic, and you'll know how to configure Alembic correctly so these operations work with no surprises at deploy time.
Mental model: the index as a parallel guide built without getting in the way
Imagine the tasks table is a library and an index is the catalog list (books ordered by title). A direct CREATE INDEX is like closing the library, making the list in silence, and reopening when it finishes. Nobody consults books while it lasts.
CREATE INDEX CONCURRENTLY is like building the catalog while the library stays open: the librarian makes two passes (the first to record what exists, the second to capture any new book or change that occurred during the first pass). It takes more total time, but nobody notices their work. The readers keep consulting books, the librarians keep adding new ones.
The price of not getting in the way is:
- It's slower. Two passes vs one.
- It can't be in a transaction. The two passes need to be individually visible; a transaction would isolate both in an atomic unit that breaks the model.
- It can fail halfway. If the second pass finds an inconsistency (concurrent writes creating a temporary conflict), the index gets marked as invalid and needs a drop + retry.
Those three trade-offs are what you're going to manage in production. The capsule breaks them down with real code.
The concrete problem: CREATE INDEX on a live table
Let's see it on the migration_test table with 1M rows we created in capsule 02. Assume you need a new index on migration_test.title to speed up searches.
The blocking version (don't use in production)
CREATE INDEX idx_test_title ON migration_test(title);
What happens:
Lock taken: SHARE (on migration_test)
Conflicts with: ROW EXCLUSIVE (INSERT, UPDATE, DELETE)
Compatible with: ACCESS SHARE (SELECT)
Approximate time for 1M rows: 3-8 seconds. During that time:
- ✅ The SELECTs keep working.
- ❌ The INSERTs queue up.
- ❌ The UPDATEs queue up.
- ❌ The DELETEs queue up.
If your app is write-heavy (a CRM, e-commerce, audit logs), those seconds are visible to the customers: requests that normally take 50ms now take 5 seconds. If there are timeouts (typically 30s in HTTP), there are no errors. If there are shorter timeouts (the application, the load balancer), errors start firing.
The non-blocking version (the correct one)
CREATE INDEX CONCURRENTLY idx_test_title ON migration_test(title);
What happens:
Lock taken: SHARE UPDATE EXCLUSIVE (on migration_test)
Conflicts with: another SHARE UPDATE EXCLUSIVE (another CREATE INDEX CONCURRENTLY, VACUUM, ANALYZE)
Compatible with: ACCESS SHARE, ROW SHARE, ROW EXCLUSIVE
Approximate time for 1M rows: 5-15 seconds (slower). During that time:
- ✅ The SELECTs keep working.
- ✅ The INSERTs keep working.
- ✅ The UPDATEs keep working.
- ✅ The DELETEs keep working.
Zero impact on production traffic. The price is only more total time. In production, this is always the correct trade-off.
The Alembic gotcha: implicit transactions
If you try to write this "naive" migration:
# alembic/versions/00X_add_index_naive.py
def upgrade():
op.create_index(
"idx_test_title",
"migration_test",
["title"],
postgresql_concurrently=True, # ← what SQLAlchemy/Alembic offers for CONCURRENTLY
)
And you run it:
alembic upgrade head
You're going to see:
ERROR: CREATE INDEX CONCURRENTLY cannot run inside a transaction block
Why: Alembic, by default, wraps every migration in a transaction. The intent is safety: if the migration fails halfway, the automatic rollback leaves the DB in a consistent state. For almost all DDL, that's correct. But CREATE INDEX CONCURRENTLY is the explicit exception: PostgreSQL forbids running it inside a transaction because its two passes need to be individually visible.
There are two ways to solve it:
Solution 1: op.get_context().autocommit_block() (recommended for specific cases)
It lets you escape the transaction for a specific operation, keeping the rest of the migration transactional.
# alembic/versions/00X_add_index_concurrently.py
"""add concurrent index on migration_test.title
Revision ID: 00X_add_index
Revises: previous_revision
Create Date: 2026-05-02 16:00:00
"""
from alembic import op
revision = "00X_add_index"
down_revision = "previous_revision"
branch_labels = None
depends_on = None
def upgrade():
# Exit the transaction so CONCURRENTLY works.
# After the block, the normal transaction comes back.
with op.get_context().autocommit_block():
op.execute(
"CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_test_title "
"ON migration_test (title)"
)
def downgrade():
with op.get_context().autocommit_block():
op.execute("DROP INDEX CONCURRENTLY IF EXISTS idx_test_title")
Characteristics:
autocommit_block()isolates the operation. Only theCREATE INDEX CONCURRENTLYruns outside the transaction. If you had other DDL operations in the same migration, they could stay inside the transaction.IF NOT EXISTSfor idempotence. If the migration runs twice (a deploy with a retry), it doesn't fail the second time. Critical for production (capsule 06 goes deeper into idempotence).- A symmetric downgrade.
DROP INDEX CONCURRENTLYalso needs to be outside a transaction.
Solution 2: transaction_per_migration=False in env.py (for apps that create many indexes)
If your app creates CONCURRENTLY indexes frequently and you'd rather not write with autocommit_block() in every migration, you can disable the transactional wrapping globally.
# alembic/env.py
from alembic import context
def run_migrations_online() -> None:
"""Run migrations in 'online' mode."""
connectable = engine_from_config(
config.get_section(config.config_ini_section, {}),
prefix="sqlalchemy.",
poolclass=pool.NullPool,
)
with connectable.connect() as connection:
context.configure(
connection=connection,
target_metadata=target_metadata,
# The critical change: do NOT wrap each migration in a transaction
transaction_per_migration=False,
# The default was True (each migration in its own transaction).
)
with context.begin_transaction():
context.run_migrations()
run_migrations_online()
This option's trade-off:
- Pro: every migration can run
CREATE INDEX CONCURRENTLYwithoutautocommit_block(). - Con: if a migration fails halfway, there's no automatic rollback. Each migration is responsible for its own consistency. That demands very small migrations (ideally one operation per migration).
Recommendation: use autocommit_block() (Solution 1) unless your team has adopted atomic migrations as a convention. The block's explicitness makes it obvious to the reader that the operation is outside a transaction.
Other non-blocking operations you need to know
CREATE INDEX CONCURRENTLY isn't the only operation with a "concurrent" version. There are others that show up in production migrations.
DROP INDEX CONCURRENTLY
The same principle: dropping an index takes ACCESS EXCLUSIVE (blocking everything) in the normal version. With CONCURRENTLY, it takes SHARE UPDATE EXCLUSIVE.
def upgrade():
with op.get_context().autocommit_block():
op.execute("DROP INDEX CONCURRENTLY IF EXISTS idx_test_title")
When to use it: when you need to remove an unused index on a production table. The same trade-off (slower but non-blocking).
REINDEX CONCURRENTLY (PostgreSQL 12+)
Rebuilding an index (useful when it's bloated or corrupt) took ACCESS EXCLUSIVE in the normal version. Since PG 12, there's a CONCURRENTLY version:
def upgrade():
with op.get_context().autocommit_block():
op.execute("REINDEX INDEX CONCURRENTLY idx_test_title")
When to use it: when an index is corrupt (rare) or has a lot of bloat after massive UPDATEs/DELETEs. In production, avoid non-concurrent REINDEX.
ALTER TABLE ... VALIDATE CONSTRAINT
You saw it in capsule 03 with the NOT VALID trick. The idea is to add a CHECK constraint as NOT VALID (a short lock, it doesn't validate existing rows), and then VALIDATE it (a lighter lock than a direct SET NOT NULL).
-- Step 1: add it as NOT VALID (a short lock)
ALTER TABLE tasks ADD CONSTRAINT tasks_priority_positive
CHECK (priority >= 0) NOT VALID;
-- Step 2: validate (SHARE UPDATE EXCLUSIVE — compatible with writes and reads)
ALTER TABLE tasks VALIDATE CONSTRAINT tasks_priority_positive;
VALIDATE CONSTRAINT takes SHARE UPDATE EXCLUSIVE, just like CONCURRENTLY. It doesn't block traffic. That's what makes hardening constraints on large tables safe.
def upgrade():
op.execute(
"ALTER TABLE tasks ADD CONSTRAINT tasks_priority_positive "
"CHECK (priority >= 0) NOT VALID"
)
op.execute("ALTER TABLE tasks VALIDATE CONSTRAINT tasks_priority_positive")
(This operation does NOT require autocommit_block() because it can run in a transaction.)
ALTER TABLE ... ATTACH PARTITION
If you have partitioned tables (covered in guide #14), adding a new partition with CONCURRENTLY-like behavior is via ATTACH PARTITION. A short lock on the parent table.
ALTER TABLE tasks ATTACH PARTITION tasks_2026 FOR VALUES FROM ('2026-01-01') TO ('2027-01-01');
(Mentioned for completeness; it goes deeper in guide #14.)
What happens if CREATE INDEX CONCURRENTLY fails?
When CREATE INDEX CONCURRENTLY fails halfway (a timeout, a deadlock, a conflict in the second pass), PostgreSQL leaves the index marked as INVALID. The index exists in the catalog but doesn't get used for queries and doesn't protect constraints.
This is a subtle operational problem: the next day you try to run the migration again and you get:
ERROR: relation "idx_test_title" already exists
The IF NOT EXISTS saves you from the error... but the index that exists is INVALID, not valid. Your "successful" new migration created nothing useful.
Diagnosing invalid indexes
-- List every invalid index
SELECT
n.nspname AS schema,
c.relname AS table,
i.relname AS index_name
FROM pg_index x
JOIN pg_class c ON c.oid = x.indrelid
JOIN pg_class i ON i.oid = x.indexrelid
JOIN pg_namespace n ON n.oid = c.relnamespace
WHERE x.indisvalid = false;
Example output:
schema | table | index_name
--------+----------------+------------------------
public | migration_test | idx_test_title
Remediating: drop + retry
-- 1. Drop the invalid index
DROP INDEX CONCURRENTLY IF EXISTS idx_test_title;
-- 2. Recreate it
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_test_title ON migration_test (title);
A robust Alembic pattern
For production, write the migration so it handles the case of a pre-existing invalid index:
def upgrade():
with op.get_context().autocommit_block():
# Drop it if it exists and is invalid (it does no harm if it doesn't exist)
op.execute(
"""
DO $$
BEGIN
IF EXISTS (
SELECT 1 FROM pg_index x
JOIN pg_class i ON i.oid = x.indexrelid
WHERE i.relname = 'idx_test_title'
AND x.indisvalid = false
) THEN
EXECUTE 'DROP INDEX CONCURRENTLY idx_test_title';
END IF;
END $$;
"""
)
# Create it if it doesn't exist (valid or not)
op.execute(
"CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_test_title "
"ON migration_test (title)"
)
This pattern survives deploy retries where the first attempt failed leaving an invalid index.
Why does this matter in real work?
1. It's Alembic's second most common gotcha in production. The first is ADD COLUMN ... NOT NULL DEFAULT ... that rewrites (capsule 02). The second is CREATE INDEX CONCURRENTLY failing because of Alembic's transaction. Knowing how to solve it saves you a failed deploy iteration.
2. Any non-trivial expand-contract requires new indexes. When you add a column and the app is going to query by it, you need an index. That index gets created with CONCURRENTLY. If you don't know the pattern, it becomes blocking.
3. Index maintenance is the senior backend engineer's responsibility. Indexes become obsolete (queries change), bloated (lots of UPDATE/DELETE), or redundant (duplicates). Cleaning them up in production requires DROP CONCURRENTLY. Without this capsule, you're going to postpone the cleanup out of fear, and accumulate technical debt.
4. It shows up in code review constantly. PRs with op.create_index() without postgresql_concurrently=True are one of the easiest patterns to spot in code review. Having the reflex and being able to explain the why (the transaction block) makes your seniority obvious.
Traps and common mistakes
Mistake 1 (critical operational): copying the code without autocommit_block()
Symptom: the migration fails at deploy with CREATE INDEX CONCURRENTLY cannot run inside a transaction block. You've lost deploy time and you have to redo it.
Why it happens: Alembic wraps every migration in a transaction by default. The dev doesn't know about the gotcha and writes the migration "like any other."
How to tell: any migration using CONCURRENTLY (in CREATE INDEX, DROP INDEX, REINDEX) MUST have autocommit_block() or a modified env.py. If not, it's going to fail.
How to fix it: wrap the operation in with op.get_context().autocommit_block():. There's no case where you don't need this (unless you configured transaction_per_migration=False in env.py for the whole project).
Mistake 2 (conceptual): thinking "a short lock" equals "it doesn't block"
Symptom: a dev measures that CREATE INDEX (non-concurrent) takes 2 seconds locally. They decide to run it in production arguing "it's only 2 seconds." In production the table is 10x bigger, it takes 20 seconds. During those 20 seconds, thousands of INSERTs queue up.
Why it happens: the intuition of "2 seconds doesn't matter" is misleading when there's continuous traffic. An API receiving 100 INSERTs per second is going to have 2000 INSERTs queued by the end of the 20 seconds. Those 2000 requests got 20 seconds of latency.
How to tell: any operation taking a lock more restrictive than SHARE UPDATE EXCLUSIVE (CONCURRENTLY, normal VACUUM) has to be used in production. Non-concurrent CREATE INDEX takes SHARE, which blocks writes. Unviable for apps with constant writes.
How to fix it: a simple rule: CREATE INDEX always with CONCURRENTLY in production. Except in initial migrations over an empty table.
Mistake 3 (operational): not handling the invalid index case
Symptom: the "successful" migration didn't create the expected index. The query that depended on the index is still slow. Investigating, you discover the index is marked INVALID and the CREATE INDEX CONCURRENTLY IF NOT EXISTS didn't recreate it because "it already exists."
Why it happens: a previous CREATE CONCURRENTLY attempt failed (a timeout, a deadlock, a statement_timeout) and left the index invalid. The new run detects the name exists and skips.
How to tell: after every migration that creates an index, verify with the "invalid indexes" query (shown above). If you find invalid ones, they're latent bugs.
How to fix it: use the "drop if invalid + create" pattern shown above. Or, alternatively, monitor pg_index.indisvalid in healthchecks and alert.
Mistake 4 (conceptual): using IF EXISTS / IF NOT EXISTS without understanding idempotence
Symptom: a dev adds IF NOT EXISTS "just because," thinking it's a best practice. But the migration has several steps and the IF NOT EXISTS masks a badly-done intermediate state.
Why it happens: confusion between "idempotent" (re-running produces the same final result) and "tolerant of a partial state" (it doesn't fail if something is already created). The first is desirable, the second can hide bugs.
How to tell: IF NOT EXISTS is fine for individual operations (CREATE INDEX, ADD COLUMN). It's wrong when it masks the fact that the migration should verify consistency before continuing.
How to fix it: use IF NOT EXISTS only for genuine idempotence. For state checks, use DO $$ ... $$ blocks with explicit logic (like the "drop if invalid + create" example).
Mistake 5 (conceptual): assuming CONCURRENTLY is always better
Symptom: a dev uses CONCURRENTLY even in an initial migration over an empty table. The migration takes 30 seconds when the normal version would have taken 1 second.
Why it happens: the "always concurrent" reflex doesn't distinguish contexts. CONCURRENTLY is for tables with active traffic. On an empty table or in an initial setup, there's no traffic to protect.
How to tell: is the table empty or nearly empty? Are you in an initial migration where the app hasn't been deployed yet? If so, a normal CREATE INDEX is correct and faster.
How to fix it: CONCURRENTLY for tables with production traffic. Normal for initialization or over empty tables.
Exercises
Exercise 1: identify and fix a migration that fails from the gotcha
This migration fails at deploy. Identify the cause and propose a fix.
# alembic/versions/wrong_concurrent_index.py
def upgrade():
op.create_index(
"idx_tasks_priority_concurrent",
"tasks",
["priority"],
postgresql_concurrently=True,
)
op.execute("ANALYZE tasks") # update the stats after the index
See solution
The cause of the failure:
op.create_index(..., postgresql_concurrently=True) generates CREATE INDEX CONCURRENTLY, but Alembic wraps the migration in a transaction by default. PostgreSQL rejects it with CREATE INDEX CONCURRENTLY cannot run inside a transaction block.
Additionally, ANALYZE also can't run inside a transaction (technically it can, but it shares the transaction with the failing CREATE).
The corrected version:
def upgrade():
# Take it out of the transaction for CONCURRENTLY
with op.get_context().autocommit_block():
op.execute(
"CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_tasks_priority_concurrent "
"ON tasks (priority)"
)
op.execute("ANALYZE tasks")
def downgrade():
with op.get_context().autocommit_block():
op.execute("DROP INDEX CONCURRENTLY IF EXISTS idx_tasks_priority_concurrent")
Notes:
- I used
op.execute("CREATE INDEX CONCURRENTLY ...")directly instead ofop.create_index(..., postgresql_concurrently=True). Both work, butop.executewith raw SQL makes it explicit in the code that it's CONCURRENTLY (easier for code review). - I added
IF NOT EXISTSfor idempotence. - The
downgradealso needsautocommit_blockbecause DROP INDEX CONCURRENTLY also forbids a transaction.
Exercise 2: configure Alembic to auto-detect invalid indexes
Implement a scripts/check_invalid_indexes.py script that runs as part of the CI pipeline. It has to query pg_index and fail (exit code != 0) if it finds invalid indexes in the DB.
See solution
# scripts/check_invalid_indexes.py
"""Verifies there are no invalid indexes in the DB.
Runs as part of the CI/CD pipeline to detect
CONCURRENTLY indexes that failed silently.
"""
import os
import sys
import psycopg2
DATABASE_URL = os.environ.get("DATABASE_URL")
if not DATABASE_URL:
print("ERROR: DATABASE_URL is not set", file=sys.stderr)
sys.exit(1)
def main() -> int:
conn = psycopg2.connect(DATABASE_URL)
try:
with conn.cursor() as cur:
cur.execute(
"""
SELECT
n.nspname AS schema_name,
c.relname AS table_name,
i.relname AS index_name
FROM pg_index x
JOIN pg_class c ON c.oid = x.indrelid
JOIN pg_class i ON i.oid = x.indexrelid
JOIN pg_namespace n ON n.oid = c.relnamespace
WHERE x.indisvalid = false
AND n.nspname NOT IN ('pg_catalog', 'information_schema')
ORDER BY n.nspname, c.relname, i.relname
"""
)
invalid_indexes = cur.fetchall()
finally:
conn.close()
if not invalid_indexes:
print("OK: 0 invalid indexes detected")
return 0
print(f"ERROR: {len(invalid_indexes)} invalid index(es) detected:")
for schema, table, index in invalid_indexes:
print(f" - {schema}.{table} -> {index}")
print()
print("Recommended action: drop the invalid index and recreate it:")
for schema, table, index in invalid_indexes:
print(
f" DROP INDEX CONCURRENTLY {schema}.{index};\n"
f" -- Then recreate it according to the original migration"
)
return 1
if __name__ == "__main__":
sys.exit(main())
Usage in CI (a GitHub Actions example):
# .github/workflows/ci.yml
- name: Check invalid indexes
env:
DATABASE_URL: ${{ secrets.DATABASE_URL }}
run: python scripts/check_invalid_indexes.py
Why it works:
- The query uses
pg_index.indisvalid = false, which is PostgreSQL's internal marker for indexes that got created partially. - It filters out the system schemas (
pg_catalog,information_schema). - If it finds invalid indexes, it returns exit code 1, failing the pipeline.
- The message includes the exact commands to remediate.
When to run it:
- On every deploy to staging (post-migration).
- On every deploy to production (post-migration).
- Periodically (a cron) to detect indexes that got invalidated by operational causes (e.g. the fallback of some maintenance job).
Exercise 3: implement the robust "drop if invalid + create" pattern
Write an Alembic migration that creates the index idx_tasks_tenant_priority on tasks(tenant_id, priority) idempotently and robustly against deploy retries. If a previous attempt left an invalid index, it has to clean it up and recreate.
See solution
# alembic/versions/00X_add_tasks_tenant_priority_index.py
"""add idx_tasks_tenant_priority CONCURRENTLY (idempotent)
Revision ID: 00X_tasks_tenant_priority_idx
Revises: previous_revision
Create Date: 2026-05-02 17:00:00
"""
from alembic import op
revision = "00X_tasks_tenant_priority_idx"
down_revision = "previous_revision"
branch_labels = None
depends_on = None
INDEX_NAME = "idx_tasks_tenant_priority"
TABLE_NAME = "tasks"
COLUMNS = "(tenant_id, priority)"
def upgrade():
with op.get_context().autocommit_block():
# Step 1: if it exists and is invalid, drop it first.
# This handles the case of a retry after a previous failure.
op.execute(
f"""
DO $$
BEGIN
IF EXISTS (
SELECT 1 FROM pg_index x
JOIN pg_class i ON i.oid = x.indexrelid
WHERE i.relname = '{INDEX_NAME}'
AND x.indisvalid = false
) THEN
RAISE NOTICE 'Dropping invalid index {INDEX_NAME}';
EXECUTE 'DROP INDEX CONCURRENTLY {INDEX_NAME}';
END IF;
END $$;
"""
)
# Step 2: create the index if it doesn't exist (valid or not).
# IF NOT EXISTS makes the operation idempotent for retries.
op.execute(
f"""
CREATE INDEX CONCURRENTLY IF NOT EXISTS {INDEX_NAME}
ON {TABLE_NAME} {COLUMNS}
"""
)
# Step 3: update the statistics so the query planner
# uses the new index from the start.
op.execute(f"ANALYZE {TABLE_NAME}")
# Step 4: post-creation check: the index has to be valid.
# If for some reason it got created invalid, fail the migration.
result = op.get_bind().execute(
sa.text(
"""
SELECT x.indisvalid
FROM pg_index x
JOIN pg_class i ON i.oid = x.indexrelid
WHERE i.relname = :name
"""
),
{"name": INDEX_NAME},
).scalar()
if result is None:
raise RuntimeError(f"Index {INDEX_NAME} was not created correctly")
if result is False:
raise RuntimeError(
f"Index {INDEX_NAME} was created but is marked INVALID. "
f"Investigate pg_stat_activity for the cause of the failure."
)
def downgrade():
with op.get_context().autocommit_block():
op.execute(f"DROP INDEX CONCURRENTLY IF EXISTS {INDEX_NAME}")
Why it's robust:
- It cleans up a pre-existing invalid index: if a previous deploy failed leaving an invalid index, the
DO $$ ... $$drops it before trying to recreate. IF NOT EXISTSfor concurrency: if two pods try to run the migration at the same time (it shouldn't happen but as defense), the second skips with no error.- A post-creation
ANALYZE: the statistics get refreshed so the planner starts considering the new index in future plans. - A final check: if the index got created but ended up invalid (rare but possible), the migration fails explicitly. This prevents it from going unnoticed.
Operational notes:
- It needs
import sqlalchemy as saat the top for thesa.text(). - In production, this pattern makes each index migration a bit longer (extra checks) in exchange for robustness.
Exercise 4: use VALIDATE CONSTRAINT instead of a direct SET NOT NULL
On the tasks table with 30M rows, you need to make the priority column NOT NULL (assuming the backfill already completed and every row has a value). A direct ALTER TABLE tasks ALTER COLUMN priority SET NOT NULL takes a prolonged lock from the full scan. Rewrite the operation using the NOT VALID + VALIDATE trick.
See solution
# alembic/versions/00X_priority_set_not_null_with_validate.py
"""make tasks.priority NOT NULL using NOT VALID + VALIDATE trick
Revision ID: 00X_priority_not_null
Revises: previous_revision
"""
from alembic import op
revision = "00X_priority_not_null"
down_revision = "previous_revision"
def upgrade():
op.execute("SET lock_timeout = '5s'")
# Step 1: add the CHECK constraint as NOT VALID — a brief ACCESS EXCLUSIVE lock.
# PostgreSQL accepts the constraint without checking existing rows (it's metadata-only).
op.execute(
"""
ALTER TABLE tasks
ADD CONSTRAINT tasks_priority_not_null_check
CHECK (priority IS NOT NULL) NOT VALID
"""
)
# Step 2: validate the constraint — a SHARE UPDATE EXCLUSIVE lock (compatible with writes).
# PostgreSQL scans the table but does NOT block reads or writes.
# If it finds a row with priority IS NULL, the operation fails.
# This is the step that takes time (proportional to the size), but without blocking.
op.execute("ALTER TABLE tasks VALIDATE CONSTRAINT tasks_priority_not_null_check")
# Step 3: now that the constraint is validated, SET NOT NULL is metadata-only.
# PostgreSQL already knows the property holds, it just records it as NOT NULL.
# A brief lock.
op.alter_column("tasks", "priority", nullable=False)
# Step 4: the CHECK constraint is now redundant (NOT NULL implies the check).
# We drop it for cleanliness.
op.execute("ALTER TABLE tasks DROP CONSTRAINT tasks_priority_not_null_check")
def downgrade():
# Going back to nullable is metadata-only (a brief lock).
op.execute("SET lock_timeout = '5s'")
op.alter_column("tasks", "priority", nullable=True)
Why this pattern is better than a direct SET NOT NULL:
| Operation | Lock | Time on a 30M table | Blocks traffic |
|---|---|---|---|
Direct ALTER COLUMN ... SET NOT NULL | ACCESS EXCLUSIVE | 30-60s | YES, the whole time |
| The NOT VALID + VALIDATE trick | SHARE UPDATE EXCLUSIVE during VALIDATE | 30-60s for the VALIDATE + ms for the SET NOT NULL | NO (only the last step takes a brief lock) |
The total time is similar, but the lock's distribution is radically different. In the trick, the heavy step (VALIDATE) uses a lock compatible with traffic. Only the short step (SET NOT NULL) takes an exclusive lock. The app keeps serving throughout the whole process.
Important: this pattern assumes the backfill already completed and SELECT COUNT(*) FROM tasks WHERE priority IS NULL is 0. If not, the VALIDATE is going to fail (PostgreSQL rejects the constraint because it finds rows that don't satisfy it).
Exercise 5: drop an unused index in production
Your monitoring (pg_stat_user_indexes) shows the index idx_tasks_old_status hasn't been used in 6 months. You want to drop it to reduce maintenance overhead (every UPDATE/INSERT updates every index). The table has 100M rows, in 24/7 production. Design the migration.
See solution
# alembic/versions/00X_drop_unused_idx_tasks_old_status.py
"""drop unused index idx_tasks_old_status (CONCURRENTLY)
Revision ID: 00X_drop_old_status_idx
Revises: previous_revision
Create Date: 2026-05-02 18:00:00
Justification:
pg_stat_user_indexes shows this index hasn't been used in 6 months.
Check with:
SELECT idx_scan FROM pg_stat_user_indexes
WHERE indexrelname = 'idx_tasks_old_status';
Result: 0 (in the last 180 days).
"""
from alembic import op
revision = "00X_drop_old_status_idx"
down_revision = "previous_revision"
INDEX_NAME = "idx_tasks_old_status"
def upgrade():
# A direct DROP INDEX takes ACCESS EXCLUSIVE — it blocks EVERYTHING during the drop.
# CONCURRENTLY takes SHARE UPDATE EXCLUSIVE — compatible with traffic.
with op.get_context().autocommit_block():
op.execute(f"DROP INDEX CONCURRENTLY IF EXISTS {INDEX_NAME}")
def downgrade():
# Recreate the index: also CONCURRENTLY so as not to block.
# Assumes we know the original definition.
with op.get_context().autocommit_block():
op.execute(
f"CREATE INDEX CONCURRENTLY IF NOT EXISTS {INDEX_NAME} "
"ON tasks (status) "
"WHERE deleted_at IS NULL" # Assuming it was a partial index
)
Pre-deploy validation (not part of the migration):
Before merging this migration, run in staging and production:
-- 1. Confirm the index isn't used
SELECT
schemaname,
relname AS table,
indexrelname AS index,
idx_scan,
idx_tup_read,
idx_tup_fetch
FROM pg_stat_user_indexes
WHERE indexrelname = 'idx_tasks_old_status';
-- Expected: idx_scan = 0 or very low
-- 2. Confirm there are no foreign keys or constraints depending on the index
SELECT conname, contype
FROM pg_constraint
WHERE conindid = (
SELECT oid FROM pg_class WHERE relname = 'idx_tasks_old_status'
);
-- Expected: 0 rows
If the validation passes, merge and deploy. If not, do NOT drop (there may be dependencies).
An additional safety pattern: instead of dropping directly, some teams prefer marking the index as INVISIBLE (PG 14+ doesn't support it natively, but there are workarounds). This leaves the index "disabled" for weeks to confirm there are no regressions, and then drops it for good. For this capsule, we assume a direct drop with confidence in the metrics.
Summary and next step
In this capsule you learned:
CREATE INDEX CONCURRENTLYtakesSHARE UPDATE EXCLUSIVE, compatible with reads and writes. It's the "non-blocking" version for production.- CONCURRENTLY can't run inside a transaction. Alembic wraps every migration in a transaction by default. You have to escape with
op.get_context().autocommit_block()or configuretransaction_per_migration=Falseglobally. - The most common gotcha: copying
op.create_index(..., postgresql_concurrently=True)withoutautocommit_block()produces the errorcannot run inside a transaction blockat deploy. DROP INDEX CONCURRENTLYandREINDEX CONCURRENTLYfollow the same non-blocking pattern.ALTER TABLE ... VALIDATE CONSTRAINTis the other key operation: combined withADD CONSTRAINT ... NOT VALID, it lets you harden constraints on large tables without a prolonged lock.- If CONCURRENTLY fails, it leaves an invalid index that
IF NOT EXISTSdoesn't detect. You have to drop and recreate, or monitorpg_index.indisvalid. - The correct reflex:
CREATE INDEXalways with CONCURRENTLY in production, except in an initial migration over an empty table.
Before moving on you should be able to:
- Write an Alembic migration that uses
autocommit_block()for CREATE INDEX CONCURRENTLY correctly. - Decide between a targeted
autocommit_block()or a globaltransaction_per_migration=False. - Diagnose and clean up invalid indexes with queries over
pg_index. - Apply the "drop if invalid + create" pattern, robust against retries.
- Use the NOT VALID + VALIDATE trick for SET NOT NULL on large tables.
- Articulate why CONCURRENTLY takes more total time but less lock per unit of time.
Next capsule — lock_timeout and statement_timeout in migrations. You've learned to do non-blocking DDL operations. But even CONCURRENTLY can hang if there's another active lock on the table. Capsule 05 teaches you the defensive reflex of setting timeouts at the start of every dangerous migration: if for some reason the operation can't take the lock it needs, fail fast instead of hanging. It's what separates migrations that break "cleanly" (an immediate rollback) from migrations that hang deploys for hours.
Resources
- PostgreSQL — CREATE INDEX CONCURRENTLY — the official documentation, including what happens when it fails halfway.
- Alembic — autocommit_block — the exact API for the auto-commit block in Alembic.
- PostgreSQL — Monitoring index usage — queries for detecting unused or invalid indexes.
- Crunchy Data — Index maintenance in PostgreSQL — an operational guide on index maintenance at scale.
- GitLab — Adding indexes safely — GitLab's playbook for adding indexes in production.
- Strong Migrations — Adding indexes — the most common anti-patterns in CREATE INDEX.
Module 5 — SQL Patterns for Production APIs Guide
Next capsule: lock_timeout and statement_timeout in migrations — the defensive reflex for making migrations fail fast instead of hanging deploys.