PostgreSQL Schema Migration: Deploying Changes Safely
Learn how backend developers can safely manage PostgreSQL schema migrations using lock timeouts, the expand-and-contract pattern, and visual schema sync.
On this page
The PostgreSQL Locking Queue and ACCESS EXCLUSIVE
PostgreSQL executes data definition language (DDL) operations like ALTER TABLE by acquiring an ACCESS EXCLUSIVE lock on the target relation: the documentation states that DROP TABLE, TRUNCATE, REINDEX, CLUSTER and VACUUM FULL take this lock, and that many forms of ALTER INDEX and ALTER TABLE also acquire a lock at this level[1]. ACCESS EXCLUSIVE is the most restrictive table-level lock mode: it conflicts with locks of all other modes, including the lightweight ACCESS SHARE lock that a plain SELECT acquires, and the documentation notes that only an ACCESS EXCLUSIVE lock blocks a plain SELECT[1]. While an ACCESS EXCLUSIVE lock is held, all concurrent reads, inserts, updates, and deletes on that table are completely blocked.
The Lock Queue Dilemma
Downtime rarely occurs because the DDL operation itself takes hours to execute. Downtime occurs because of how PostgreSQL queues lock requests: a DDL statement that cannot acquire its ACCESS EXCLUSIVE lock blocks indefinitely, and every other statement needing a lock on that table then queues behind it, including SELECTs that need only an ACCESS SHARE lock[2]. When an ALTER TABLE statement requests an ACCESS EXCLUSIVE lock on an active table, it must wait for all currently running queries on that table to finish, because two transactions cannot hold locks of conflicting modes on the same table at the same time[1].
While the DDL statement sits in the lock queue waiting for active read queries to release their ACCESS SHARE locks, the table is effectively blocked for reads and writes: SELECTs and UPDATEs queue up behind the stalled ALTER TABLE and cannot execute[2]. Even a fast SELECT statement that requires only milliseconds cannot bypass the waiting ALTER TABLE command. As incoming application traffic queues up behind the stalled DDL statement, database connection pools exhaust their available slots, causing application timeouts and production outages.
| Lock Mode | Command Examples | Conflicting Lock Modes |
|---|---|---|
| ACCESS SHARE | SELECT | ACCESS EXCLUSIVE only |
| ROW EXCLUSIVE | INSERT, UPDATE, DELETE, MERGE | SHARE, SHARE ROW EXCLUSIVE, EXCLUSIVE, ACCESS EXCLUSIVE |
| SHARE UPDATE EXCLUSIVE | VACUUM, ANALYZE, CREATE INDEX CONCURRENTLY | SHARE UPDATE EXCLUSIVE, SHARE, SHARE ROW EXCLUSIVE, EXCLUSIVE, ACCESS EXCLUSIVE |
| SHARE | CREATE INDEX (without CONCURRENTLY) | ROW EXCLUSIVE, SHARE UPDATE EXCLUSIVE, SHARE ROW EXCLUSIVE, EXCLUSIVE, ACCESS EXCLUSIVE |
| ACCESS EXCLUSIVE | ALTER TABLE, DROP TABLE, TRUNCATE, VACUUM FULL | All lock modes (blocks all concurrent reads and writes) |
A simple schema change, such as adding a column or modifying a constraint, will cascade into a total database outage if executed against a table running long analytical queries or unindexed background scans.
Defending the Application with lock_timeout
Backend developers should treat lock_timeout as a mandatory setting before running any DDL statement in production: migration sessions should always set it to a value appropriate for the application[2][3]. By default, lock_timeout is zero, which disables the timeout and leaves the statement waiting for the lock indefinitely[4]. Setting a low, explicit timeout forces the DDL command to abort if it cannot obtain its lock within the allocated threshold, preventing the formation of a destructive lock queue.
Implementing Fast-Fail Timeouts and Retry Loops
Configure lock_timeout at the session level before executing your migration script. Values of less than 2 seconds are common for production systems[2]. If an active query holds a conflicting lock when the migration begins, the DDL command fails fast with a "canceling statement due to lock timeout" error and releases its queue position, so incoming application queries are unblocked immediately[2].
- Set the session lock_timeout to a tight window (for example, SET lock_timeout = '100ms';).
- Execute the DDL statement inside an explicit transaction block.
- Catch lock acquisition exceptions in your deployment runner or migration script.
- Apply an exponential backoff with random jitter between retry attempts.
- Re-attempt execution during off-peak traffic windows until the lock is acquired cleanly.
Pairing lock_timeout with statement_timeout provides complete defensive coverage. While lock_timeout limits the time spent waiting to acquire table locks, statement_timeout limits the total execution time of the command once locks are granted, ensuring long table rewrites do not stall client connections.
Safe DDL: Defaults, NOT VALID, and Indexes
Modern PostgreSQL releases provide specialized DDL options that avoid table rewrites and reduce heavy lock durations during schema migrations. ALTER TABLE documents the lock level required for each subform, and several subforms need only a SHARE UPDATE EXCLUSIVE or SHARE ROW EXCLUSIVE lock rather than ACCESS EXCLUSIVE[5]. Understanding these mechanisms allows developers to modify large production tables without blocking concurrent web transactions.
Instant Column Additions with Non-Volatile Defaults
PostgreSQL 11 introduced the ability to avoid a table rewrite for ALTER TABLE... ADD COLUMN with a non-null column default[6]. When a column is added with ADD COLUMN and a non-volatile DEFAULT is specified, the default is evaluated once at that time and stored in the catalog, so existing rows are not rewritten[5]. The engine returns that stored default on demand when legacy rows are read. Volatile default expressions, such as clock_timestamp() or random functions, still require a full physical table rewrite.
Adding Constraints Concurrently with NOT VALID
Adding a table constraint normally causes a scan of the table to verify that all existing rows satisfy the new constraint, and most forms of ADD table_constraint require an ACCESS EXCLUSIVE lock (ADD FOREIGN KEY needs only SHARE ROW EXCLUSIVE)[5]. On large tables, this validation scan holds the lock for minutes. You can split this operation into two safe stages:
- Add the constraint with the NOT VALID clause: the potentially lengthy verification scan is skipped, but the constraint is still applied against subsequent inserts and updates[5].
- Validate the constraint separately: run ALTER TABLE table_name VALIDATE CONSTRAINT constraint_name;. The database does not assume the constraint holds for existing rows until it is validated this way, and the validation pass is designed to run without blocking concurrent reads and writes[5].
Building Indexes Concurrently
Standard CREATE INDEX (without CONCURRENTLY) acquires a SHARE lock, which permits concurrent SELECT queries but conflicts with the ROW EXCLUSIVE lock taken by INSERT, UPDATE, and DELETE, so writes are blocked until indexing finishes[1]. In production environments, always use CREATE INDEX CONCURRENTLY, which acquires only a SHARE UPDATE EXCLUSIVE lock and therefore leaves table reads and writes operational during the index build[1].
The Expand-and-Contract Pattern for Breaking Changes
Destructive schema modifications, such as renaming columns, splitting tables, or changing incompatible data types, cannot be applied in a single transactional step without breaking running application instances[7]. The expand-and-contract pattern (also known as the parallel change pattern) decouples database modifications from application deployments across phased releases.
Phases of Zero-Downtime Schema Evolution
- Expand: Add the new column, table, or relation alongside the existing structure without removing or modifying legacy columns.
- Dual-Write: Update application services to write data simultaneously to both the old and new schema structures while continuing to read from the old structure.
- Backfill: Execute asynchronous, batch-oriented background workers to copy and transform historical records from the old structure to the new structure.
- Contract: Switch application queries to read exclusively from the new structure, remove the dual-write logic, and safely drop the old, deprecated column or table.
By adopting this decoupled lifecycle, database structures remain backward-compatible with running application versions throughout every stage of deployment, eliminating coordinated maintenance windows.
State-Based vs. Artifact-Based Migration Strategies
Engineering teams manage database evolution over time through two primary methodologies: artifact-based migrations and state-based migrations[7]. Choosing the right strategy determines how schema changes are tested, reviewed, and deployed across team environments.
Artifact-based deployments (often called migration-based deployments) rely on a linear sequence of immutable, numbered SQL or XML change scripts (for example, V1create_users.sql, V2add_email_index.sql)[7]. A tracking table in the database records which scripts have been applied. While this approach offers granular step-by-step control, it obscures the current holistic architecture of the database, requiring developers to mentally assemble hundreds of delta scripts to understand the active schema model.
State-Based deployments define the database schema declaratively as a complete target state model file. A comparison engine inspects the live database catalog, calculates the structural difference against the target state, and generates the exact DDL commands required to bring the database into alignment[7]. This approach provides clear architectural visibility and allows teams to evaluate schema changes directly in visual diagrams before generating migration scripts.
| Criterion | Artifact-Based Migrations | State-Based Migrations |
|---|---|---|
| Schema Source of Truth | Linear sequence of historical delta scripts | Declarative visual model file representing target state |
| Change Definition | Developer hand-writes explicit up and down SQL scripts | Comparison engine calculates DDL diff between model and database |
| Drift Handling | Blindly runs next script; fails if manual hotfixes occurred | Detects discrepancies between live database and design model |
| Architectural Visibility | Low (requires piecing together past migration files) | High (interactive ER diagrams and full data dictionaries) |
Evaluating modern schema migration tools helps teams choose the workflow that best balances automated CI/CD pipelines with comprehensive architectural oversight.
Generating Migration Scripts via Schema Synchronization
Implementing a state-based workflow begins with an offline database design model. The complete database schema structure, entity relationships, and column comments are stored in an offline .dbs model file. This XML-based design model file lives directly inside your Git repository, enabling teams to review schema modifications in pull requests before any DDL reaches a live server.
On-Demand Schema Comparison and Diff Resolution
Synchronization between the design model and your PostgreSQL database is strictly on demand, never automatic. It is a menu command: Refresh Model from Database pulls the server's structure into the model, Create or Upgrade Model in the Database pushes the model at the server, and nothing moves between the two on its own. When you start a schema comparison, DbSchema reads the live database structure and lists every difference in the Synchronization Dialog as a per-object tree, with the model's value on the left and the database's on the right.
For each detected discrepancy, developers make an explicit per-difference choice:
- Keep in model: the difference is applied into the design model instead of the server. Apply Model Actions rewrites that object in your local .dbs file so the model matches what the database already has, which is how a hotfix somebody ran straight against the server gets adopted into the design rather than issuing a command to apply that change to the database.
- Commit to database: the difference is applied into the database. Generate Script turns the row into DDL and Commit In Database runs it, so the model's definition wins. Use this direction for changes you designed in the model, not for capturing changes made directly on the server.
- Do nothing: leave the row Inactive and neither side moves. The difference is skipped, not resolved, so it is still there the next time you compare and it shows up again in later reviews without interrupting other updates.
Once the directions are set, Generate Script writes the PostgreSQL DDL out, so you can save it, put the lock_timeout and statement_timeout preamble in front of it, and commit it to version control next to the model. Do that editing in your own editor: the script viewer inside the Execute Script In Database dialog is read-only, offering Execute, Execute All, Skip and Close but no way to rewrite a statement in place. Schema compare and synchronisation is a Pro-edition feature, and the 15-day Pro trial covers it.
Executing Migrations and Resolving Schema Drift
Once the script exists, the risk moves from writing it to running it in order, from local development out to staging and production environments. Schema drift is what breaks that order: emergency patches, administrative index tweaks, or uncommitted hotfixes alter a database out of band, creating silent discrepancies between environments[7], and the script you generated no longer matches the database it is aimed at. Comparing production and staging schemas before the deploy is what catches that.
Automated Verification and Pipeline Governance
Integrating schema validation into continuous integration pipelines ensures that drift is detected early. Running automated schema comparisons against staging databases flags uncommitted modifications before production deployment scripts execute, preventing migration failures and data corruption[7]. On large PostgreSQL schemas the difference list is the hard part rather than the DDL: a model with a hundred tables produces a long tree to read, which is a navigation problem before it is a migration one, and turning a schema diff into a safe migration script stays a review step rather than a button.
- Execute deployment scripts within explicit transaction blocks so failures roll back completely without leaving partial schema states.
- Prepend session-level lock_timeout and statement_timeout configurations to every migration payload.
- Verify that new indexes are built using CONCURRENTLY outside transactional blocks.
- Validate foreign keys and check constraints in separate post-deployment stages using VALIDATE CONSTRAINT.
Deploying PostgreSQL schema changes safely comes down to three habits: a lock_timeout in front of every DDL statement, DDL forms that do not rewrite the table, and a schema comparison you read before you run the script it generates. Download DbSchema, open your design model against your own PostgreSQL database, and see what the two sides disagree about.
Frequently asked questions
What happens if a PostgreSQL migration script is run without lock_timeout?
If a migration script requests an ACCESS EXCLUSIVE lock while another long-running query is active, it forms a lock queue. Every subsequent application query waiting to access that table will be blocked indefinitely, effectively causing complete downtime. Enforcing a strict lock_timeout like 50ms ensures the migration fails safely instead of freezing the database.
Is it safe to add a new column to a large PostgreSQL table?
Since Postgres 11, adding a new column with a static DEFAULT value is a safe, metadata-only operation. The database updates the system catalog without rewriting the entire table. However, adding non-constant defaults or running this on older versions will force a table rewrite, heavily impacting performance.
How do I add a NOT NULL constraint without locking the table?
You can add constraints in PostgreSQL without locking out reads and writes by utilizing the NOT VALID modifier. First, apply a CHECK constraint using NOT VALID, which skips the initial data scan. Then, run a separate VALIDATE CONSTRAINT command. Finally, apply the SET NOT NULL rule, which executes instantly because the database trusts the validated constraint.
What is the difference between state-based and artifact-based schema migrations?
State-based deployments evaluate the desired database structure against the live environment and generate the migration script dynamically to resolve any differences. Artifact-based deployments rely on developers writing and tracking sequential change scripts that are executed in a specific order.
Does a state-based tool automatically sync schema changes to production?
No. Schema synchronization is strictly on demand. Developers compare the local offline design model against the live database, reviewing each difference side by side. For every discrepancy, they choose whether to keep it in the model, commit it to the database, or ignore it completely.
Sources
See what your model and your database disagree about
DbSchema reverse-engineers your PostgreSQL schema into a design model, compares the two on demand, and generates the DDL for the differences you choose to commit.