{"id":51238578,"url":"https://github.com/denpeshkov/postgresql-zero-downtime","last_synced_at":"2026-06-28T22:02:31.872Z","repository":{"id":354431613,"uuid":"1223620201","full_name":"denpeshkov/postgresql-zero-downtime","owner":"denpeshkov","description":"A guideline on zero-downtime deployments (migrations) for PostgreSQL ","archived":false,"fork":false,"pushed_at":"2026-04-28T13:51:07.000Z","size":7,"stargazers_count":0,"open_issues_count":0,"forks_count":0,"subscribers_count":0,"default_branch":"main","last_synced_at":"2026-04-28T15:32:46.723Z","etag":null,"topics":["best-practices","database","deployment","guide","migrations","postgres","postgresql","zero-downtime"],"latest_commit_sha":null,"homepage":"","language":null,"has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":"mit","status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/denpeshkov.png","metadata":{"files":{"readme":"README.md","changelog":null,"contributing":null,"funding":null,"license":"LICENSE","code_of_conduct":null,"threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":null,"support":null,"governance":null,"roadmap":null,"authors":null,"dei":null,"publiccode":null,"codemeta":null,"zenodo":null,"notice":null,"maintainers":null,"copyright":null,"agents":null,"dco":null,"cla":null}},"created_at":"2026-04-28T13:50:11.000Z","updated_at":"2026-04-28T13:52:43.000Z","dependencies_parsed_at":null,"dependency_job_id":null,"html_url":"https://github.com/denpeshkov/postgresql-zero-downtime","commit_stats":null,"previous_names":["denpeshkov/postgresql-zero-downtime"],"tags_count":null,"template":false,"template_full_name":null,"purl":"pkg:github/denpeshkov/postgresql-zero-downtime","repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/denpeshkov%2Fpostgresql-zero-downtime","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/denpeshkov%2Fpostgresql-zero-downtime/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/denpeshkov%2Fpostgresql-zero-downtime/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/denpeshkov%2Fpostgresql-zero-downtime/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/denpeshkov","download_url":"https://codeload.github.com/denpeshkov/postgresql-zero-downtime/tar.gz/refs/heads/main","sbom_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/denpeshkov%2Fpostgresql-zero-downtime/sbom","scorecard":null,"host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":286080680,"owners_count":34905180,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2026-05-26T15:22:16.424Z","status":"online","status_checked_at":"2026-06-28T02:00:05.809Z","response_time":54,"last_error":null,"robots_txt_status":"success","robots_txt_updated_at":"2025-07-24T06:49:26.215Z","robots_txt_url":"https://github.com/robots.txt","online":true,"can_crawl_api":true,"host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":["best-practices","database","deployment","guide","migrations","postgres","postgresql","zero-downtime"],"created_at":"2026-06-28T22:02:27.555Z","updated_at":"2026-06-28T22:02:31.864Z","avatar_url":"https://github.com/denpeshkov.png","language":null,"funding_links":[],"categories":[],"sub_categories":[],"readme":"# Expand-Contract Pattern\n\n## The Two Coexisting Schemas\n\nThere is only **one physical schema** — the real tables — but the database presents **two logical schemas** at the same time:\n\n- **The old logical schema** — what the previous application version expects: the original column names, types, and constraints\n- **The new logical schema** — what the next application version expects: the renamed column, the new type, the added constraint, etc.\n\nOld pods talk to the old logical schema, new pods talk to the new one, and the database (or application) keeps the data they see consistent\n\nTypically implemented with **versioned views** backed by the same underlying tables, each exposing the data shape the corresponding application version expects\n\nA pod selects its logical schema once at connection time (e.g. via `search_path`) and is unaffected by the rollout from then on\n\n## The Three Phases\n\n### 1. Expand\n\nAdd the new structure **alongside** the old one, never in place of it\n\nEvery operation in Expand is chosen so it can run on a busy table without holding long locks\n\nDuring the rollout window, a write can arrive through either logical schema; dual-writes guarantee that both representations stay consistent\n\nDual-writes cover _new_ writes; the backfill covers everything that already existed\n\n- Add new columns or tables; leave existing columns and tables untouched\n- Build the new logical schema (the new view) so next-version pods can query it immediately, even if no row has been migrated yet\n- Dual-write to both columns via a trigger (or in application code) to keep the old and new columns in sync\n- Backfill existing rows in batches so the new column is populated for historical data\n\nAfter Expand finishes, both logical schemas are live and writable\n\nAny mix of old-version and new-version application instances can run against the database\n\n### 2. Roll Out the Application\n\nThe application rollout is now a **completely independent operation**: the new binary can be deployed gradually\n\nThe database is not involved — both code paths still work because both logical schemas still work\n\n### 3. Contract\n\nOnce every running instance has moved to the new version, the old structure is removed:\n\n- Drop the dual-write trigger (or the equivalent logic in application code)\n- Drop the obsolete columns or tables\n- Drop the old logical schema (the old view)\n\nAfter Contract, only the new shape remains\n\nThe transition is complete\n\n# Migrations Guideline\n\n## Pre-Deploy vs Post-Deploy\n\nMigrations split into two phases around the application rollout:\n\n- **Pre-deploy** runs before the new application version is deployed; use it for additive, backward-compatible changes\n- **Post-deploy** runs after every instance has moved to the new version; use it for destructive or restrictive changes\n\n## Locking\n\nDDL that takes `ACCESS EXCLUSIVE` is \"fast\" in isolation but can stall the database if it queues behind a long-running query: while the migration waits, every subsequent reader queues _behind_ it\n\nTwo defenses, applied to every DDL session:\n\n1. Query [`pg_locks`](https://www.postgresql.org/docs/18/view-pg-locks.html) for long-running blockers on the target table; pause and retry until the queue is short\n2. Bound the wait with `lock_timeout`:\n\n   ```sql\n   BEGIN;\n   SET LOCAL lock_timeout = '2s';   -- fail if not acquired in 2s\n   ALTER TABLE t ADD COLUMN c text;\n   COMMIT;\n   ```\n\nOperations that take a _weaker_ lock — `CREATE INDEX CONCURRENTLY`, `ALTER TABLE … VALIDATE CONSTRAINT` — don't block concurrent reads or writes, so they don't need this protection\n\nThey can still wait a long time for in-flight transactions on the table to drain, but that wait is invisible to other clients\n\n## Migrations and Transactions\n\nSome migrations must run outside a transaction:\n\n- `CREATE INDEX CONCURRENTLY`, `DROP INDEX CONCURRENTLY`, `REINDEX CONCURRENTLY`\n- Batched data migrations (backfills)\n\n## Table Operations\n\n### Create a Table\n\n[`CREATE TABLE`](https://www.postgresql.org/docs/18/sql-createtable.html) does not lock existing tables\n\nFor FKs, follow [Add a `FOREIGN KEY`](#add-a-foreign-key)\n\n### Rename a Table\n\n[`ALTER TABLE … RENAME`](https://www.postgresql.org/docs/18/sql-altertable.html) takes a short `ACCESS EXCLUSIVE`\n\n1. Rename the table and create a view under the old name:\n\n   Reads, inserts, updates, and deletes against the view continue to work — see the [updatable views caveats](https://www.postgresql.org/docs/18/sql-createview.html#SQL-CREATEVIEW-UPDATABLE-VIEWS)\n\n   ```sql\n   BEGIN;\n   ALTER TABLE t_old RENAME TO t_new;\n   CREATE VIEW t_old AS SELECT * FROM t_new;\n   COMMIT;\n   ```\n2. Deploy a code change to start using the new table\n3. Drop the temporary view:\n\n   ```sql\n   DROP VIEW t_old;\n   ```\n\nSanity-check via [`pg_stat_user_tables`](https://www.postgresql.org/docs/18/monitoring-stats.html#MONITORING-PG-STAT-USER-TABLES-VIEW) that the view is no longer being read before dropping it\n\n### Drop a Table\n\n`DROP TABLE` takes `ACCESS EXCLUSIVE`\n\nIf another table has a FK pointing to this one, drop those FKs first in separate migrations (one FK per migration); otherwise `DROP TABLE` fails\n\nAvoid `DROP TABLE … CASCADE` — it acquires `ACCESS EXCLUSIVE` on every dependent table in a single transaction, increasing the chance of deadlocks\n\n1. Deploy a code change to stop using the table\n2. Drop the now unused table:\n\n   ```sql\n   DROP TABLE t;\n   ```\n\nSanity-check via `pg_stat_user_tables` that the table is no longer being read before running the post-deploy migration\n\n## Columns\n\n### Add a Column\n\n`ALTER TABLE … ADD COLUMN` takes `ACCESS EXCLUSIVE`; the duration depends on whether the table needs a rewrite\n\n#### Without a Rewrite\n\nNo rewrite is required when the new column is one of:\n\n- No `DEFAULT` specified (the column defaults to `NULL`)\n- A non-volatile `DEFAULT` (e.g., a constant or a non-volatile expression)\n- A virtual generated column\n\nFor a non-volatile `DEFAULT`, the value is evaluated once at statement time and stored in the table's metadata; existing rows return the default on access without being rewritten\n\n```sql\nALTER TABLE t ADD COLUMN c text;                                      -- defaults to NULL\nALTER TABLE t ADD COLUMN c text DEFAULT 'foo';                        -- non-volatile DEFAULT\nALTER TABLE t ADD COLUMN c int GENERATED ALWAYS AS (a + b) VIRTUAL;   -- virtual generated\n```\n\n#### With a Rewrite\n\nThe entire table and its indexes are rewritten under `ACCESS EXCLUSIVE` when the new column has any of:\n\n- A volatile `DEFAULT` (e.g., `clock_timestamp()`, `random()`, `nextval()`)\n- `GENERATED ALWAYS AS (…) STORED`\n- `GENERATED … AS IDENTITY`\n- A domain data type that has constraints\n\nFor a volatile `DEFAULT`, skip the rewrite by adding the column nullable first, backfilling, and then attaching the default for future inserts:\n\n1. Add the column as nullable with no `DEFAULT`\n2. Backfill the desired values in batches, outside any transaction\n3. Set the original `DEFAULT` for future inserts:\n\n   Applies only to subsequent `INSERT` and `UPDATE` statements and does not change rows already in the table, regardless of the expression's volatility\n\n   ```sql\n   ALTER TABLE t ALTER COLUMN c SET DEFAULT …;\n   ```\n4. If the column needs to be `NOT NULL`, follow [Add `NOT NULL` constraint](#add-a-check-or-not-null-constraint)\n\n### Drop a Column\n\n`DROP COLUMN` takes a short `ACCESS EXCLUSIVE` while the column is marked dropped in the catalog\n\n1. Deploy a code change to stop using the column\n2. Drop the now unused column:\n   1. Find dependents in the [`pg_depend`](https://www.postgresql.org/docs/18/catalog-pg-depend.html) catalog\n   2. Drop dependent indexes with `DROP INDEX CONCURRENTLY` first; otherwise they would be dropped along with the column under `ACCESS EXCLUSIVE`\n   3. If a view references the column, recreate the view without it\n   4. Drop the column:\n\n      ```sql\n      ALTER TABLE t DROP COLUMN c;\n      ```\n\nSanity-check via `pg_stat_user_tables` that the column is no longer being read before running the post-deploy migration\n\n### Rename a Column\n\nBoth names must coexist while the deploy is in progress, because old code still uses the old name and new code uses the new one\n\n1. Create a new temporary column with a target name:\n   1. Add the new column\n   2. Dual-write to both columns via a `BEFORE INSERT/UPDATE` trigger (or in application code)\n   3. [Build indexes](#create-an-index) that referenced the old column on the new column\n   4. [Recreate any FKs](#add-a-foreign-key) that referenced the old column on the new column\n   5. If a view references the old column, recreate it pointing at the new column\n   6. Backfill existing data from the old column to the new column in batches, outside any transaction\n2. Deploy a code change to stop using the old column\n3. Drop the trigger and the now unused old column\n\n### Change a Column's Type\n\n#### Without a Rewrite\n\nIf the change is catalog-only, it is a single migration with a short `ACCESS EXCLUSIVE`, without a table rewrite\n\nVerify that [`pg_class.relfilenode`](https://www.postgresql.org/docs/18/catalog-pg-class.html) doesn't change before treating it as catalog-only\n\n```sql\nALTER TABLE t ALTER COLUMN c TYPE text;\n```\n\nIndexes on the column may still be rebuilt under the same `ACCESS EXCLUSIVE` unless PostgreSQL can prove the new index is logically equivalent to the old one (e.g. collation unchanged); check that each index's `relfilenode` is unchanged to confirm\n\n#### With a Rewrite\n\nFor changes that rewrite the column, the approach is almost identical to [renaming a column](#rename-a-column)\n\nValidate the cast on a clone first — a failed cast mid-backfill leaves the column half-migrated\n\n1. Create a new temporary column with a target type:\n   1. Add a new column of the target type\n   2. Dual-write to both columns via a `BEFORE INSERT/UPDATE` trigger (or in application code), casting in both directions\n   3. [Build indexes](#create-an-index) that referenced the old column on the new column\n   4. [Recreate any FKs](#add-a-foreign-key) that referenced the old column on the new column\n   5. If a view references the old column, recreate it pointing at the new column\n   6. Backfill existing data with the cast, in batches, outside any transaction\n2. Deploy a code change to stop using the old column\n3. Drop the trigger and the now unused old column\n\n## Indexes\n\n### Create an Index\n\n[`CREATE INDEX CONCURRENTLY`](https://www.postgresql.org/docs/18/sql-createindex.html) takes `SHARE UPDATE EXCLUSIVE`\n\nIt cannot run inside a transaction (see [Migrations and Transactions](#migrations-and-transactions)) and requires two table scans\n\nIt also waits for in-flight transactions on the table to complete before finishing — it can stall behind a long query, but that wait does not block any other client\n\n```sql\nCREATE INDEX CONCURRENTLY t_idx ON t (c);\n```\n\nIf the build fails, the database leaves an `INVALID` index — drop it before retrying:\n\n```sql\nDROP INDEX CONCURRENTLY IF EXISTS t_idx;\n```\n\n### Drop an Index\n\n`DROP INDEX CONCURRENTLY` takes `SHARE UPDATE EXCLUSIVE`\n\n```sql\nDROP INDEX CONCURRENTLY t_idx;\n```\n\nCannot drop an index backing a `PRIMARY KEY` or `UNIQUE` constraint — drop the constraint instead, which removes the index\n\n### Reindex an Index\n\nSame `SHARE UPDATE EXCLUSIVE` lock and caveats as for [`CREATE INDEX CONCURRENTLY`](#create-an-index)\n\n```sql\nREINDEX INDEX CONCURRENTLY t_idx;\n```\n\n## Constraints\n\n### Add a `CHECK` or `NOT NULL` Constraint\n\n[`ADD … NOT VALID`](https://www.postgresql.org/docs/18/sql-altertable.html) registers the constraint under a short `ACCESS EXCLUSIVE` without scanning existing rows\n\n[`VALIDATE CONSTRAINT`](https://www.postgresql.org/docs/18/sql-altertable.html) scans them later under `SHARE UPDATE EXCLUSIVE`\n\nNew rows are checked from the moment the constraint is added\n\n`ADD … NOT VALID` and `VALIDATE CONSTRAINT` must run in separate transactions — otherwise the validation scan inherits the `ACCESS EXCLUSIVE` held since `NOT VALID`, blocking the table for the full scan\n\nPostgreSQL 18 supports the same `ADD … NOT VALID` / `VALIDATE CONSTRAINT` pattern for `NOT NULL`, replacing the pre-18 `SET NOT NULL` form that required a table scan\n\n1. Deploy a code change to stop writing rows that would violate the constraint\n2. Register the not-valid constraint:\n\n   ```sql\n   -- CHECK\n   ALTER TABLE t ADD CONSTRAINT t_c CHECK (c \u003e 0) NOT VALID;\n   \n   -- NOT NULL (PG 18+)\n   ALTER TABLE t ADD CONSTRAINT t_c NOT NULL c NOT VALID;\n   ```\n3. Fix existing violating rows in batches, outside any transaction\n4. Validate the constraint on existing rows:\n\n   ```sql\n   ALTER TABLE t VALIDATE CONSTRAINT t_c;\n   ```\n\nAfter `VALIDATE` for `NOT NULL`, the column behaves as if declared `NOT NULL` at column level — the optimizer treats it the same, and no follow-up `SET NOT NULL` step is needed\n\nFor partitioned tables, validate each partition first, then attach to the parent\n\n### Add a `FOREIGN KEY` Constraint\n\n[`ADD FOREIGN KEY … NOT VALID`](https://www.postgresql.org/docs/18/sql-altertable.html) takes `SHARE ROW EXCLUSIVE` on both the referencing and referenced tables for the catalog update\n\n[`VALIDATE CONSTRAINT`](https://www.postgresql.org/docs/18/sql-altertable.html) takes `SHARE UPDATE EXCLUSIVE` on the referencing table and `ROW SHARE` on the referenced table\n\nNew rows are checked from the moment the constraint is added\n\nTwo rules follow from the lock profile:\n\n- One FK per transaction — acquiring `SHARE ROW EXCLUSIVE` on two tables in one transaction can cause a deadlock\n- `ADD … NOT VALID` and `VALIDATE CONSTRAINT` must run in separate transactions — otherwise the validation scan inherits the `SHARE ROW EXCLUSIVE` held since `NOT VALID`, blocking writes on both tables for the full scan\n\n1. Deploy a code change to stop writing rows that would violate the FK (e.g. orphan rows)\n2. Register the FK without validation:\n\n   ```sql\n   ALTER TABLE t1 ADD CONSTRAINT t1_c_fk FOREIGN KEY (c) REFERENCES t2 (id) NOT VALID;\n   ```\n3. Clean up violating rows in batches, outside any transaction\n4. Validate the existing rows:\n\n   ```sql\n   ALTER TABLE t1 VALIDATE CONSTRAINT t1_c_fk;\n   ```\n\nFor partitioned tables, validate each partition first, then attach to the parent\n\n### Drop a `NOT NULL` Constraint\n\nTakes a short `ACCESS EXCLUSIVE`, without a table rewrite\n\nIf the constraint was added as a named table constraint, drop it by name:\n\n```sql\nALTER TABLE t DROP CONSTRAINT t_c;\n```\n\nOr via the column-level form, which works regardless of how the constraint was added:\n\n```sql\nALTER TABLE t ALTER COLUMN c DROP NOT NULL;\n```\n\nFor partitioned tables, drop on the parent — partitions inherit it\n\n### Add a `UNIQUE` Constraint\n\n1. Create a new index:\n\n   Takes `SHARE UPDATE EXCLUSIVE`\n\n   ```sql\n   CREATE UNIQUE INDEX CONCURRENTLY t_c_uniq ON t (c);\n   ```\n\n   Note that `CREATE UNIQUE INDEX CONCURRENTLY` fails if any duplicates exist at build time, leaving an `INVALID` index behind\n2. Deploy a code change that rejects any new duplicate writes\n3. Convert an index to the constraint:\n\n   Takes a short `ACCESS EXCLUSIVE`\n\n   ```sql\n   ALTER TABLE t ADD CONSTRAINT t_c_uniq UNIQUE USING INDEX t_c_uniq;\n   ```\n\n## Enum Types\n\n### Add or Rename a Value\n\n[`ALTER TYPE`](https://www.postgresql.org/docs/18/sql-altertype.html) does not lock tables that reference the enum:\n\n```sql\nALTER TYPE my_enum ADD VALUE 'x';\nALTER TYPE my_enum RENAME VALUE 'a' TO 'b';\n```\n\nNote that `ADD VALUE` cannot run in the same transaction that later references the new value\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fdenpeshkov%2Fpostgresql-zero-downtime","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Fdenpeshkov%2Fpostgresql-zero-downtime","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fdenpeshkov%2Fpostgresql-zero-downtime/lists"}