ArmorDB Logo
ArmorDB
All articles
ComparisonsAugust 22, 20268 min read

PostgreSQL Online Schema Change Options Compared

Compare PostgreSQL online schema change patterns for production apps: concurrent indexes, staged constraints, nullable columns, backfills, and maintenance windows.

ArmorDB Engineering

ArmorDB engineering

PostgreSQLSchema MigrationsDDL
On this page 11 sections

A PostgreSQL schema change is easy to write and surprisingly easy to ship badly. The SQL may be one line, but the production effect depends on table size, lock mode, rewrite behavior, index build strategy, old application versions, and how long the migration waits behind active transactions.

The practical problem is not "can PostgreSQL change this table?" It can. The better question is which rollout shape keeps the application available while the database moves from the old contract to the new one. This comparison focuses on common production changes: adding columns, building indexes, enforcing constraints, changing data shape, and deciding when an actual maintenance window is cleaner than pretending a risky change is online.

Why online schema changes are mostly about locks

PostgreSQL protects schema changes with table locks so that concurrent sessions do not read or write a table while its structure is in an unsafe intermediate state. The official ALTER TABLE documentation is explicit: different subforms require different lock levels, and unless the documentation says otherwise, ALTER TABLE takes an ACCESS EXCLUSIVE lock. When multiple subcommands are combined, PostgreSQL uses the strictest lock required by any subcommand.

That does not mean every migration causes a long outage. Some changes are metadata-only and finish quickly. Others scan the table, validate existing rows, build an index, or rewrite stored data. The danger comes from assuming those are equivalent because the migration file is short. A fast ACCESS EXCLUSIVE lock on a quiet table may be harmless; the same lock waiting behind a long transaction on a busy table can block application traffic at exactly the wrong time.

For managed PostgreSQL users, the provider can make backups, monitoring, and restore workflow easier, but it cannot make a blocking DDL statement non-blocking after it has been submitted. The application team still owns rollout shape, deploy order, and verification.

A comparison of common PostgreSQL migration patterns

Use the smallest pattern that satisfies the product change. Online does not always mean zero risk; it means the migration is designed to limit blocking, preserve compatibility, and keep rollback possible until the new path is proven.

Change patternBest fitAvailability profileMain risk
Metadata-only column addNew nullable column or non-volatile defaultUsually brief because no table rewrite is needed in current PostgreSQLOld code may not populate the new column yet
Concurrent index buildLarge active table that needs a new indexAvoids blocking ordinary inserts, updates, and deletesTakes longer, cannot run inside a transaction block, and may leave an invalid index after failure
Staged constraintForeign key, check, or not-null-style rule after cleanupAllows a separate validation step instead of one big surpriseHalf-finished constraints must be tracked and validated
Batched backfillPopulating new columns or reshaping existing dataKeeps transactions small and throttledCan create bloat, replica lag, or write amplification if rushed
Maintenance windowType rewrites, table rebuilds, risky drops, or tightly coupled releasesDowntime is explicit and plannedRequires communication, rollback point, and tested restore path

The right answer is often a sequence, not a single command. A safe migration might add a nullable column, deploy code that writes both old and new fields, backfill in batches, add a constraint in a staged way, switch reads, and only then remove the old field after another deploy.

Adding columns without creating a deploy trap

Adding a nullable column is one of the safer PostgreSQL changes. The ALTER TABLE notes say that adding a column without constraints uses NULL as the default and does not require a table rewrite. PostgreSQL also stores a non-volatile default in metadata for existing rows, which keeps many common column adds fast even on large tables.

That does not make the application rollout automatic. If the column is part of a new invariant, do not add it as NOT NULL with application assumptions in the same breath. Start with the database accepting both old and new application versions. Deploy code that writes the new value. Backfill existing rows. Then enforce the rule once you have evidence that no old path still writes missing data.

A useful example is adding billing_state to an accounts table. The online shape is to add the column as nullable, make new writes populate it, backfill accounts in small batches, add a check or not-null enforcement after validation, and only then rely on the field in critical billing logic. This is slower than one dramatic migration, but it keeps the product contract understandable during rolling deploys.

Indexes: regular builds versus concurrent builds

A regular CREATE INDEX can be faster and simpler, but PostgreSQL documents that a standard index build locks out writes on the table until it finishes. For a small table during a quiet release, that may be acceptable. For a large customer-facing table, it is often the wrong default.

CREATE INDEX CONCURRENTLY is the online tool most application teams reach for. PostgreSQL builds the index without taking locks that prevent concurrent inserts, updates, or deletes. The tradeoff is operational complexity: the concurrent build does more work, takes longer, cannot run inside a transaction block, and needs cleanup if it fails and leaves an invalid index behind. Migration frameworks that wrap every migration in a transaction may need a special mode for this command.

Treat a concurrent index as a production operation. Check available storage, watch write load, and avoid stacking it with a large backfill or vacuum-heavy cleanup. After it finishes, verify that the planner actually uses the index for the intended query. An index that was safely built but never used still adds write overhead and storage cost.

Constraints work best as staged contracts

Constraints are where online schema changes become product changes, not just database changes. A constraint says future data must obey a rule. Existing rows may or may not already obey it, and older application versions may still violate it during a rolling deploy.

PostgreSQL supports staged validation for several constraint workflows. The ALTER TABLE documentation includes ADD table_constraint NOT VALID and VALIDATE CONSTRAINT, which lets a team record the intended rule and validate existing rows separately. For large tables, that separation is valuable because it moves the migration from "hope the whole table passes while traffic is live" to "clean data, add the rule for new writes where applicable, validate deliberately, and keep evidence."

Do not let NOT VALID become a permanent hiding place. Every transitional constraint should have an owner, a follow-up migration, and a query that proves whether existing rows are clean. In a managed environment, pair that follow-up with normal operational checks: recent backup, low-traffic validation window, and visibility into locks and long-running sessions.

Backfills should be boring and restartable

Backfills are where many online migrations fail in practice. The schema change may be safe, but the data movement creates sustained writes, WAL volume, index churn, replica lag, and autovacuum work. One huge UPDATE also holds locks and transaction state for longer than necessary.

Prefer small, restartable batches. Use a stable key range or a work queue, commit each batch, and record progress outside the rows being rewritten when possible. Keep the application compatible while the backfill is incomplete, because a production backfill can pause for reasons that have nothing to do with SQL correctness: a traffic spike, a storage alert, an incident elsewhere, or a decision to slow down during business hours.

A practical backfill runbook includes the batch predicate, expected rows per batch, sleep or throttle behavior, metrics to watch, and the stop condition. It also includes the cleanup path. If the new code must be rolled back, the database should still accept old writes until the final enforcement step has shipped.

When a maintenance window is the honest answer

Some changes should not be disguised as online. Changing a column type in a way that rewrites a large table, dropping a heavily used column before all code paths have stopped reading it, rebuilding a hot table, or combining DDL with a risky application release may be better handled in a planned maintenance window.

A maintenance window is not a failure of engineering discipline. It is a way to make risk explicit. The stronger plan is to tell users what will happen, take or confirm a restore point, pause background jobs that would fight the migration, run the change from a controlled environment, verify application health, and keep a rollback decision point. On ArmorDB, review /docs/backups before high-risk changes and use /docs/pgbouncer context if connection churn could amplify the incident.

A practical decision flow

Start by classifying the change. If it is metadata-only and compatible with old code, ship it normally but still keep the migration small. If it touches a large active table, identify the lock behavior before running it. If it adds a rule, separate writing the rule from proving all old data follows the rule. If it changes stored data, make the backfill resumable. If it rewrites large data or removes compatibility, schedule a maintenance window.

Before applying any production DDL, check for long-running transactions, pick a lock timeout that fails safely instead of waiting forever, and run the SQL against a staging restore that resembles production size. A migration that succeeds instantly on an empty database has not been tested for the thing that usually hurts: time under real data shape.

Common mistakes

The first mistake is mixing too many operations into one migration file. PostgreSQL will use the strictest lock required by the combined ALTER TABLE, so a safe-looking subcommand can inherit the risk of a more invasive neighbor. Split migrations when the rollout steps have different safety profiles.

The second mistake is forgetting old application versions. In rolling deploys, jobs, workers, and admin scripts may keep using the old schema contract after the web process has moved on. Online database changes should be backward-compatible until every writer and reader has been updated.

The third mistake is treating successful DDL as successful rollout. The rollout is complete only after the application uses the new shape, validation has passed, transitional code is removed, and the old column, index, or compatibility path has a deliberate cleanup plan.

Takeaway

PostgreSQL gives teams several strong tools for online schema evolution, but they work best as a rollout discipline rather than isolated tricks. Use metadata-only changes when possible, build large indexes concurrently, stage constraints, backfill in small restartable batches, and choose a maintenance window when the change is genuinely invasive.

For managed PostgreSQL teams, the best migration is the one whose database behavior, application deploy order, backup posture, and rollback path all agree with each other before production sees the first statement.

Sources and further reading

Written by ArmorDB Engineering

Practical notes on PostgreSQL operations, security, and infrastructure decisions for teams building production applications.

Updated Aug 22, 2026