Skip to content

Postgres Locks and the Change Classifier

Every change between two Postgres schemas is classified before anything runs, by the lock it takes and whether it reads or rewrites the table, as the Postgres 18 reference states them. Where 14 to 17 differ, the rule says so.

ClassMeansDisruption
createa new object; nothing that exists changesin-place
metadata onlya catalog change under a brief lock; no row is read or writtenin-place
validates under a weaker lockevery row is read to check a constraint, under a lock weaker than ACCESS EXCLUSIVErolling
needs CONCURRENTLYan index build or drop that blocks writes unless it is CONCURRENTLY, which cannot run in a transaction blockrolling
ACCESS EXCLUSIVE rewrite or scanthe table is rewritten or read in full while reads and writes waitrolling
expand and contractno in-place change keeps old readers working; a plan refuses itreplace
dropthe object, and a table’s or sequence’s data, is gonedestroy

The disruption column is what chant lifecycle plan reports for each update. A changed path alone cannot always say which rule applies (a column’s type, a constraint, a view’s query, an enum’s labels), and those report unknown there; chant sql plan has both definitions and classifies them.

Terminal window
chant sql diff base.json head.json # two chant build outputs, offline
chant sql plan prod schema.json # a build output against the prod server

Both read the build’s dialect and exit 2 when a change can only be made as expand and contract. --json prints the changes, the hints, the refused changes and the migration Op suggestions as one document. chant sql diff compares a pull request’s base and head builds with no server. chant sql plan reads the server sql.profiles.<env> binds (else POSTGRES_URL), in the profile’s schemas when it lists them, otherwise in the default schema and every schema the build declares an object in. Where the normalization rules leave an expression different, it asks that server: the declared view, or the table’s column types, defaults, generated expressions and checks, are created as temporary objects in a transaction that is always rolled back, and read back with the same printers, so a view’s SELECT * and the server’s added parentheses are not reported as changes.

chant sql diff base.json head.json --statements prints the statements that take the base schema to the head one, offline: each one the applier would send for the same change, after a comment naming its object, rule and class, and whether it has to run outside a transaction block. --json prints them as one document, which diffStatements(before, after) from @intentius/chant-lexicon-sql also returns, each statement with transactional set:

-- users (app.users): SQLPG201 metadata
ALTER TABLE app.users ADD COLUMN name text DEFAULT 'none' NOT NULL;
-- usersEmail (app.users_email_idx): SQLPG240 concurrently, outside a transaction
CREATE INDEX CONCURRENTLY users_email_idx ON app.users (email);
-- orders (app.orders): SQLPG205 made by PostgresMigrationOp, not a statement:
-- export const { op } = PostgresMigrationOp({ name: "migrate-app-orders-reference", env: "<env>", table: "app.orders", column: "reference" });

A column rename or a type change across kinds is never DDL: it is a step naming PostgresMigrationOp, one per column. Any other expand-and-contract change, and the refused table’s other changes, which the applier also holds back, are a manual step. A column or object drop is a statement marked destructive. Statements use the default schema for a bare name, as the applier sets search_path. The command exits 2 when a step is not a statement. What the applier asks the server is left out: its normalization of expressions the rules leave different, and an extension’s own comment.

Two rules change class across the supported majors: a STORED generated column’s expression (SQLPG212) is a rewrite from 17 and expand and contract before it, and a table’s access method (SQLPG226) is a rewrite from 15 and expand and contract before it.

  • chant sql diff classifies for the major the builds recorded (postgresMajor in the build output): the newer build’s, else the older build’s, else sql.postgresMajor, else 18. A diff between two revisions does not depend on today’s config.
  • chant sql plan classifies for the major the server runs (server_version_num), since the server is what takes the locks. When the build targets another major, the plan says so in a hint.

An object is identified by its export name between two builds, and by its schema-qualified name against a server, which has no export names. Tables, views, materialized views, sequences and indexes share one namespace per schema, types and domains another, and schemas and extensions are matched by name. Functions and procedures share a namespace of their own and are told apart by their input parameter types, so an overload is another object; a trigger is matched by its name on its table.

A rename is declared where it happens. Before a CREATE, -- previously: <old name> says the object had another name; on a column’s line, it says the column did:

CREATE TABLE app.orders (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
memo text -- previously: note
)

Without the hint, a column dropped and another added with the same type in the same place are reported as a drop and an add, with a hint asking whether it is a rename. A constraint left unnamed is matched by what it says, not by the name Postgres gave it, and an index on a table the same plan creates is part of the create.

An object another tool keeps is never proposed for a drop: an ORM’s or migration runner’s revision table with its sequence and indexes, and, with sql.provider set, the provider’s own schemas and extensions. The plan names each in a hint and leaves it alone. Any other object in the schemas the plan reads that the build does not declare is a drop (SQLPG270); the applier drops it only when told to prune and only when it carries this project’s marker.

The plan only reports. postgresApply makes every change that is not expand and contract, grouping the statements into transactions by class and running CONCURRENTLY builds outside them. A column rename (SQLPG205) or a type change across kinds (SQLPG208) runs as PostgresMigrationOp, and both commands print the declaration to start from for each such column (migrationOps in --json): see Migrating a Column. The other expand-and-contract changes (a NOT NULL column with no default, a view losing or reordering columns, partitioning, a rename of a table, an enum label removed) have no Op yet and are made by hand.

IdClassChangeRestriction
SQLPG200createCreate an objectA new object is created; nothing existing changes. A table created in the same plan as its indexes needs no CONCURRENTLY. Postgres 18 docs
SQLPG201metadata onlyAdd a columnADD COLUMN with no default, or with a non-volatile default, changes only the catalog under ACCESS EXCLUSIVE: the default is stored once and read for existing rows (since 11, so in every supported major). A NOT NULL column needs that default. Postgres 18 docs
SQLPG202ACCESS EXCLUSIVE rewrite or scanAdd a column whose value has to be computed for every rowADD COLUMN with a volatile default (clock_timestamp(), random(), gen_random_uuid(), nextval()), a STORED generated column or an identity column rewrites the whole table under ACCESS EXCLUSIVE. A VIRTUAL generated column (18) does not. Postgres 18 docs
SQLPG203EXPAND AND CONTRACTAdd a NOT NULL column with no defaultADD COLUMN … NOT NULL with no default fails on a table that has rows. Add it nullable, backfill it, then set NOT NULL. Postgres 18 docs
SQLPG204metadata onlyDrop a columnDROP COLUMN only hides the column in the catalog under ACCESS EXCLUSIVE; its space is reclaimed as rows are rewritten. The data is gone. Postgres 18 docs
SQLPG205EXPAND AND CONTRACTRename a columnRENAME COLUMN is a catalog change, but every reader still using the old name fails the moment it runs: add the new column, write both, move readers, drop the old. Postgres 18 docs
SQLPG206metadata onlyChange a column’s type without a rewriteALTER COLUMN TYPE to a binary-coercible type (varchar(n) to a longer varchar or to text, numeric(p,s) to a wider precision or to unconstrained numeric, cidr to inet) needs no rewrite; indexes on the column may still be rebuilt. ACCESS EXCLUSIVE, briefly. Postgres 18 docs
SQLPG207ACCESS EXCLUSIVE rewrite or scanChange a column’s type with a rewriteALTER COLUMN TYPE that is not binary-coercible (integer to bigint, a shorter varchar, real to double precision) rewrites the table and rebuilds its indexes under ACCESS EXCLUSIVE. Postgres 18 docs
SQLPG208EXPAND AND CONTRACTChange a column’s type across kindsA type change between kinds (text to integer, a type to an enum) needs a USING expression and breaks readers that expect the old type: add the new column, backfill, move readers, drop the old. Postgres 18 docs
SQLPG209metadata onlyChange a column’s defaultSET DEFAULT and DROP DEFAULT change only the catalog; existing rows keep their values. Postgres 18 docs
SQLPG210validates under a weaker lockSet NOT NULLSET NOT NULL alone reads the whole table under ACCESS EXCLUSIVE, unless a valid CHECK (column IS NOT NULL) constraint already proves it (since 12). So it is made as four statements: that check added NOT VALID, validated under SHARE UPDATE EXCLUSIVE while writes go on, SET NOT NULL, which the check proves without a scan, and the check dropped. Rows where the column is NULL fail the validation; the first statement’s pre-check counts them. Postgres 18 docs
SQLPG211metadata onlyDrop NOT NULLDROP NOT NULL changes only the catalog. Postgres 18 docs
SQLPG212ACCESS EXCLUSIVE rewrite or scanChange a generated column’s expressionALTER COLUMN SET EXPRESSION (17 and later) rewrites a STORED generated column under ACCESS EXCLUSIVE; on 14 to 16 the column has to be dropped and added. A VIRTUAL column (18) changes only the catalog. Postgres 18 docs
SQLPG213metadata onlyChange a column’s identityADD, SET and DROP IDENTITY change the column’s sequence and the catalog, not the rows. Postgres 18 docs
SQLPG214ACCESS EXCLUSIVE rewrite or scanChange a column’s collationA collation change is ALTER COLUMN TYPE with COLLATE: the column’s indexes are rebuilt under ACCESS EXCLUSIVE. Postgres 18 docs
SQLPG215metadata onlyChange a column’s storage or compressionSET STORAGE and SET COMPRESSION apply to values written afterwards; existing values are not rewritten. Postgres 18 docs
SQLPG216metadata onlyChange a commentCOMMENT ON changes only the catalog. Postgres 18 docs
SQLPG217metadata onlyAdd a constraint NOT VALIDADD CONSTRAINT … NOT VALID checks new rows only and reads none of the existing ones; a foreign key takes SHARE ROW EXCLUSIVE on both tables, briefly. Validate it as a separate step. Postgres 18 docs
SQLPG218validates under a weaker lockAdd a CHECK constraintADD CONSTRAINT … CHECK alone reads every row under ACCESS EXCLUSIVE. On a table that exists it is made as two statements: the check added NOT VALID (SQLPG217), which reads no rows, then VALIDATE CONSTRAINT (SQLPG220) in a transaction of its own, which reads them under SHARE UPDATE EXCLUSIVE while writes go on. Rows the check refuses fail the validation; the first statement’s pre-check counts them. Postgres 18 docs
SQLPG219validates under a weaker lockAdd a foreign keyADD FOREIGN KEY alone reads every row under SHARE ROW EXCLUSIVE on both tables, which blocks writes to them for the scan. On a table that exists it is made as two statements: the key added NOT VALID (SQLPG217), which takes that lock briefly and reads no rows, then VALIDATE CONSTRAINT (SQLPG220) in a transaction of its own, which reads them under SHARE UPDATE EXCLUSIVE while writes go on. Orphan rows fail the validation; the first statement’s pre-check counts them. Postgres 18 docs
SQLPG220validates under a weaker lockValidate a constraintVALIDATE CONSTRAINT reads every row under SHARE UPDATE EXCLUSIVE, so reads and writes go on (ROW SHARE on a foreign key’s referenced table). Postgres 18 docs
SQLPG221needs CONCURRENTLYAdd a primary key or unique constraintADD PRIMARY KEY or UNIQUE builds its index under ACCESS EXCLUSIVE. Build the unique index CONCURRENTLY first, then ADD CONSTRAINT … USING INDEX, which changes only the catalog. Postgres 18 docs
SQLPG222ACCESS EXCLUSIVE rewrite or scanAdd an exclusion constraintADD CONSTRAINT … EXCLUDE builds its index under ACCESS EXCLUSIVE; there is no concurrent form. Postgres 18 docs
SQLPG223metadata onlyDrop a constraintDROP CONSTRAINT changes the catalog under ACCESS EXCLUSIVE, briefly; a primary key’s or unique constraint’s index goes with it. Postgres 18 docs
SQLPG224metadata onlyChange storage parametersSET and RESET of storage parameters change the catalog; fillfactor and the autovacuum parameters take only SHARE UPDATE EXCLUSIVE. Rows are not rewritten; a new fillfactor applies to pages written afterwards. Postgres 18 docs
SQLPG225ACCESS EXCLUSIVE rewrite or scanMake a table LOGGED or UNLOGGEDSET LOGGED and SET UNLOGGED rewrite the table under ACCESS EXCLUSIVE. Postgres 18 docs
SQLPG226ACCESS EXCLUSIVE rewrite or scanChange a table’s access method or tablespaceSET ACCESS METHOD (15 and later) and SET TABLESPACE rewrite the table under ACCESS EXCLUSIVE. Postgres 18 docs
SQLPG227EXPAND AND CONTRACTChange partitioning or inheritanceA table’s partition key, its parent or bound, INHERITS and OF type cannot be altered in place: a new table is filled and swapped in. DETACH PARTITION CONCURRENTLY (14 and later) and ATTACH PARTITION, which takes SHARE UPDATE EXCLUSIVE on the parent, are the steps. Postgres 18 docs
SQLPG228EXPAND AND CONTRACTRename an object or move it to another schemaRENAME and SET SCHEMA are catalog changes, but every reader still using the old name fails at once: create the new name (a view over the old object works), move readers, drop the old. Postgres 18 docs
SQLPG229metadata onlyRename an indexALTER INDEX … RENAME changes only the catalog; nothing reads an index by name. Postgres 18 docs
SQLPG240needs CONCURRENTLYCreate an index CONCURRENTLYCREATE INDEX CONCURRENTLY builds the index without blocking writes (SHARE UPDATE EXCLUSIVE), in two scans, and cannot run inside a transaction block; a failed build leaves an INVALID index to drop. Postgres 18 docs
SQLPG241needs CONCURRENTLYCreate an index without CONCURRENTLYCREATE INDEX on an existing table takes SHARE, which blocks every write to the table until the build ends. Declare it CONCURRENTLY. Postgres 18 docs
SQLPG242needs CONCURRENTLYDrop an indexDROP INDEX takes ACCESS EXCLUSIVE on the table; DROP INDEX CONCURRENTLY waits for running queries instead, and cannot run inside a transaction block. Postgres 18 docs
SQLPG243needs CONCURRENTLYChange an indexAn index’s definition cannot be altered: it is dropped and created, both CONCURRENTLY to keep writes going (SQLPG240, SQLPG242). Postgres 18 docs
SQLPG250metadata onlyChange a view’s query, keeping its columnsCREATE OR REPLACE VIEW replaces the query under ACCESS EXCLUSIVE on the view when the new query keeps the old columns, by name, in order, and only appends new ones. Postgres 18 docs
SQLPG251EXPAND AND CONTRACTChange a view’s columnsA view whose columns are removed, renamed or reordered cannot be replaced: it is dropped and created, and every view and reader on top of it with it. Postgres 18 docs
SQLPG252ACCESS EXCLUSIVE rewrite or scanChange a materialized view’s queryA materialized view’s query cannot be altered: it is dropped and created, and its rows are computed again, with every view on top of it dropped too. Postgres 18 docs
SQLPG253metadata onlyChange a view’s optionsALTER VIEW … SET (check_option, security_barrier, security_invoker) changes only the catalog. Postgres 18 docs
SQLPG260metadata onlyAdd an enum labelALTER TYPE … ADD VALUE changes only the catalog; since 12 it can run in a transaction block, but the new label cannot be used in that transaction. Postgres 18 docs
SQLPG261EXPAND AND CONTRACTRemove or reorder enum labelsAn enum label cannot be removed and labels cannot be reordered in place: a new type is created, columns are moved to it, and the old one is dropped. Postgres 18 docs
SQLPG262validates under a weaker lockAdd a domain constraintALTER DOMAIN ADD CONSTRAINT (and SET NOT NULL) checks every column of the domain’s type in every table; ADD CONSTRAINT … NOT VALID skips existing rows and VALIDATE CONSTRAINT checks them later. Postgres 18 docs
SQLPG263metadata onlyChange a domain’s default or drop its constraintALTER DOMAIN SET DEFAULT, DROP NOT NULL and DROP CONSTRAINT change only the catalog. Postgres 18 docs
SQLPG264EXPAND AND CONTRACTChange a domain’s data typeA domain’s underlying type cannot be altered: a new domain is created and columns are moved to it. Postgres 18 docs
SQLPG265metadata onlyChange a sequence’s optionsALTER SEQUENCE changes the sequence’s own row; tables are not touched. A sequence’s AS type bounds its values. Postgres 18 docs
SQLPG266metadata onlyChange an extension’s version or schemaALTER EXTENSION UPDATE runs the extension’s update script, which may itself alter objects; SET SCHEMA moves its objects. Postgres 18 docs
SQLPG267metadata onlyChange a schema’s ownerALTER SCHEMA … OWNER TO changes only the catalog. Postgres 18 docs
SQLPG268EXPAND AND CONTRACTChange an object’s kindA view and a materialized view, or a table and either, are different kinds of object: the old one is dropped and the new one created. Postgres 18 docs
SQLPG280metadata onlyReplace a function’s or procedure’s definitionCREATE OR REPLACE FUNCTION (or PROCEDURE) replaces the body and attributes in the catalog; it locks no table, and a call already running finishes with the old definition. The parameter types, the result and the input parameters’ names have to stay the same. Postgres 18 docs
SQLPG281metadata onlyDrop and create a function or procedureCREATE OR REPLACE refuses a change of the result type, the output parameters, an input parameter’s name, a removed parameter default or the routine’s kind: the routine is dropped and created in one transaction. Nothing on the server depends on it (a view, a trigger, a column default would make the DROP fail). Postgres 18 docs
SQLPG282EXPAND AND CONTRACTChange a function’s result or parameters while other objects depend on itDROP FUNCTION refuses while a view, a trigger, a column default or another routine depends on the function, and CREATE OR REPLACE cannot make this change. Create the new definition under a new name (or signature), move what depends on it, then drop the old. Postgres 18 docs
SQLPG283metadata onlyCreate a trigger on an existing tableCREATE TRIGGER takes SHARE ROW EXCLUSIVE on its table: writes and other schema changes to the table wait until the transaction commits, but no row is read. The applier’s lock_timeout bounds the wait behind long-running transactions. Postgres 18 docs
SQLPG284metadata onlyChange a triggerCREATE OR REPLACE TRIGGER (14 and later) replaces the trigger under SHARE ROW EXCLUSIVE on its table. A constraint trigger has no OR REPLACE, and a trigger moved to another table is another trigger: each is dropped (ACCESS EXCLUSIVE, briefly) and created in one transaction. Postgres 18 docs
SQLPG285dropDrop a triggerDROP TRIGGER takes ACCESS EXCLUSIVE on its table, briefly; no row is read or lost, and the table’s writes no longer fire it. Postgres 18 docs
SQLPG290metadata onlyCreate a policy on an existing tableCREATE POLICY takes ACCESS EXCLUSIVE on its table, briefly; no row is read. Where row-level security is on, the rows each role sees change when the transaction commits. Postgres 18 docs
SQLPG291metadata onlyChange a policyALTER POLICY changes its roles, USING and WITH CHECK under ACCESS EXCLUSIVE on the table, briefly. Its command, AS RESTRICTIVE and table cannot be altered, nor an expression taken away: the policy is dropped and created in one transaction, so no session sees the table without it. Postgres 18 docs
SQLPG292dropDrop a policyDROP POLICY takes ACCESS EXCLUSIVE on its table, briefly; the rows it let a role see are hidden from that role at once, or, if it was the last policy, every non-owner sees none. Postgres 18 docs
SQLPG293metadata onlyTurn row-level security on or off, or force it on the ownerALTER TABLE … ENABLE, DISABLE, FORCE or NO FORCE ROW LEVEL SECURITY changes only the catalog, under ACCESS EXCLUSIVE, briefly. With it on and no policy that lets a role in, that role sees no rows; FORCE subjects the table’s owner too. Postgres 18 docs
SQLPG294metadata onlyChange a role’s attributesALTER ROLE changes the role in the cluster’s catalog, for every database on the server, at once; sessions already open keep the attributes they started with until they reconnect. Postgres 18 docs
SQLPG296metadata onlyGrant a privilegeGRANT adds to the object’s privileges in the catalog; no row is read and the role can use it from its next statement. Postgres 18 docs
SQLPG297metadata onlyRevoke a privilegeREVOKE removes from the object’s privileges in the catalog. The role loses it at once: its next statement that needs it fails, so move every reader off it first. Postgres 18 docs
SQLPG298metadata onlyChange default privilegesALTER DEFAULT PRIVILEGES changes what objects created afterwards are given; objects that exist keep their privileges. Postgres 18 docs
SQLPG270dropDrop an objectThe object and, for a table, a materialized view or a sequence, its data are gone. Postgres 18 docs