Setting lock_timeout and Retrying DDL Safely

You have decided that no migration should be able to freeze a table by queueing for a lock, and now you have to make that true in the tooling you actually use. The difficulty is not the SQL — SET lock_timeout = '2s' is one line — but where that line lives, how it interacts with the migration tool’s own transaction handling, and what happens when it fires. A timeout without a retry turns every busy moment into a failed deploy; a retry that catches too much turns a real error into a loop. This guide builds the combination that works: a timeout scoped to the migration session, and a runner that retries exactly one error class with bounded, jittered backoff. It implements the procedure from DDL Lock Management & Timeouts for the common toolchains.

Where Should the Timeout Live? Decision tree. If the tool runs each migration file in one transaction, use SET LOCAL at the top of the file. If not, use a session-level SET in the tool's init hook. If the tool cannot run init SQL, set the timeout on the migration role with ALTER ROLE. Where Should the Timeout Live? Does the tool wrap each file in a transaction? yes no SET LOCAL lock_timeout at the top of the file Can the tool run init SQL per connection? yes no SET lock_timeout in the init hook ALTER ROLE migrator SET lock_timeout
Scope the timeout to the migration and nothing else: SET LOCAL inside the file, the tool's init hook, or a dedicated migration role — never the server-wide default.

Symptom / Error Signatures

You need this guide in two situations. The first is the absence of any timeout: a migration runs, waits silently for a lock, and the application stalls — see why one blocked ALTER TABLE stalls every query behind it. The second is a timeout without retry logic, which makes deploys flaky. The tell-tale errors are:

ERROR:  canceling statement due to lock timeout            -- PostgreSQL, SQLSTATE 55P03
ERROR 1205 (HY000): Lock wait timeout exceeded; try restarting transaction   -- MySQL
FlywayMigrateException: Migration V42__add_region.sql failed ... SQL State: 55P03
liquibase.exception.MigrationFailedException: ... ERROR: canceling statement due to lock timeout

If these appear in CI deploy logs and a manual re-run succeeds a minute later, the timeout is doing its job and the retry is what is missing.

Root Cause Analysis

lock_timeout bounds the time a statement will wait for any single lock. Unlike statement_timeout, it does not count the work the statement does once it has the lock, which makes it the precise tool for the queueing problem. Its scope follows ordinary SET rules: SET LOCAL lasts until the transaction ends, SET lasts for the session, and ALTER ROLE ... SET becomes the default for new sessions of that role. The scope matters because migration tools manage transactions differently.

Tool Default transaction behaviour Where the timeout goes
Flyway one transaction per migration file (PostgreSQL) SET lock_timeout as the first statement of the file, or initSql in configuration
Liquibase one transaction per changeset <sql>SET LOCAL lock_timeout = '2s'</sql> as the first change of the changeset
Alembic one transaction for the whole run (transaction_per_migration=False) op.execute("SET LOCAL lock_timeout = '2s'"), with per-migration transactions enabled
Rails / Django one transaction per migration a SET LOCAL statement at the start of the migration
plain psql -f autocommit unless the file has BEGIN SET lock_timeout at the top of the file

The retry half has its own subtlety. A lock timeout aborts the whole transaction, so the retry must re-run the whole migration unit, not the single statement. That is only safe if the unit is atomic (PostgreSQL DDL is) or idempotent — which is why idempotent script design and retries go together. On MySQL, where each DDL statement commits implicitly, a multi-statement migration that fails halfway cannot be retried from the top unless every statement is guarded.

Three Attempts Against a Busy Table Timeline over 30 seconds. A reporting query holds a lock from 0 to 14 seconds. Attempt 1 at 1 second times out at 3 seconds. Attempt 2 at 7 seconds times out at 9 seconds. Attempt 3 at 16 seconds acquires the lock and completes. Application queries are delayed only during each 2-second attempt window. Three Attempts Against a Busy Table Report holds ACCESS SHARE Migration #1 #2 #3 App delay 2 s 2 s 0 s 5 s 10 s 15 s 20 s 25 s 30 s blocker running attempt waiting DDL applied app queries delayed
Each attempt costs the application at most two seconds of queueing; backoff spaces the attempts so the blocker has time to finish between them.

Immediate Mitigation

If deploys are already failing on lock timeouts, or stalling without them, these steps restore a safe baseline today.

1. Put the timeout in the migration, not the server config. For Flyway on PostgreSQL, the simplest scoped option is the first line of each risky file:

-- PostgreSQL · Flyway migration V42__add_region.sql · runs in Flyway's per-file transaction
-- WARNING: SET LOCAL is discarded at COMMIT; it cannot affect application sessions.
SET LOCAL lock_timeout = '2s';
SET LOCAL statement_timeout = '30s';
ALTER TABLE orders ADD COLUMN fulfilment_region text;
-- ROLLBACK PATH: ALTER TABLE orders DROP COLUMN IF EXISTS fulfilment_region;

For Alembic, enable per-migration transactions in env.py so a retry re-runs one revision rather than the whole upgrade, and set the timeout inside each revision:

# Python · Alembic env.py and a revision · runs as the migration role
# WARNING: without transaction_per_migration=True, SET LOCAL would span the entire upgrade run.
context.configure(connection=connection, target_metadata=target_metadata,
                  transaction_per_migration=True)

def upgrade():
    op.execute("SET LOCAL lock_timeout = '2s'")
    op.add_column("orders", sa.Column("fulfilment_region", sa.Text(), nullable=True))
# ROLLBACK PATH: op.drop_column("orders", "fulfilment_region") in downgrade()

2. Give the migration role a safety-net default. Even if an individual file forgets, the role’s default applies to every session it opens.

-- PostgreSQL · superuser or role owner · affects new sessions of role migrator only
ALTER ROLE migrator SET lock_timeout = '3s';
ALTER ROLE migrator SET statement_timeout = '5min';
-- ROLLBACK PATH: ALTER ROLE migrator RESET lock_timeout; ALTER ROLE migrator RESET statement_timeout;

3. Wrap the tool invocation in a retry that matches only lock errors. Retrying any failure hides syntax errors and constraint violations; match the SQLSTATE or its message.

# Shell · CI deploy job · Flyway CLI with credentials from the environment
# WARNING: only lock-timeout failures are retried; everything else fails the job immediately.
max=5
for i in $(seq 1 $max); do
  if flyway -locations=filesystem:db/migration migrate > flyway.log 2>&1; then
    cat flyway.log; exit 0
  fi
  if ! grep -Eq "55P03|lock timeout|Lock wait timeout exceeded" flyway.log; then
    cat flyway.log; exit 1
  fi
  delay=$(( 2 ** i + RANDOM % 4 ))
  echo "lock timeout on attempt $i/$max, retrying in ${delay}s"; sleep "$delay"
done
cat flyway.log; echo "migration abandoned after $max lock timeouts"; exit 1

4. For MySQL, lower lock_wait_timeout in the session. MySQL has no SET LOCAL, so set the session variable in the migration and keep one DDL statement per migration file so a retry never re-runs an already-committed statement.

-- MySQL 8.0 · migration session · one DDL per file because DDL commits implicitly
SET SESSION lock_wait_timeout = 5;
ALTER TABLE orders ADD COLUMN fulfilment_region VARCHAR(32) NULL, ALGORITHM=INSTANT;
-- ROLLBACK PATH: ALTER TABLE orders DROP COLUMN fulfilment_region, ALGORITHM=INSTANT;

Permanent Fix / Long-Term Pattern

Make the timeout-and-retry contract a property of the pipeline rather than of individual files. The robust pattern has four parts. The migration role carries conservative defaults, so a forgotten SET still fails fast. Each migration file that takes a strong lock sets its own, tighter value. The runner retries only lock errors, with exponential backoff and jitter, for a bounded number of attempts. And the pipeline records the attempt count as a metric, so a table that needs four attempts every deploy becomes visible as a workload problem, as covered in tracking schema migration metrics and SLOs.

Enforce the first two parts in review: a lint rule can require that any file containing ALTER TABLE also contains lock_timeout, which is the kind of check described in Migration Linting & Static Analysis. Keep the backoff ceiling below your deploy’s overall timeout so the pipeline fails with a clear message rather than being killed mid-retry.

Probability of Success vs Attempts Line chart of cumulative probability that the migration acquires its lock, for 1 to 6 attempts, with a 2 second timeout. On a table where 30 percent of instants have a blocker, success rises from 70 to 99.9 percent. On a table where 60 percent of instants have a blocker, success rises from 40 to 95 percent. Probability of Success vs Attempts 0 20 40 60 80 100 1 2 3 4 5 6 attempts % chance the lock is acquired 95% target blocker present 30% of the time blocker present 60% of the time
Retries compound: with a blocker present 30% of the time, five 2-second attempts succeed 99.8% of the time, so a short timeout costs almost nothing in deploy reliability.

Verification Checklist

Frequently Asked Questions

Should lock_timeout ever be set globally in postgresql.conf? Rarely. A global value applies to application sessions too, where a lock wait is usually legitimate and a timeout turns it into an error. Scope it to the migration role or the migration session; if you want a global guard, use idle_in_transaction_session_timeout instead, which removes blockers rather than failing waiters.

How many retries are enough? Three to five, with exponential backoff that spans at least a minute in total. That covers most transient blockers. If a table needs more, a long-running workload is holding locks during deploy windows, and the fix is to move or reschedule that workload.

Is it safe to retry a migration whose DDL timed out? On PostgreSQL, yes, because DDL is transactional and the failed attempt rolled back completely. On MySQL, only if each migration unit contains a single DDL statement or every statement is idempotent, because earlier statements in a multi-statement file have already committed.

What is the difference between lock_timeout and statement_timeout? lock_timeout limits how long a statement waits for each lock; statement_timeout limits the statement’s total runtime including lock waits. Use a short lock_timeout to prevent queueing and a longer statement_timeout as a ceiling on the work itself.