The Postgres Lock Mode Cheat Sheet Nobody Gave You (All 8, Ranked)

All 8 PostgreSQL lock modes explained, plus the exact lock level for every ALTER TABLE, CREATE INDEX, and VALIDATE CONSTRAINT command.

The Postgres documentation on lock modes is thorough and precise. It is also 4,000 words long and assumes you already understand it.

Here’s the version I wish someone had given me two years ago: all 8 lock modes, what takes each one, the complete ALTER TABLE lock-level reference, and three specific footguns (an invalid index trap, a VALIDATE CONSTRAINT misconception, and a REFRESH MATERIALIZED VIEW CONCURRENTLY error) that nobody documents in one place.

The eight lock modes, ranked

Postgres has eight table-level lock modes. They’re ordered from weakest to strongest. A stronger lock conflicts with more things.

Lock ModeWhat takes itWhat it blocks
ACCESS SHARESELECTAlmost nothing
ROW SHARESELECT FOR UPDATEEXCLUSIVE, ACCESS EXCLUSIVE
ROW EXCLUSIVEINSERT, UPDATE, DELETE, MERGESHARE and above
SHARE UPDATE EXCLUSIVEVACUUM, CREATE INDEX CONCURRENTLYSHARE UPDATE EXCLUSIVE and above
SHARECREATE INDEX (non-concurrent)ROW EXCLUSIVE and above (blocks writes)
SHARE ROW EXCLUSIVECREATE TRIGGER, ADD CONSTRAINT ... FOREIGN KEYROW EXCLUSIVE and above
EXCLUSIVEREFRESH MATERIALIZED VIEW CONCURRENTLYROW SHARE and above
ACCESS EXCLUSIVEALTER TABLE (most), DROP TABLE, TRUNCATEEverything

The one that ruins your day is ACCESS EXCLUSIVE. It conflicts with every other lock mode, including ACCESS SHARE (which is what SELECT takes). That means while an ACCESS EXCLUSIVE lock is held, nobody can even read the table.

Every Postgres lock mode: what takes it, what it blocks

This is the complete reference, in the same weakest-to-strongest order as the table above. Every mode gets exactly one section: what acquires it, what it conflicts with, and why it matters for a migration.

ACCESS SHARE

Conflicts with ACCESS EXCLUSIVE only.

Acquired by:

  • SELECT, and in general any query that only reads a table

This is the lock nearly every read takes. It’s why a single unqualified SELECT can still get stuck behind a migration: it has to wait for any ACCESS EXCLUSIVE lock already queued on the same table (see “The lock queue problem” below).

ROW SHARE

Conflicts with EXCLUSIVE and ACCESS EXCLUSIVE.

Acquired by:

  • SELECT ... FOR UPDATE, FOR NO KEY UPDATE, FOR SHARE, or FOR KEY SHARE

Rare in migrations, common in application code that locks a row before updating it.

ROW EXCLUSIVE

Conflicts with SHARE, SHARE ROW EXCLUSIVE, EXCLUSIVE, and ACCESS EXCLUSIVE.

Acquired by:

  • INSERT, UPDATE, DELETE, and MERGE

This is what ordinary application traffic takes. It’s compatible with itself, so many concurrent writers can hold it at once. It only becomes a problem when it queues up behind a stronger lock that a pending migration is waiting to acquire.

SHARE UPDATE EXCLUSIVE

Conflicts with SHARE UPDATE EXCLUSIVE (itself), SHARE, SHARE ROW EXCLUSIVE, EXCLUSIVE, and ACCESS EXCLUSIVE. It does not conflict with ROW EXCLUSIVE, so ordinary reads and writes are never blocked by it.

Acquired by:

  • VACUUM (without FULL), ANALYZE, CREATE STATISTICS, COMMENT ON
  • CREATE INDEX CONCURRENTLY and REINDEX CONCURRENTLY
  • ALTER TABLE ... VALIDATE CONSTRAINT
  • ALTER TABLE ... SET STATISTICS, ALTER TABLE ... ATTACH PARTITION (on the parent), and a handful of storage parameter changes (fillfactor, TOAST and autovacuum settings, parallel_workers)

This is the lock every zero-downtime migration pattern is built to land on. It protects the table against concurrent schema changes and VACUUM runs, but it never blocks a read or a write, which is exactly why splitting a migration into NOT VALID plus VALIDATE CONSTRAINT, or reaching for CONCURRENTLY, is worth the extra step.

SHARE

Conflicts with ROW EXCLUSIVE, SHARE UPDATE EXCLUSIVE, SHARE ROW EXCLUSIVE, EXCLUSIVE, and ACCESS EXCLUSIVE.

Acquired by:

  • CREATE INDEX (without CONCURRENTLY)

This blocks every INSERT, UPDATE, and DELETE on the table for as long as the index build takes, a single table scan. On a table with live write traffic, this is the exact lock CREATE INDEX CONCURRENTLY exists to avoid, at the cost of scanning the table twice instead of once.

SHARE ROW EXCLUSIVE

Conflicts with ROW EXCLUSIVE, SHARE UPDATE EXCLUSIVE, SHARE, SHARE ROW EXCLUSIVE (itself), EXCLUSIVE, and ACCESS EXCLUSIVE. It does not conflict with ACCESS SHARE or ROW SHARE, so plain reads, including SELECT ... FOR UPDATE, keep running.

Acquired by:

  • CREATE TRIGGER
  • ALTER TABLE ... ADD CONSTRAINT ... FOREIGN KEY, on both the table being altered and the table it references
  • ALTER TABLE ... ENABLE TRIGGER and ALTER TABLE ... DISABLE TRIGGER
  • LOCK TABLE ... IN SHARE ROW EXCLUSIVE MODE, if you take it explicitly

Postgres’s own documentation describes SHARE ROW EXCLUSIVE as self-exclusive: only one session can hold it on a given table at a time. In practice, that means a second ADD CONSTRAINT ... FOREIGN KEY or a second CREATE TRIGGER on the same table has to wait for the first one to finish, even though plain reads are unaffected the whole time. It blocks every write (ROW EXCLUSIVE) and every VACUUM, ANALYZE, or CREATE INDEX CONCURRENTLY (SHARE UPDATE EXCLUSIVE) for as long as it’s held, which is why an unvalidated foreign key on a busy table is not the free lunch it looks like. See the NOT VALID pattern in the VALIDATE CONSTRAINT section below.

EXCLUSIVE

Conflicts with ROW SHARE, ROW EXCLUSIVE, SHARE UPDATE EXCLUSIVE, SHARE, SHARE ROW EXCLUSIVE, EXCLUSIVE (itself), and ACCESS EXCLUSIVE. Only ACCESS SHARE is compatible with it, so plain SELECTs proceed, but every write and every SELECT ... FOR UPDATE has to wait.

Acquired by:

  • REFRESH MATERIALIZED VIEW CONCURRENTLY

Despite the name, this is the second-strongest lock on the list, one step below ACCESS EXCLUSIVE. See “Why REFRESH MATERIALIZED VIEW CONCURRENTLY throws ‘cannot run inside a transaction block’” below for the error most people actually hit while using it, and why it isn’t really this lock’s fault.

ACCESS EXCLUSIVE

Conflicts with every lock mode, including itself and ACCESS SHARE. Nothing, not even a read, can proceed against the table while this lock is held.

Acquired by:

  • DROP TABLE, TRUNCATE, REINDEX (without CONCURRENTLY), CLUSTER, VACUUM FULL
  • REFRESH MATERIALIZED VIEW without CONCURRENTLY
  • Most forms of ALTER TABLE, the full breakdown is next
  • LOCK TABLE with no mode specified: ACCESS EXCLUSIVE is the default

This is the one that ruins your day, and the one worth understanding in the most detail, which is what the rest of this page is for.

Postgres ALTER TABLE lock levels: the complete reference

Postgres’s own documentation states the rule plainly: “An ACCESS EXCLUSIVE lock is acquired unless explicitly noted.” Every ALTER TABLE subcommand defaults to ACCESS EXCLUSIVE, and only a specific, documented set of exceptions get anything lighter. Here is every form, in one place.

ALTER TABLE subcommandLock modeWhy
ADD COLUMN (nullable, no default)ACCESS EXCLUSIVE (brief)Metadata-only, no rewrite
ADD COLUMN ... DEFAULT <constant> (PG11+)ACCESS EXCLUSIVE (brief)Default stored as catalog metadata, applied lazily on read
ADD COLUMN ... DEFAULT <volatile expression>ACCESS EXCLUSIVE (long)Forces a full table and index rewrite
ADD COLUMN ... GENERATED ... STORED, identity column, or a domain type with constraintsACCESS EXCLUSIVE (long)Also forces a full rewrite. A virtual generated column is the one exception: no rewrite
DROP COLUMNACCESS EXCLUSIVE (brief)Metadata-only marking. The column’s storage isn’t physically removed until the table is next rewritten
ALTER COLUMN TYPEACCESS EXCLUSIVE (usually long)Normally rewrites the table and its indexes. Skipped only when the USING clause doesn’t change the column’s contents and the old type is binary coercible to the new one, or an unconstrained domain over it
ALTER COLUMN SET NOT NULLACCESS EXCLUSIVEScans the table for NULLs unless a valid CHECK constraint already proves none exist, which skips the scan but not the lock
ALTER COLUMN DROP NOT NULLACCESS EXCLUSIVE (brief)Metadata-only
ADD CONSTRAINT ... CHECK / UNIQUE / PRIMARY KEY / EXCLUDEACCESS EXCLUSIVEHeld for the full validation scan or index build, unless NOT VALID applies
ADD CONSTRAINT ... CHECK ... NOT VALIDACCESS EXCLUSIVE (brief)Skips the scan, validated later
ADD CONSTRAINT ... FOREIGN KEYSHARE ROW EXCLUSIVE (both tables)The one constraint type documented as lighter than ACCESS EXCLUSIVE, NOT VALID or not
VALIDATE CONSTRAINTSHARE UPDATE EXCLUSIVESee the dedicated section below
RENAME COLUMN / RENAME CONSTRAINT / table renameACCESS EXCLUSIVE (brief)Metadata-only
OWNER TOACCESS EXCLUSIVE (brief)Metadata-only
SET TABLESPACEACCESS EXCLUSIVE (long)Copies the physical files, lock held for the duration
SET (fillfactor, toast.*, autovacuum.*, parallel_workers)SHARE UPDATE EXCLUSIVEExplicitly named as an exception in the documentation
SET STATISTICSSHARE UPDATE EXCLUSIVEExplicit exception
CLUSTER ON / SET WITHOUT CLUSTERSHARE UPDATE EXCLUSIVEExplicit exception. This only changes which index CLUSTER would use later; running CLUSTER itself takes ACCESS EXCLUSIVE
ATTACH PARTITIONSHARE UPDATE EXCLUSIVE on the parentPlus ACCESS EXCLUSIVE on the partition being attached and on the default partition, if any
DETACH PARTITIONACCESS EXCLUSIVEUnless CONCURRENTLY is specified
DETACH PARTITION CONCURRENTLYSHARE UPDATE EXCLUSIVE (first phase)On both the parent and the partition
ENABLE TRIGGER / DISABLE TRIGGERSHARE ROW EXCLUSIVEExplicit exception
REPLICA IDENTITYACCESS EXCLUSIVE (brief)Default rule, metadata-only
INHERIT / NO INHERITACCESS EXCLUSIVE (brief)Default rule
SET LOGGED / SET UNLOGGEDACCESS EXCLUSIVE (long)Default rule. This rewrites the table’s storage

Bookmark this table. It’s the answer to “what lock does my ALTER TABLE statement take” for every form Postgres supports. For the same treatment applied to index, sequence, type, and schema-level statements too, each one verified against a specific line of PostgreSQL source, see the DDL lock cheat sheet.

Why ALTER TABLE takes ACCESS EXCLUSIVE (and which commands don’t)

ACCESS EXCLUSIVE is ALTER TABLE’s default, not a special case, so the more useful question is why it isn’t needed for the handful of forms that escape it. Four patterns explain the whole table above.

  1. The table gets rewritten. Adding a column with a volatile default, a generated or identity column, or a domain type with constraints, and changing a column’s type in the general case, all rewrite every row (and usually every index). Nothing else can safely touch the table while that happens, so ACCESS EXCLUSIVE for the duration of the rewrite is unavoidable.
  2. The table gets a full validation scan, without NOT VALID. SET NOT NULL and ADD CONSTRAINT ... CHECK don’t rewrite the table, but Postgres still holds ACCESS EXCLUSIVE for the entire scan unless you use NOT VALID. Splitting the constraint into NOT VALID plus a separate VALIDATE CONSTRAINT trades one long ACCESS EXCLUSIVE hold for a near-instant one, plus a long SHARE UPDATE EXCLUSIVE scan that never blocks reads or writes. UNIQUE, PRIMARY KEY, and EXCLUDE constraints can’t use NOT VALID at all. UNIQUE and PRIMARY KEY have a safe path anyway: CREATE UNIQUE INDEX CONCURRENTLY followed by ADD CONSTRAINT ... UNIQUE USING INDEX (or ... PRIMARY KEY USING INDEX). EXCLUDE constraints have no such escape hatch. USING INDEX only accepts UNIQUE and PRIMARY KEY, so adding an EXCLUDE constraint always takes the full ACCESS EXCLUSIVE lock for the validation scan.
  3. Postgres deliberately relaxes it for one constraint type. ADD CONSTRAINT ... FOREIGN KEY is documented to take SHARE ROW EXCLUSIVE instead of ACCESS EXCLUSIVE, even without NOT VALID, because checking referential integrity only needs to block writes, not reads.
  4. Nothing else is special-cased. Plain metadata changes like RENAME, OWNER TO, DROP COLUMN, and ALTER COLUMN DROP NOT NULL don’t need a rewrite or a scan, but Postgres hasn’t carved out a lighter lock for them either. They still take ACCESS EXCLUSIVE, just for a fraction of a second, since the change itself is instant.

The commands actually worth memorizing as exceptions: ADD CONSTRAINT ... FOREIGN KEY and ALTER TABLE ... ENABLE/DISABLE TRIGGER take SHARE ROW EXCLUSIVE; VALIDATE CONSTRAINT, SET STATISTICS, ATTACH PARTITION, and a short list of storage parameters take SHARE UPDATE EXCLUSIVE. Everything else in ALTER TABLE is ACCESS EXCLUSIVE. For a closer look at just the constraint family, including the USING INDEX variants, see ADD CONSTRAINT lock modes are not one-size-fits-all.

VALIDATE CONSTRAINT lock mode: SHARE UPDATE EXCLUSIVE, not ACCESS EXCLUSIVE

ALTER TABLE ... VALIDATE CONSTRAINT takes a SHARE UPDATE EXCLUSIVE lock, not ACCESS EXCLUSIVE. This is the entire reason the NOT VALID pattern works.

Postgres’s documentation explains why: “The validation step does not need to lock out concurrent updates, since it knows that other transactions will be enforcing the constraint for rows that they insert or update; only pre-existing rows need to be checked. Hence, validation acquires only a SHARE UPDATE EXCLUSIVE lock on the table being altered.” For a foreign key specifically, the same page adds: “If the constraint is a foreign key then a ROW SHARE lock is also required on the table referenced by the constraint,” one of the weakest locks Postgres has, so the referenced table is barely touched either.

SHARE UPDATE EXCLUSIVE doesn’t conflict with ROW EXCLUSIVE, so ordinary INSERT, UPDATE, and DELETE traffic keeps running the entire time Postgres scans the table, which for a large table can take minutes. It does conflict with SHARE, SHARE ROW EXCLUSIVE, EXCLUSIVE, ACCESS EXCLUSIVE, and itself, so a second VALIDATE CONSTRAINT, CREATE INDEX CONCURRENTLY, or VACUUM on the same table still has to wait.

That’s why the safe pattern is two statements with two different lock modes:

-- Step 1: brief lock, skips the scan
ALTER TABLE orders
  ADD CONSTRAINT orders_customer_id_fkey
  FOREIGN KEY (customer_id) REFERENCES customers(id)
  NOT VALID;                                        -- SHARE ROW EXCLUSIVE, near-instant

-- Step 2: long scan, but blocks nothing
ALTER TABLE orders
  VALIDATE CONSTRAINT orders_customer_id_fkey;       -- SHARE UPDATE EXCLUSIVE

That only helps if the two statements run as two separate transactions. Combine them in one BEGIN ... COMMIT and the first statement’s lock is still held while the second one scans, since Postgres holds a lock until the transaction ends, not until the statement that acquired it finishes.

The same two-step split works for CHECK constraints too (the brief NOT VALID step takes ACCESS EXCLUSIVE there instead of SHARE ROW EXCLUSIVE, since CHECK isn’t the foreign-key exception) and, since PostgreSQL 18 extended NOT VALID and VALIDATE CONSTRAINT to cover not-null constraints, for those as well. It does not apply to UNIQUE, PRIMARY KEY, or EXCLUDE constraints: Postgres’s documentation is explicit that NOT VALID is “currently only allowed for foreign-key, CHECK, and not-null constraints.” For UNIQUE and PRIMARY KEY, use CREATE UNIQUE INDEX CONCURRENTLY plus ADD CONSTRAINT ... UNIQUE USING INDEX (or ... PRIMARY KEY USING INDEX) instead, which reaches the same non-blocking result through a different mechanism. EXCLUDE constraints have no equivalent workaround: USING INDEX only accepts UNIQUE and PRIMARY KEY, so adding an EXCLUDE constraint always takes ACCESS EXCLUSIVE for the full validation scan.

For a worked example against a large table, the exact pg_locks output while validation runs, and the one-transaction mistake that quietly undoes the whole pattern, see PostgreSQL VALIDATE CONSTRAINT: what lock mode it actually takes.

The CREATE INDEX CONCURRENTLY IF NOT EXISTS invalid index trap

A CREATE INDEX CONCURRENTLY that fails partway through doesn’t roll back cleanly. It leaves a broken index behind. Postgres documents this directly: “If a problem arises while scanning the table, such as a deadlock or a uniqueness violation in a unique index, the CREATE INDEX command will fail but leave behind an ‘invalid’ index. This index will be ignored for querying purposes because it might be incomplete; however it will still consume update overhead.”

That part is well known. The trap is what happens on the retry, when someone adds IF NOT EXISTS to make it safe to re-run:

CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_orders_customer_id
  ON orders (customer_id);

IF NOT EXISTS checks whether a relation with that name already exists in the schema. It does not check whether that relation is a valid, complete index. The invalid index left behind by the failed build still occupies its name in the catalog, exactly like a healthy one would, so the retry finds the name taken, prints a notice, and exits successfully without ever building a usable index. Nothing in the output distinguishes that from a genuinely healthy index being skipped, which means both the migration and any CI check that only looks at the exit code will report green.

Find any of these sitting in production with:

SELECT indexrelid::regclass AS index_name, indrelid::regclass AS table_name
FROM pg_index
WHERE NOT indisvalid;

And fix them the way Postgres recommends, not by re-running the original statement: “The recommended recovery method in such cases is to drop the index and try again to perform CREATE INDEX CONCURRENTLY. (Another possibility is to rebuild the index with REINDEX INDEX CONCURRENTLY.)”

DROP INDEX CONCURRENTLY idx_orders_customer_id;
CREATE INDEX CONCURRENTLY idx_orders_customer_id ON orders (customer_id);
-- or, equivalently:
REINDEX INDEX CONCURRENTLY idx_orders_customer_id;

For the full mechanics, including how a broken unique index can keep enforcing its constraint even while it’s invisible to the query planner, and a review checklist for catching this before it ships, see CREATE INDEX CONCURRENTLY IF NOT EXISTS does not fix a broken index.

Why REFRESH MATERIALIZED VIEW CONCURRENTLY throws “cannot run inside a transaction block”

Start with what’s actually true: REFRESH MATERIALIZED VIEW CONCURRENTLY itself has no transaction-block restriction. It takes an EXCLUSIVE lock (see above), and it runs its diff-and-merge entirely inside the current transaction. Unlike CREATE INDEX CONCURRENTLY, DROP INDEX CONCURRENTLY, REINDEX CONCURRENTLY, and ALTER TABLE ... DETACH PARTITION CONCURRENTLY, the refresh command never triggers Postgres’s transaction-block check. You can wrap it in BEGIN ... COMMIT, and it runs, and rolls back cleanly, exactly like an ordinary UPDATE.

So when this error shows up next to a REFRESH MATERIALIZED VIEW CONCURRENTLY statement, it’s coming from something else in the same migration or transaction, most often the CREATE UNIQUE INDEX CONCURRENTLY that the refresh depends on. REFRESH MATERIALIZED VIEW CONCURRENTLY only works on a view that already has “at least one UNIQUE index… which uses only column names and includes all rows,” per the documentation, and building that index without blocking writes means CREATE UNIQUE INDEX CONCURRENTLY, which is one of the statements that genuinely can’t run inside a transaction block. A migration file, or a migration tool that wraps every statement in one implicit transaction (Rails, Django, Flyway, or plain psql -1), that contains both statements back to back fails on the index line, not the refresh line, and it’s easy to misread the failure as belonging to the refresh instead. The other usual suspects are a DROP INDEX CONCURRENTLY, REINDEX CONCURRENTLY, or DETACH PARTITION CONCURRENTLY sitting elsewhere in the same migration.

The fix either way: run the statement that actually needs its own transaction as its own migration step, and leave REFRESH MATERIALIZED VIEW CONCURRENTLY inside your migration tool’s normal transaction wrapper. It doesn’t need the opt-out, and removing the wrapper only removes your rollback safety net.

For the full source-level proof, a live Postgres session showing the refresh commit and roll back cleanly, and the two real (non-transaction) preconditions that can still make CONCURRENTLY fail, see REFRESH MATERIALIZED VIEW CONCURRENTLY runs fine inside a transaction block.

The lock queue problem

Here’s the part most people miss. Locks don’t just block, they queue.

Say you run ALTER TABLE users ADD COLUMN ... (ACCESS EXCLUSIVE). It can’t acquire the lock immediately because there are active SELECT queries on the table. So it waits. But here’s the problem: while it’s waiting, every new query that needs any lock on that table also waits behind it.

Active queries:  [SELECT (ACCESS SHARE)]  ← running
Lock queue:      [ALTER TABLE (ACCESS EXCLUSIVE)]  ← waiting
                 [SELECT (ACCESS SHARE)]  ← blocked by ALTER TABLE in queue
                 [SELECT (ACCESS SHARE)]  ← also blocked
                 [INSERT (ROW EXCLUSIVE)]  ← also blocked

Your ALTER TABLE is waiting for existing queries to finish. But new queries are piling up behind it. If the existing queries take a while (maybe a complex report or a slow analytical query), your entire table becomes unreachable for the duration.

This is why SET lock_timeout is not optional. Without it:

-- This might wait forever if a long-running query is active
ALTER TABLE users ADD COLUMN email_verified boolean;

With it:

SET lock_timeout = '2s';
-- If the lock isn't acquired within 2 seconds, the statement fails
-- instead of blocking your entire table indefinitely
ALTER TABLE users ADD COLUMN email_verified boolean;

If the migration fails, you retry during a quieter window. Much better than blocking all traffic while you wait.

Safe alternatives for common operations

Dangerous PatternSafe AlternativeLock Difference
ADD COLUMN ... NOT NULL DEFAULTAdd nullable → backfill → CHECK NOT VALID → VALIDATE → SET NOT NULLLong ACCESS EXCLUSIVE rewrite → brief metadata locks plus SHARE UPDATE EXCLUSIVE validation
CREATE INDEXCREATE INDEX CONCURRENTLYSHARE → SHARE UPDATE EXCLUSIVE
ADD CONSTRAINT ... FOREIGN KEYADD CONSTRAINT ... NOT VALID + VALIDATE CONSTRAINTLong SHARE ROW EXCLUSIVE scan → brief metadata lock plus SHARE UPDATE EXCLUSIVE validation
ADD CONSTRAINT ... UNIQUECREATE UNIQUE INDEX CONCURRENTLY + ADD CONSTRAINT ... USING INDEXLong ACCESS EXCLUSIVE inline build to concurrent build plus brief ACCESS EXCLUSIVE attach
ALTER COLUMN TYPEAdd new column → backfill → swap → drop oldACCESS EXCLUSIVE long rewrite → brief metadata locks plus batched ROW EXCLUSIVE work

Look up any single pattern

Prefer to jump straight to one statement instead of scanning the tables above? Every pattern in this post, plus the constraint, partition, trigger, and row-level-security patterns pgfence also checks, has its own page with the lock mode, what it blocks, and a safe rewrite: the Postgres lock mode reference.

Use this as a CI check

You don’t need to memorize this table. pgfence does the lookup for you:

npx @flvmnt/pgfence analyze migrations/*.sql

It maps every statement to the correct lock mode and tells you what’s blocked. If you want a stricter CI gate than the default:

npx @flvmnt/pgfence analyze --ci --max-risk low migrations/*.sql

The lock mode matrix is the foundation. Everything else, risk levels, safe rewrites, policy checks, is built on top of it.

← All posts