CLI — open source, MIT
The outage rules and credential hygiene, run locally, JSON output for scripting. Never phones home; --remote records the check in the run log, and brings in the full corpus on Startup and above.
69 Postgres rules at corpus v0.9.0 today, and an honest map of where the verification layer goes from here. A green check is live; everything else is labeled for what it is.
One command, no account, no config. The open-source (MIT) CLI runs the free rules locally — the outage core and credential hygiene; the hosted service runs the same rules on Free with the run log and PR comments, and the rest of the corpus, live-schema context and review on a paid plan.
npx bolvrk check migration.sql
# with live-schema context from your own shadow database (Startup and above)
npx bolvrk check migration.sql --db=$SHADOW_DB
# through the cloud, credential-free — structure only, never rows
BOLVRK_TOKEN=blv_... npx bolvrk check migration.sql --db=$SHADOW_DB --remote
# the same credential rules over any file — always local, never uploaded
npx bolvrk secrets "src/**" ".env*"The outage rules and credential hygiene, run locally, JSON output for scripting. Never phones home; --remote records the check in the run log, and brings in the full corpus on Startup and above.
Add your team token as the BOLVRK_TOKEN repository secret and findings land on every pull request that touches a migration — one evolving comment, not a pile.
0 clean, 1 findings, 2 usage or parse error, 3 service or database unavailable — wire it into any pipeline in one line.
The heart of Bolvrk: verification over real parse trees, never regex, never heuristics — so it can judge SQL from any author, human or AI.
Locks that block writes, full-table rewrites, constraints that fail only on populated tables, destructive operations, replication hazards, column-type traps, credentials written into the migration.
libpg_query — the exact parser Postgres uses. Keywords inside strings or comments can never trigger a false alarm.
critical fails production; warning blocks or rewrites at scale; note means "this will work — and then likely regress performance".
Every rule ships with dangerous and safe-lookalike fixtures; lock and rewrite claims are validated empirically against Postgres 13–17 in CI.
A standing near-miss battery: safe SQL one token from every blocked pattern, asserted silent on every run. When we cannot prove a claim, we stay silent.
Seeded fuzzing over every untrusted input: arbitrary SQL and hostile snapshots must produce a report or a clean parse error — never a crash.
Sequence exhaustion projections, partition attach/detach hazards, operational safety, replication traps — each with fixtures both directions or not at all.
Plaintext role passwords, user-mapping and subscription credentials, connection strings with embedded passwords, vendor-format API keys and private keys, literals seeded into secret-named columns. Every finding masks the value — the report never repeats a secret.
The same credential rules over .env, YAML, JSON, source and shell — line-indexed findings in the same contract, same policy overrides, SARIF for code scanning. Always local: a credential scan never leaves the machine.
Redundant indexes, boolean-led indexes, B-tree where GIN belongs, foreign keys losing their covering index, index pile-up on hot tables. Advisory notes that never block unless your policy says so.
Read-only EXPLAIN of the migration's own queries against the connected schema — sequential scans of large tables, nested loops over sequential scans, large sorts. Never executes a statement.
Connected checks read the shape and traffic of your real schema — and go silent when it proves an operation safe. Structure only: never rows, never values, never a credential.
# audit every byte before it leaves your machine
npx bolvrk check migration.sql --db=$SHADOW_DB --remote --show-payloadRules escalate on large tables and silence themselves on provably tiny or empty ones — table sizes, foreign keys, and indexes from the catalogs. Hosted, Startup and above.
Write-traffic counters from pg_stat: a lock on a hot table escalates even when the table is small — a brief lock still queues a damaging backlog. Hosted, Startup and above.
The CLI introspects the database it already has access to and pushes only structure. Our cloud never holds a database credential. Hosted, Startup and above.
--show-payload prints the exact request body and exits without sending. Enforced by tests: a planted secret row value can never appear in a snapshot.
For zero-setup CI: sealed-box encrypted connection strings, opened per-check, read-only, catalogs only. 2 on Startup, 25 on Scale, custom on Enterprise.
Postgres first and deepest. The seam for more engines exists — one findings contract, one severity taxonomy, a dedicated parser and corpus per engine, each held to the same evidence bar.
The first corpus, tested against a five-major-version matrix on every change.
Parked — not in active development. The danger model is mapped: online DDL ALGORITHM traps, implicit commits, charset rewrites, replication-unsafe statements, destructive partition operations.
Parked — not in active development. A different danger model: most ALTER TABLE forms rewrite the whole table, the 12-step migration dance, foreign_keys pragma pitfalls, WAL locking.
Schemaless-to-SCHEMAFULL transitions, DEFINE TABLE and FIELD changes over populated data, index builds, permission changes.
Every connected run remembered — what went wrong, how often, trending where. The run log, PR comments and notifications are on every plan, Free included; validation is never metered (AI reviews: 500 per month on Scale). The cloud sells the full corpus, context and memory.
Every check from every connected client recorded with its findings — the history your team relies on when something does go wrong. Every plan, Free included.
Which rules fire most, how the clean-run rate trends, which days went sideways. Recurring findings are process problems — now visible. Startup and above.
State streams to the dashboard over SSE — checks, connections, reviews appear as they happen, nothing polls.
Admin and member roles, GitHub sign-in with zero extra scopes, instant per-token revocation.
Platform teams pick the severity that fails CI — critical only, warning and above, or any finding — and tune or silence any rule, org-wide, from the dashboard. Hosted, Scale and above.
Stored connections are polled for reachability every 15 minutes — structure only. A check whose connection has since gone unreachable carries a stale-schema warning on its page.
"This finding is wrong" on any check page. Disputes are counted per rule in Insights and feed the false-positive discipline.
Findings and clean-run summaries where the team already looks — Slack and Discord incoming webhooks, or any HTTPS endpoint with HMAC-signed payloads.
Enterprise account controls for regulated teams.
We are here to bring trust to the development workflow, no matter who — or what — makes the changes. AI advises here; it never decides.
Point any agent at bolvrk check --json and it has a deterministic verifier to iterate against: your model proposes, our checker judges. Validation is never metered; our own AI reviews are 500 per month on Scale.
A second pair of eyes beyond the rules — intent, ordering, backfill strategy. Clearly labeled, never blocks a check, never part of the verdict. Hosted, Scale and above: 500 per month.
A principle, enforced in architecture: no model output — ours included — can pass or fail a check, and Bolvrk never generates SQL.
Bolvrk as a first-class tool inside Claude, Cursor, and agent frameworks: agents call the verifier directly and read structured findings.
The mission is bigger than migrations: deterministic verification wherever AI-driven change can hurt a business from within.
Batching, transaction scope, idempotency, lock duration against live traffic stats — the UPDATE scripts AI writes alongside every schema change.
Plan-regression checks for changed queries against live schema statistics — the read-side twin of "the migration parsed fine".
Unpinned actions, permission escalations, injection through untrusted inputs — rules for the pipelines AI increasingly edits.
Breaking-change detection over OpenAPI, GraphQL, and protobuf diffs, with deploy-window awareness.
"Parses fine, production disagrees" is the everyday IAM experience — wildcard escalations and privilege widening are rule-shaped.
Every rule ships with fixture migrations in both directions — dangerous variants that must fire, and safe look-alikes that must stay silent. Lock and rewrite claims are validated empirically against live Postgres before a rule ships. The BC family catches credentials before they are committed — and runs over any file with bolvrk secrets. Each rule has its own page with the full rationale.
Dropping a column that other tables' foreign keys point at either fails mid-migration or, with CASCADE, silently drops those constraints — leaving orphanable rows in dependent tables.
ALTER TABLE users DROP COLUMN id;Adding a NOT NULL column with no default fails on any table that already has rows — existing rows would violate the constraint. The migration errors out in production even though it works on an empty dev database.
ALTER TABLE orders ADD COLUMN region text NOT NULL;CREATE INDEX holds a SHARE lock for the entire build, blocking INSERT/UPDATE/DELETE on the table. Build time scales with table size, so a migration that is instant in dev blocks writes for minutes in production.
CREATE INDEX idx_orders_region ON orders (region);ALTER COLUMN TYPE takes ACCESS EXCLUSIVE and, unless the change is binary-coercible, rewrites every row. The table is completely unavailable — reads included — for the duration.
ALTER TABLE orders ALTER COLUMN id TYPE bigint;Between the migration running and the last old app instance stopping, old code still queries the old name. A rename or drop makes those queries error immediately — a partial outage that lasts exactly as long as your rollout.
ALTER TABLE users RENAME COLUMN email TO email_address;Statements like CREATE INDEX CONCURRENTLY refuse to run in a transaction block. Mixed with other DDL in one migration, either the whole file fails, or the runner drops the transaction and a mid-file error strands the schema between states with no rollback.
ALTER TABLE orders ADD COLUMN region text;
CREATE INDEX CONCURRENTLY idx ON orders (region);Adding a column with a constant default is instant on modern Postgres, so teams assume all defaults are. A volatile default must be evaluated per existing row, so Postgres rewrites the whole table under ACCESS EXCLUSIVE — reads and writes blocked for the duration.
ALTER TABLE users ADD COLUMN uid uuid DEFAULT gen_random_uuid();Adding a validated foreign key scans every existing row while holding a SHARE ROW EXCLUSIVE lock on both tables. With NOT VALID the constraint applies to new writes instantly, and VALIDATE CONSTRAINT can scan later with a much weaker lock.
ALTER TABLE orders ADD CONSTRAINT fk FOREIGN KEY (user_id) REFERENCES users (id);Adding a validated CHECK constraint scans the whole table while holding ACCESS EXCLUSIVE — nothing can read or write until the scan finishes. NOT VALID makes the ALTER instant; VALIDATE CONSTRAINT afterwards only takes SHARE UPDATE EXCLUSIVE, which does not block reads or writes.
ALTER TABLE orders ADD CONSTRAINT positive CHECK (total >= 0);CASCADE resolves the dependency graph at execution time and drops everything in it, without listing what it took. What it removes in production can differ from dev — a view or foreign key added since, gone silently.
DROP TABLE legacy_events CASCADE;SET NOT NULL must verify every existing row, and it does so while holding ACCESS EXCLUSIVE — the table is fully unavailable for the scan. The safe path is a CHECK (col IS NOT NULL) NOT VALID constraint, VALIDATE CONSTRAINT (which does not block), then SET NOT NULL, which sees the validated constraint and skips the scan.
ALTER TABLE orders ALTER COLUMN region SET NOT NULL;ADD PRIMARY KEY / ADD UNIQUE builds a whole index while holding ACCESS EXCLUSIVE — build time scales with table size and the table is completely unavailable meanwhile. Building the index CONCURRENTLY first and attaching it with ADD CONSTRAINT ... USING INDEX reduces the exclusive lock to a metadata swap.
ALTER TABLE orders ADD CONSTRAINT orders_pk PRIMARY KEY (id);TRUNCATE deletes every row irreversibly, takes ACCESS EXCLUSIVE on the table, and cascades to referencing tables when asked. A migration that truncates in dev fixtures does the same to production data.
TRUNCATE orders CASCADE;A WHERE-less UPDATE/DELETE rewrites or removes every row in a single transaction: it locks all rows for the duration, doubles the table in dead tuples, floods WAL, and stalls replicas. Backfills belong in batched jobs, not migrations.
UPDATE orders SET region = 'eu';VACUUM FULL and CLUSTER rewrite the entire table under ACCESS EXCLUSIVE; plain REINDEX locks writes on the table while rebuilding. All three look like harmless maintenance and take the table down for the duration. Plain VACUUM (without FULL) needs no such lock, and REINDEX CONCURRENTLY avoids the write block.
VACUUM FULL orders;DROP INDEX needs ACCESS EXCLUSIVE on the table. The drop itself is instant, but the lock request queues behind any running query touching the table — and every new query then queues behind the lock request. One slow SELECT turns an instant drop into a stall for all traffic.
DROP INDEX idx_orders_region;Postgres indexes the referenced side of a foreign key (it must be unique) but never the referencing side. Without that index, every UPDATE or DELETE on the parent table sequential-scans the child table to enforce the constraint — fine in dev, a scan-per-row regression in production.
ALTER TABLE orders ADD CONSTRAINT fk FOREIGN KEY (user_id) REFERENCES users (id) NOT VALID;A table without a primary key (or any unique constraint) works until it doesn't: logical replication rejects UPDATE/DELETE without a replica identity, targeted row operations degrade to full scans, and maintenance tooling that addresses rows by key can't help you. Adding a key later on a populated table is exactly the migration this tool exists to warn about.
CREATE TABLE audit_log (entry text, created_at timestamptz);Adding an enum value has transaction restrictions: before Postgres 12 it errors inside a transaction block, and on 12+ the new value is unusable until the transaction commits. A migration runner that wraps the file in one transaction either fails outright or fails on the first statement that uses the new value.
ALTER TYPE order_status ADD VALUE 'archived';
UPDATE orders SET status = 'archived';DETACH PARTITION without CONCURRENTLY holds ACCESS EXCLUSIVE on the parent and the partition — the whole partition tree stalls behind it. ATTACH PARTITION validates the partition bound by scanning the incoming table while the parent is locked, unless a CHECK constraint matching the bound already exists to skip the scan.
ALTER TABLE measurements DETACH PARTITION measurements_2024;A plain REFRESH MATERIALIZED VIEW takes an exclusive lock on the view while it recomputes — every SELECT against it blocks for the whole rebuild. REFRESH ... CONCURRENTLY lets readers keep the old contents during the rebuild; it requires a unique index on the view.
REFRESH MATERIALIZED VIEW daily_revenue;LOCK TABLE holds its lock until the transaction commits. Migration runners wrap the file in one transaction, so an explicit lock taken early is held across every remaining statement — a manual outage whose duration is the rest of the migration.
LOCK TABLE orders IN ACCESS EXCLUSIVE MODE;Unlogged tables skip WAL: writes are faster, but the table is truncated to empty on crash recovery and its contents never reach physical replicas. This will work — until the first failover or crash quietly empties it.
CREATE UNLOGGED TABLE session_cache (id bigint PRIMARY KEY);A STORED generated column must be computed for every existing row, so adding one rewrites the entire table under ACCESS EXCLUSIVE — the constant-default fast path does not apply.
ALTER TABLE orders ADD COLUMN total_c numeric GENERATED ALWAYS AS (subtotal + tax) STORED;Moving a table to another tablespace physically copies every block while holding ACCESS EXCLUSIVE. Copy time scales with table size; the table is unavailable throughout.
ALTER TABLE orders SET TABLESPACE fast_ssd;Switching a table between LOGGED and UNLOGGED rewrites the whole table (SET LOGGED additionally writes every row to WAL) under ACCESS EXCLUSIVE.
ALTER TABLE staging_events SET LOGGED;Dropping a schema removes every object in it in one statement — with CASCADE, without even listing them. Dropping a database is the same at a larger radius. Neither belongs in schema history; both are irreversible.
DROP SCHEMA app CASCADE;DROP OWNED BY removes every object the role owns in the current database and revokes its grants — the blast radius is whatever that role ever created, which nobody can enumerate from the migration text.
DROP OWNED BY deploy_user;DISABLE TRIGGER ALL includes the system triggers that enforce foreign keys — writes made while it is off can violate referential integrity permanently, and re-enabling does not re-check them. Disabling a single trigger silently skips whatever logic it carried.
ALTER TABLE orders DISABLE TRIGGER ALL;Dropping a column destroys its data irreversibly, and any app instance deployed before the migration still selects or writes the column until the rollout completes — those queries fail immediately. The safe order is: stop reading it in code, deploy, then drop one release later.
ALTER TABLE users DROP COLUMN legacy_flags;ADD COLUMN ... UNIQUE (or PRIMARY KEY) creates the constraint's index inside the ALTER, under ACCESS EXCLUSIVE — the same trap as ADD CONSTRAINT, hidden in a column definition.
ALTER TABLE users ADD COLUMN email text UNIQUE;CREATE TABLE AS runs its query to completion inside the migration's transaction: the copy's duration scales with the source data, the transaction stays open throughout (holding back vacuum and locks), and the copied rows are frozen at migration time — usually a backfill pretending to be schema.
CREATE TABLE orders_archive AS SELECT * FROM orders WHERE closed_at < '2024-01-01';RESTART rewinds a sequence regardless of what values are already in use. If any existing row holds an id at or above the restart point, inserts start failing with duplicate-key errors at some unpredictable later moment.
ALTER SEQUENCE orders_id_seq RESTART WITH 1;An ACCESS EXCLUSIVE request queues behind any long-running query — and every new query queues behind it. Without SET lock_timeout, one slow report turns a metadata-only ALTER into a site-wide stall of unbounded length. A short lock_timeout makes the migration fail fast and retryable instead.
ALTER TABLE orders ADD COLUMN region text;Each ALTER TABLE statement acquires its own ACCESS EXCLUSIVE lock — queueing behind traffic every single time. One ALTER TABLE with comma-separated actions acquires the lock once and does all the work under it.
ALTER TABLE orders ADD COLUMN a text;
ALTER TABLE orders ADD COLUMN b text;VALIDATE CONSTRAINT deliberately takes only SHARE UPDATE EXCLUSIVE so it can run without blocking — but in the same transaction as ACCESS EXCLUSIVE DDL, the exclusive locks are held while the validation scans the whole table. The non-blocking scan drags the blocking locks out with it.
ALTER TABLE orders ADD COLUMN region text;
ALTER TABLE orders VALIDATE CONSTRAINT orders_total_check;REPLICA IDENTITY FULL writes the complete old row into WAL for every UPDATE and DELETE, and logical decoding must compare entire rows downstream. On write-heavy tables that is a permanent WAL and CPU tax; an index-based replica identity carries only the key.
ALTER TABLE orders REPLICA IDENTITY FULL;With autovacuum_enabled = false, dead tuples accumulate unchecked (bloat, degrading scans) and the table still gets aggressive wraparound vacuums eventually — at the worst possible moment. Tuning autovacuum beats disabling it in almost every case.
ALTER TABLE orders SET (autovacuum_enabled = false);RENAME VALUE changes the label in place: every app instance still deployed with the old label gets an invalid-enum error on insert or comparison until the rollout completes — the enum flavor of the rename deploy-window break.
ALTER TYPE order_status RENAME VALUE 'pending' TO 'awaiting';Plain timestamp stores wall-clock digits with no zone: the moment it crosses a server, client, or DST boundary, the same value means different instants. timestamptz stores an absolute instant and costs the same 8 bytes.
CREATE TABLE events (occurred_at timestamp);char(n) space-pads every value to n and strips the padding in surprising places — comparisons and lengths behave differently from every other string type, with no storage or speed benefit over text in Postgres.
CREATE TABLE users (country_code char(2));The money type's meaning depends on the server's lc_monetary locale: no currency is stored, fractional precision is fixed, and dumping/restoring across locales reinterprets the values. numeric plus an explicit currency column is the boring, correct answer.
CREATE TABLE orders (total money);serial is a legacy macro: the sequence it creates is only loosely attached, permissions and ownership drift from the column, and anyone can still insert arbitrary values. GENERATED ... AS IDENTITY is the standard replacement with tighter semantics.
CREATE TABLE users (id bigserial PRIMARY KEY);An int primary key tops out at 2,147,483,647. Tables that get there discover it as an outage, and the fix — retyping the primary key and every referencing column — is one of the worst migrations there is. bigint costs 4 more bytes now and removes the cliff.
CREATE TABLE orders (id serial PRIMARY KEY);REINDEX SYSTEM and REINDEX DATABASE rebuild every index in the database, system catalogs included — and catalog indexes can never be rebuilt concurrently, so their rebuild takes locks that stall catalog lookups for every session. Runtime scales with total index volume across the database. Whatever a migration needs, it is never this; rebuild the specific index or table instead.
REINDEX DATABASE app;An unbounded INSERT ... SELECT runs to completion inside the migration's transaction: its duration scales with the source data, the transaction stays open the whole time (holding locks and holding back vacuum), and a failure rolls back everything — a backfill wearing a migration's clothes.
INSERT INTO orders_archive SELECT * FROM orders WHERE closed_at < '2024-01-01';SET lock_timeout = 0 (or statement_timeout = 0) switches the safety off: zero means 'wait forever'. A migration that disables its timeouts can queue behind one slow query indefinitely — with all new traffic queueing behind it — precisely the stall the guard exists to prevent.
SET statement_timeout = 0;
ALTER TABLE orders ADD COLUMN region text;ALTER SYSTEM edits the server's configuration for every database and every connection, persists in postgresql.auto.conf far beyond the migration, cannot run inside a transaction block, and usually needs a reload or restart to even take effect. Nothing about it is schema — it is cluster administration in migration clothing.
ALTER SYSTEM SET max_connections = 500;DISABLE ROW LEVEL SECURITY switches off every policy on the table at once: from the moment it commits, queries see all rows, not the policy-filtered subset. If RLS was carrying tenant isolation or access control, this single line is a data exposure — and it looks like routine DDL.
ALTER TABLE orders DISABLE ROW LEVEL SECURITY;GRANT ... TO PUBLIC applies to every role the cluster has now — and every role created later, forever. Granting write privileges (or ALL) on a table to PUBLIC turns one migration line into a standing policy that no future account can be excluded from.
GRANT ALL ON orders TO PUBLIC;A B-tree index on (a) is fully covered by an existing index on (a, b): the planner can use the wider index for every query the narrow one serves. The extra index buys nothing and costs a write on every INSERT, UPDATE and DELETE, plus disk, plus vacuum time — forever.
CREATE INDEX idx_orders_user ON orders (user_id);
CREATE INDEX idx_orders_user_created ON orders (user_id, created_at);A B-tree whose leading column has two values splits the table in half at best. The planner will usually ignore it in favor of a sequential scan, and when it does use it the index returns half the table. Either way every write pays to maintain it. The useful form is a partial index — WHERE is_active — that only contains the rows you actually look up.
CREATE INDEX idx_users_active ON users (is_active);A B-tree on a jsonb, array, or tsvector column only serves equality and ordering on the whole value — never the containment (@>), key-existence (?), or text-search (@@) operators those types exist for. The queries that motivated the index still sequential-scan, and the index (often huge, since it stores whole documents) taxes every write.
CREATE INDEX idx_events_payload ON events (payload);Postgres never indexes the referencing side of a foreign key itself; the index someone added later is what keeps parent-side UPDATE and DELETE cheap. Drop it and every change to the referenced table sequential-scans the child table to enforce the constraint — the same regression BV017 warns about, arriving through the back door.
DROP INDEX CONCURRENTLY idx_orders_user;Every index on a table is maintained on every INSERT, on every DELETE, and on every UPDATE that touches an indexed column (or cannot use a HOT update). On a table taking sustained write traffic, the ninth index is not free: it is another page write, more WAL, and more vacuum work on the hottest path in the system. The right response is usually to drop an index nobody uses before adding one.
CREATE INDEX CONCURRENTLY idx_events_region ON events (region);A B-tree answers a query by walking leading columns with equality first, then scanning one range. With the range column first — (created_at, status) for WHERE status = 'x' AND created_at > … — the index has to scan every row in the date range and filter status afterwards. Put the equality column first and the scan shrinks to exactly the matching rows. Only fires when a query in the migration itself shows that shape.
CREATE INDEX idx_orders_created_status ON orders (created_at, status);
UPDATE orders SET archived = true WHERE status = 'closed' AND created_at < '2024-01-01';A partial index only serves queries whose WHERE clause provably implies its predicate; an expression index only serves queries that use the identical expression. An index on lower(email) does nothing for WHERE email = …, and an index WHERE status = 'archived' does nothing for WHERE status = 'active'. When the migration itself contains such a query, the mismatch is visible before it ships.
CREATE INDEX idx_users_lower_email ON users (lower(email));
UPDATE users SET verified = true WHERE email = 'a@example.com';An UPDATE or DELETE with a WHERE clause looks bounded, but if no index leads with any of the filtered columns, Postgres reads the whole table to find the rows — once per statement, and once per batch when the backfill is looped. On a large table that is minutes of I/O and a long-held lock per pass. Only fires with a live snapshot proving both the size and the absence of the index.
UPDATE orders SET legacy = false WHERE legacy_flag = 'y';SET STATISTICS 0 tells ANALYZE to collect nothing for the column. From then on the planner estimates every predicate on it with hard-coded defaults (selectivity 0.5 for equality, 1/3 for ranges) — row estimates go wrong by orders of magnitude, and with them join order, index choice, and memory sizing for every query that touches the column.
ALTER TABLE orders ALTER COLUMN region SET STATISTICS 0;autovacuum_vacuum_scale_factor is the fraction of the table that must change before autovacuum touches it; the server default is 0.2. At 0.5 or above, a table has to accumulate dead rows equal to half its size before cleanup starts — bloat, slower scans, and index bloat pile up in the meantime, and the eventual vacuum is a long one. The same holds for the analyze scale factor and stale planner statistics. Escalates when the live schema shows the table is large or hot.
ALTER TABLE orders SET (autovacuum_vacuum_scale_factor = 0.8);pg_stat_user_indexes counts how many times the planner chose each index. Dropping one with thousands of scans since the last stats reset sends every one of those queries to a sequential scan or a worse index — a regression that lands the moment the migration applies, invisible in a dev database where nothing has run. Silent when the counters are too young to trust, or when another index with the same leading columns remains.
DROP INDEX CONCURRENTLY idx_orders_region;NOT VALID is half of a two-step pattern: add the constraint instantly (protecting new writes), then VALIDATE CONSTRAINT in a later migration to check existing rows without an exclusive lock. Skip the second step and the rows that were already there are never checked — the constraint looks enforced, the planner may even trust it, and the data underneath is not. Fires only when the rest of the migration set is visible and no later file validates it.
ALTER TABLE orders ADD CONSTRAINT orders_user_fk FOREIGN KEY (user_id) REFERENCES users (id) NOT VALID;
-- ...and no later migration runs VALIDATE CONSTRAINT orders_user_fkMigrations apply in order. When file 0003 drops or renames a column and file 0005 still indexes, alters, or constrains the old name, 0005 fails at apply time — after 0003 and 0004 have already run, leaving production between two states no migration describes. Fires only for tables the set itself created, where the column list is known exactly; anything created outside the set is left alone.
-- 0003_cleanup.sql
ALTER TABLE orders DROP COLUMN legacy_flag;
-- 0005_index.sql
CREATE INDEX idx_orders_legacy ON orders (legacy_flag);CREATE ROLE … PASSWORD 'x' puts the database password into a file that is committed, reviewed in pull requests, printed by CI and kept in git history forever — and the server writes the statement to its own log when log_statement covers DDL. Rotating it later does not un-leak it. Postgres accepts an already-hashed SCRAM-SHA-256 verifier in the same position, and the rule stays silent on one; an md5 verifier is reported as a warning because it can be cracked offline.
CREATE ROLE app_user LOGIN PASSWORD 'hunter2';CREATE USER MAPPING … OPTIONS (password '…'), CREATE SERVER … OPTIONS (secret_key '…') and CREATE SUBSCRIPTION … CONNECTION 'host=… password=…' are the three places Postgres itself asks for a remote credential — and a migration is the wrong file to answer in. The value lands in git history, pull-request diffs and CI output, and pg_user_mappings / pg_subscription keep it readable to superusers afterwards. Silent when the option holds a placeholder, is empty, or points at a passfile / certificate instead.
CREATE USER MAPPING FOR app_user SERVER analytics OPTIONS (user 'reporter', password 's3cret');A DSN like postgres://app:hunter2@db.internal/app or host=… password=… seeded into a settings row, a column DEFAULT, or a dblink() call is a live credential in a committed file. It is also the one that spreads furthest — a DSN in a settings table is read by every service that loads settings. Only fires when the string parses as a libpq-style URI or keyword string with a non-empty, non-placeholder password.
INSERT INTO integrations (name, dsn) VALUES ('warehouse', 'postgres://etl:hunter2@warehouse.internal:5432/dw');Vendors give their secrets recognisable prefixes precisely so that leaks can be caught: sk_live_ (Stripe), AKIA (AWS), ghp_ (GitHub), xoxb- (Slack), -----BEGIN PRIVATE KEY-----. One of those in an INSERT, an UPDATE or a column DEFAULT is a working credential committed to the repository. The rule matches only published formats, never entropy — a uuid, a bcrypt hash or a base64 blob is not a token, and the rule stays silent on all of them.
INSERT INTO settings (key, value) VALUES ('stripe_secret', 'sk_live_51H8zK2eZvKYlo2CabcDEFghij');A column called password, api_key, secret or token that receives a literal in a DEFAULT, an INSERT or an UPDATE is a credential written into the migration by hand — the seeded admin account with password 'admin', the api_key DEFAULT 'changeme' that ships to every row. The name is the evidence, so this is a warning rather than a certainty: the rule stays silent on hashed values (bcrypt, argon2, SCRAM, pbkdf2), on empty strings and placeholders, and on names that merely contain the word (password_hash, token_type, api_key_id).
INSERT INTO users (email, password) VALUES ('admin@example.com', 'admin123');DB_PASSWORD=hunter2 in a .env, "client_secret": "…" in a JSON config, api_key = '…' in a settings module, password: … in a YAML manifest — a key whose name says secret, given a literal instead of a reference. The name is the evidence, so this is a warning rather than a certainty: the scanner stays silent when the value is a placeholder, an environment or template reference, a hash, a type annotation, or a word like example or test, and on names that merely contain the word (password_hash, token_type, api_key_id).
DB_PASSWORD=hunter2
STRIPE_SECRET="sk_live_…"
"client_secret": "0f3a…"The roadmap bends toward what connected teams actually need — tell us.
Talk to us