What a rule id like BV002 or SL013 means: the family prefixes (BV, BP, BS, BC, EX, SL, MY) and the two-letter category codes (LK, RW, CN, …) every rule carries.
Every finding carries a rule id such as BV002, BP006 or SL013. The two letters are the family, which corpus the rule belongs to, where it runs and what it reads. The three digits are stable for the life of the rule; a retired rule leaves a gap and a number is never reused. Separately, every rule is tagged with a two-letter category such as LK or CN that says what goes wrong. Families group rules by mechanism, categories by harm: one category spans several families, and one family covers many categories.
Families: the id prefix
Prefix
Engine
Corpus
Rules
Reads
Default posture
BV###
Postgres
Core safety
50
The parse tree of one migration; refined against the live schema with --db
Blocks CI on critical findings
BP###
Postgres
Performance
11
The parse tree, plus index and table statistics with --db
Advisory notes; never fails CI unless team policy raises it
BS###
Postgres
Set ordering
2
Every file in the migration set, in apply order
Blocks CI; silent on a single file
BC###
Postgres and any text file
Credentials
6
String literals in SQL; with bolvrk secrets, any file
Blocks CI; the value is always masked
EX###
Postgres
Plan rules
none
EXPLAIN (FORMAT JSON) output from bolvrk explain
Warnings and notes; not part of check
SL###
SQLite (beta)
Static corpus
35
The SQLite parse tree, behind --engine=sqlite
Local only; no --db or --remote yet
SR###
SurrealDB (beta)
Static corpus
31
The SurrealQL parse tree, behind --engine=surrealdb
Local only; no --db or --remote yet
MY###
MySQL
Static corpus
none
The MySQL parse tree
Planned; not run anywhere yet
The engine a rule targets follows from the prefix alone: SL is SQLite, SR is SurrealDB, MY is MySQL, every other family is Postgres. Reports and the rule reference list families in a fixed order, BV, BP, BS, BC, then SL and SR, and numerically within a family. The free CLI bundles 18 of the 68 Postgres SQL rules; the rest run in the hosted service on Startup and above.
Categories: the two-letter tag
Categories are shared across every engine, so one filter reads the same for a Postgres rule, a SQLite rule and a SurrealDB rule. A rule has exactly one. They are the category filter on the rule reference and on the Policy page, and the code chip on every rule row.
Code
Name
What it covers
LK
Locks & blocking
Statements that queue or block production traffic while they run.
RW
Table rewrites
Changes that copy or rewrite every row of a table.
CN
Constraints & keys
Constraints, primary keys, and foreign keys added the slow or unsafe way.
DS
Destructive changes
Statements that remove data, objects, or enforcement.
DW
Deploy window
Changes that break code still running the previous release.
TX
Transactions
Statements that misbehave inside a multi-statement transaction.
RP
Replication & durability
Replication, WAL, vacuum, and crash-safety settings.
TY
Type choices
Column types with well-known traps.
OP
Operational safety
Server-level or batch operations that do not belong in a migration.
PC
Privileges & credentials
Security posture: privileges, row-level security, secrets in SQL.
IX
Index hygiene
Indexes that cost writes without paying back in reads.
QS
Query shape
Index design judged against the migration's own queries.
TS
Table settings
Per-table storage and planner settings that undo good defaults.
SO
Set ordering
Promises one migration makes that a later one must keep.
Roughly: the first ten categories are where the BV, BC, SL and SR rules land, IX, QS and TS belong to the performance corpus, and SO is the set-ordering corpus. That is a tendency, not a rule, BV017 (foreign key without a covering index) is CN.
Reading an id
BV002, Postgres core safety, rule 2: ADD COLUMN NOT NULL without a default. Category CN. BP006, Postgres performance, rule 6, category QS: an index judged against the queries in the same migration. SL013, SQLite, rule 13, category DS: a destructive change, tagged with the same code a Postgres DROP would carry. SR001, SurrealDB, rule 1, category CN: a required field type the records already stored do not satisfy.
Rule reference
Every rule across Postgres, SQLite, SurrealDB and credential hygiene, filterable by database, severity and category. The same list lives at /rules.
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.
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.
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.
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.
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.
ALTERTABLE users RENAMECOLUMN email TO email_address;
Non-transactional statement mixed into a multi-statement migration
critical
free in the CLI
TX · Transactions
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.
ALTERTABLE orders ADDCOLUMN region text;
CREATEINDEXCONCURRENTLY 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.
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.
ALTERTABLE orders ADDCONSTRAINT fk FOREIGNKEY (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.
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.
SET NOT NULL scanning the table under ACCESS EXCLUSIVE
warning
free in the CLI
LK · Locks & blocking
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.
PRIMARY KEY or UNIQUE constraint building its index under full lock
warning
hosted, paid
LK · Locks & blocking
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.
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.
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.
VACUUM FULL / CLUSTER / REINDEX rewriting under full lock
critical
free in the CLI
LK · Locks & blocking
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.
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.
Foreign key without an index on the referencing columns
note
hosted, paid
CN · Constraints & keys
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.
ALTERTABLE orders ADDCONSTRAINT fk FOREIGNKEY (user_id) REFERENCES users (id) NOTVALID;
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.
Enum value added inside a multi-statement migration
warning
hosted, paid
TX · Transactions
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.
ALTERTYPE order_status ADD VALUE 'archived';
UPDATE orders SET status = 'archived';
Partition attach/detach blocking the partition tree
warning
hosted, paid
LK · Locks & blocking
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.
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.
Explicit LOCK TABLE held for the rest of the migration
warning
hosted, paid
LK · Locks & blocking
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.
UNLOGGED table holding data that cannot survive a crash
note
hosted, paid
RP · Replication & durability
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 bigintPRIMARYKEY);
Stored generated column added to an existing table
warning
hosted, paid
RW · Table rewrites
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.
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.
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 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.
Triggers disabled: with ALL, foreign keys stop being enforced
warning
hosted, paid
DS · Destructive changes
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.
DROP COLUMN destroying data inside the deploy window
warning
hosted, paid
DW · Deploy window
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.
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.
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.
CREATETABLE orders_archive ASSELECT * 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.
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.
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.
ALTERTABLE orders ADDCOLUMN a text;
ALTERTABLE orders ADDCOLUMN 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.
ALTERTABLE orders ADDCOLUMN region text;
ALTERTABLE orders VALIDATECONSTRAINT 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.
Autovacuum disabled: bloat and wraparound left to accumulate
note
hosted, paid
RP · Replication & durability
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.
ALTERTABLE 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.
ALTERTYPE 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.
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.
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.
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.
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.
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.
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.
INSERTINTO 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;
ALTERTABLE orders ADDCOLUMN 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.
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.
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.
Redundant index: its columns are already a prefix of another index
note
hosted, paid
IX · Index hygiene
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.
CREATEINDEX idx_orders_user ON orders (user_id);
CREATEINDEX 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.
CREATEINDEX idx_users_active ON users (is_active);
B-tree index on a jsonb, array, or tsvector column
note
hosted, paid
IX · Index hygiene
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.
CREATEINDEX idx_events_payload ON events (payload);
DROP INDEX removes the only index covering a foreign key
note
hosted, paid
IX · Index hygiene
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.
One more index on a hot table that already carries many
note
hosted, paid
IX · Index hygiene
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.
CREATEINDEXCONCURRENTLY idx_events_region ON events (region);
Composite index led by a range column ahead of an equality column
note
hosted, paid
QS · Query shape
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.
CREATEINDEX idx_orders_created_status ON orders (created_at, status);
UPDATE orders SET archived = true WHERE status = 'closed'AND created_at < '2024-01-01';
Partial or expression index the migration's own queries cannot use
note
hosted, paid
QS · Query shape
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.
CREATEINDEX idx_users_lower_email ON users (lower(email));
UPDATE users SET verified = true WHERE email = 'a@example.com';
Backfill filters a large table on columns no index leads with
note
hosted, paid
QS · Query shape
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.
ALTERTABLE orders ALTERCOLUMN region SET STATISTICS 0;
Autovacuum scale factor raised so high the table bloats before it runs
note
hosted, paid
TS · Table settings
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.
ALTERTABLE orders SET (autovacuum_vacuum_scale_factor = 0.8);
DROP INDEX on an index the planner is actively using
warning
hosted, paid
IX · Index hygiene
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.
Constraint added NOT VALID and never validated in a later migration
warning
hosted, paid
SO · Set ordering
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.
ALTERTABLE orders ADDCONSTRAINT orders_user_fk FOREIGNKEY (user_id) REFERENCES users (id) NOTVALID;
-- ...and no later migration runs VALIDATE CONSTRAINT orders_user_fk
Refers to a column an earlier migration in the set dropped or renamed
critical
hosted, paid
SO · Set ordering
Migrations 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.
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.
Foreign server, user mapping or subscription created with an embedded credential
critical
free in the CLI
PC · Privileges & credentials
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');
Connection string with an embedded password in a string literal
critical
free in the CLI
PC · Privileges & credentials
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.
API key, access token or private key in a string literal
critical
free in the CLI
PC · Privileges & credentials
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.
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).
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).
SQLite refuses to add a NOT NULL column unless it has a default other than NULL, the statement fails with "Cannot add a NOT NULL column with default value NULL", so the migration stops here in every environment, including the one that matters.
ALTER TABLE ADD COLUMN cannot carry a PRIMARY KEY or UNIQUE constraint, SQLite rejects the statement outright ("Cannot add a PRIMARY KEY column", "Cannot add a UNIQUE column"). The migration fails, and the constraint it wanted still does not exist.
A column added with ALTER TABLE may not default to CURRENT_TIME, CURRENT_DATE, CURRENT_TIMESTAMP or a parenthesised expression: SQLite rejects the statement ("Cannot add a column with non-constant default"). The pattern is common in migrations ported from Postgres, where it works.
A STORED generated column cannot be added with ALTER TABLE, SQLite only allows VIRTUAL generated columns there ("cannot add a STORED column"). The statement fails; nothing is added.
When foreign keys are enforced (PRAGMA foreign_keys=ON, which most applications set on every connection), a column added with a REFERENCES clause must default to NULL, anything else fails with "Cannot add a REFERENCES column with non-NULL default value". Whether it fails depends on a per-connection pragma, so the same file passes in one runner and stops in another.
DROP COLUMN edits the schema and then rewrites every row of the table to purge the column's values, one write transaction that holds the database's single write lock for the length of the copy. It also fails outright if anything else in the schema names the column (an index, a foreign key, a view, a trigger, a generated column) and is unsupported before SQLite 3.35.
A rename is instant in SQLite, and that is the problem: the moment it commits, every process still running the previous release fails on the old name. Views and triggers are rewritten to the new name, but application SQL is not.
ADD COLUMN with a CHECK constraint scans the table
SQLite · beta
note
free in the CLI, --engine=sqlite
LK · Locks & blocking
Adding a column is normally a schema-only change in SQLite. With a CHECK constraint (or NOT NULL on a generated column) the whole table is scanned to verify existing rows first, under the write lock, proportional to the table's size.
ALTERTABLE orders ADDCOLUMN priority INTEGERDEFAULT0CHECK (priority BETWEEN 0AND9);
This migration rebuilds a table the documented way, create the new table, copy, drop the old, rename, but never turns foreign keys off first. With PRAGMA foreign_keys=ON the DROP TABLE performs an implicit DELETE FROM: rows referencing the table fail the migration with a constraint error, or ON DELETE CASCADE quietly removes them.
CREATETABLE orders_new (id INTEGERPRIMARYKEY, qty INTEGERNOTNULL);
INSERTINTO orders_new SELECT id, qty FROM orders;
DROPTABLE orders;
ALTERTABLE orders_new RENAMETO orders;
PRAGMA foreign_keys and PRAGMA journal_mode do nothing while a transaction is open, SQLite does not error, it silently leaves the setting as it was. A rebuild that switches foreign keys off after BEGIN runs with them on, and everything SL009 warns about applies.
PRAGMA foreign_keys=OFF is per connection and does not reset at COMMIT. A migration that turns enforcement off for a rebuild and never turns it on leaves the runner's connection accepting orphans for as long as it lives, every later statement in the run, and any backfill sharing the connection, goes unchecked.
Foreign keys re-enabled without a foreign_key_check
SQLite · beta
note
free in the CLI, --engine=sqlite
CN · Constraints & keys
Turning foreign keys back on does not validate what happened while they were off. Step 10 of the SQLite rebuild recipe is PRAGMA foreign_key_check before COMMIT, without it, an orphaned row created during the rebuild is discovered by the first user query that joins on it.
An UPDATE or DELETE with no WHERE touches every row in one write transaction. SQLite has one writer at a time: the whole application queues behind it (or hits SQLITE_BUSY) for as long as the statement runs, and the rollback journal or WAL grows by the size of the change.
VACUUM rewrites the entire database file into a temporary copy and swaps it in: it needs up to twice the database's size in free disk space, holds the write lock for the whole rewrite, and fails outright if a transaction is open on the connection, so inside a migration runner's transaction it is an error, and outside one it is an outage-sized pause.
REINDEX with no argument rebuilds every index in every attached database in one write transaction, the write lock is held for the total, not per table. In a migration it is almost always broader than intended.
SQLite has no CONCURRENTLY: CREATE INDEX scans the table and writes the index under the database's write lock, and every other writer waits (or gets SQLITE_BUSY) for the duration. Fine on a small table; on a large one this is the moment the application stalls.
Only a column declared exactly INTEGER PRIMARY KEY becomes an alias for the rowid. INT PRIMARY KEY (or BIGINT, or INTEGER PRIMARY KEY DESC) is an ordinary column with a separate unique index: every lookup by primary key is an extra B-tree hop, the value is not the rowid, and VACUUM may renumber the real rowid underneath it.
CREATETABLE orders (id INTPRIMARYKEY, qty INTEGER);
AUTOINCREMENT where a plain INTEGER PRIMARY KEY would do
SQLite · beta
note
free in the CLI, --engine=sqlite
TY · Type choices
AUTOINCREMENT makes every insert also update the sqlite_sequence table, and the SQLite manual itself says it should be avoided unless strictly needed. Its one guarantee, a deleted rowid is never reused, is rarely what a migration author meant.
CREATETABLE orders (id INTEGERPRIMARYKEY AUTOINCREMENT, qty INTEGER);
Table rebuild that drops the old table before copying its rows
SQLite · beta
critical
free in the CLI, --engine=sqlite
DS · Destructive changes
The rebuild recipe is create, copy, drop, rename. This migration creates the new table, drops the old one and renames the new one onto its name, but never copies the rows across before the DROP. DROP TABLE discards them; the rename brings back the name with an empty table, and nothing in SQLite fails to say so.
Without STRICT, SQLite's column types are suggestions: a TEXT value goes into an INTEGER column, a blob into a date, and nothing complains until the application reads it back. STRICT (3.37+) makes the declared type an actual constraint, and it only exists as a CREATE TABLE option, it cannot be added later without a rebuild.
CREATETABLE orders (id INTEGERPRIMARYKEY, qty INTEGER, note TEXT);
SQLite indexes the parent side of a foreign key (the referenced key must be unique) but never the child side. Every DELETE or UPDATE of a parent row then scans the whole child table to look for references, with foreign keys on, deleting one user scans every order. The manual's advice is an index on every child key column.
CREATETABLE orders (id INTEGERPRIMARYKEY, user_id INTEGERREFERENCES users(id));
With PRAGMA legacy_alter_table=ON, RENAME TABLE and RENAME COLUMN go back to the pre-3.26 behaviour: references inside views and triggers are not rewritten. The rename succeeds, and the first query through a view that named the old table fails with "no such table".
Dropping what a view or trigger in this migration depends on
SQLite · beta
critical
free in the CLI, --engine=sqlite
DS · Destructive changes
This migration creates a view or trigger and then drops the table or column it depends on. SQLite does not stop it: the view fails with "no such column" the first time it is queried, and a trigger that names the column makes every later write to its table fail. Older releases refuse the DROP COLUMN instead. Either way the failure is guaranteed by the file itself, not by what production holds.
CREATEVIEW open_orders ASSELECT id, status FROM orders WHERE status = 'open';
ALTERTABLE orders DROPCOLUMN status;
In a rowid table, a PRIMARY KEY column that is not INTEGER does not imply NOT NULL, a long-standing SQLite bug kept for compatibility, so NULL keys are accepted and the uniqueness the key promised is gone (every NULL is distinct). In a WITHOUT ROWID table the same NULL is refused at insert time instead, so the schema behaves differently depending on one table option.
A plain BEGIN is DEFERRED: it takes no lock until the first write, then tries to upgrade a read lock to a write lock. If another connection holds the write lock at that moment the upgrade fails immediately with SQLITE_BUSY, busy_timeout does not apply to a lock upgrade, and the migration aborts part way through its reads.
BEGIN;
ALTERTABLE orders ADDCOLUMN region TEXT;
COMMIT;
SQLite has no nested transactions: a second BEGIN fails with "cannot start a transaction within a transaction", and COMMIT or ROLLBACK with nothing open fails with "no transaction is active". Either error stops the migration where it happens, usually because a runner already wraps the file.
BEGIN;
BEGIN;
ALTERTABLE orders ADDCOLUMN region TEXT;
COMMIT;
A DELETE with no WHERE on a table that other tables reference with ON DELETE CASCADE empties those tables too, in the same statement, with foreign keys on, and does nothing to them with foreign keys off. The blast radius depends on a per-connection pragma, and the statement itself names only one table.
CREATETABLE lines (id INTEGERPRIMARYKEY, order_id INTEGERREFERENCES orders(id) ONDELETECASCADE);
DELETEFROM orders;
An index expression must give the same answer for the same row every time, so SQLite refuses random(), changes(), last_insert_rowid() and their kin in CREATE INDEX: "non-deterministic functions prohibited in index expressions". The statement fails and the migration stops.
CREATEINDEX orders_shuffle ON orders (id + random());
AUTOINCREMENT only exists for the rowid, and a WITHOUT ROWID table has none: SQLite refuses the CREATE TABLE with "AUTOINCREMENT not allowed on WITHOUT ROWID tables". The table is not created and the migration stops.
CREATETABLE sessions (id INTEGERPRIMARYKEY AUTOINCREMENT, token TEXT) WITHOUT ROWID;
ATTACH opens a second database file on the runner's connection, by a path that only makes sense on one machine, for the life of that connection. In a migration it is almost always an import script that got committed: it fails on any other host, and while it works it can read from or write to a file the migration never declared.
ATTACH DATABASE'/var/backups/legacy.db'AS legacy;
INSERTINTO orders SELECT * FROM legacy.orders;
An index whose columns are a leading prefix of another index on the same table, a duplicate of one, or an index on the INTEGER PRIMARY KEY (the rowid, which is the table's own B-tree) changes no query plan, SQLite already answers those lookups from the wider index or the table itself. It still costs a write on every insert, update and delete, and space in the file.
CREATEINDEX orders_user ON orders (user_id);
CREATEINDEX orders_user_created ON orders (user_id, created_at);
In a table rebuild the copy is the expensive step. Creating the new table's indexes before INSERT ... SELECT makes SQLite maintain every index row by row during the copy, random B-tree inserts for each, instead of one sorted build per index afterwards. The manual's own recipe creates the indexes after the copy.
CREATETABLE orders_new (id INTEGERPRIMARYKEY, user_id INTEGER, qty INTEGER) STRICT;
CREATEINDEX orders_new_user ON orders_new (user_id);
INSERTINTO orders_new SELECT id, user_id, qty FROM orders;
On a rowid table a TEXT or BLOB primary key is not the table's key: rows live in a B-tree keyed by the hidden rowid, and the primary key is a separate unique index. Every lookup by key walks the index and then the table, and every row is stored twice over (key in the index, key in the row). WITHOUT ROWID makes the primary key the table's own B-tree, the manual recommends it exactly for this shape.
journal_mode is stored in the database file: a migration that sets DELETE, TRUNCATE or PERSIST changes every future connection, not just the runner's. In those modes a writer blocks readers and readers block the writer for the length of each transaction; WAL lets readers proceed during a write and is the mode the application almost certainly wants.
cache_size, synchronous, temp_store, mmap_size and busy_timeout live on the connection that set them. In a migration they tune the runner's connection for a few seconds and reach the application never, and synchronous=OFF removes durability for exactly the connection doing the schema change.
DEFINE FIELD with a required type on an existing table
SurrealDB · beta
critical
free in the CLI, --engine=surrealdb
CN · Constraints & keys
DEFINE FIELD with a TYPE is not checked against the records already in the table, so the statement succeeds. The type is enforced on each record's next write instead: every UPDATE, MERGE or UPSERT of a record that lacks the field fails with "Couldn't coerce value for field ... Expected `string` but found `NONE`", including writes that never mention the field. A plain DEFAULT does not help, it only fills the field when a record is created.
DEFINE FIELD OVERWRITE and ALTER FIELD with a new TYPE do not convert the values already stored. A record written under the old type keeps its old value, and its next write fails with "Couldn't coerce value for field" unless that value happens to coerce. OVERWRITE also drops every clause the new statement does not repeat: a DEFAULT, ASSERT or PERMISSIONS clause that is not restated is gone.
DEFINE FIELD OVERWRITE age ONTABLE person TYPEint;
An ASSERT added with DEFINE FIELD or ALTER FIELD is not checked against the records already stored. A record whose value breaks it is found on its next write, which fails with "field must conform to" even when the write does not touch that field.
ALTER FIELD email ONTABLE user ASSERT string::is_email($value);
DEFINE INDEX ... UNIQUE builds the index over every record already in the table and fails the statement at the first duplicate, "Database index already contains ...". Production data is where the duplicate that test data never had is most likely to be.
DEFINE INDEX user_email ONTABLE user FIELDS email UNIQUE;
A SCHEMAFULL table rejects fields it has no DEFINE FIELD for. Switching an existing table with DEFINE TABLE OVERWRITE ... SCHEMAFULL or ALTER TABLE ... SCHEMAFULL does not clean the records already stored: a record that carries an undeclared field fails its next write with "Found field 'x', but no such field exists", and so does every write from code still sending that field.
REMOVE FIELD deletes the definition, not the data: every record keeps its stored value. On a SCHEMAFULL table those records then carry a field the schema no longer knows, and each one fails its next write with "Found field 'x', but no such field exists", as does any code still writing the field. On a SCHEMALESS table the old values stay, unvalidated.
REMOVE TABLE deletes the table definition and every record in it, with its fields, indexes and events, in one statement. There is no undo short of a restore, and code still reading the table fails with "The table does not exist".
REMOVE DATABASE deletes every table and record in the database, REMOVE NAMESPACE every database in the namespace. A migration runs against whatever the connection points at, so the statement that cleans up a test database is the one that empties production.
A DROP table accepts writes and throws the records away: CREATE returns the record as if it were stored, and nothing is kept. Redefining an existing table with DEFINE TABLE OVERWRITE ... DROP turns every later write from the application into silent data loss, with no error to notice it by.
READONLY rejects any change to the field once the record exists. Added to a field the application already updates, every such write starts failing with "Found changed value for field ... but field is readonly".
A RELATION table requires every record to carry in and out record links. Redefining a table that already holds normal records does not convert them: each fails its next write with "Couldn't coerce value for field `in`".
ALTERTABLE follows TYPE RELATION IN user OUT user;
OVERWRITE replaces the whole table definition, and a clause it does not repeat falls back to its default. For PERMISSIONS the default is NONE: a table that granted record users access with a WHERE clause stops returning or accepting anything for them the moment the statement runs. System users are unaffected, so tests run as root do not notice.
DELETE with a table name and no WHERE removes every record of the table in one statement: the data is gone, the table's events run once per record, and on a large table the whole delete is one transaction that holds every change until it commits.
UPDATE with a table name and no WHERE rewrites every record of the table in one statement and one transaction. Each record re-runs the table's field clauses, index updates and events, and a single record that fails a TYPE or ASSERT rolls the whole statement back, after it has run for the length of the table.
FOR $row IN (SELECT ... FROM table) with no WHERE or LIMIT loads the whole table and runs the loop body once per record, all inside one statement's transaction. The migration holds every change until the loop ends, and one failing iteration rolls back all of them.
FOR $u IN (SELECT VALUE id FROM user) { UPDATE $u SET plan = 'free'; };
SurrealDB refuses a query that opens a transaction and never closes it: the whole query fails with "Missing COMMIT statement" and nothing in it is applied.
BEGINTRANSACTION;
UPDATE user SET plan = 'free'WHERE plan IS NONE;
SurrealDB has no nested transactions or savepoints: a BEGIN while a transaction is open fails the whole query with "Tried to start a transaction while another transaction was open".
BEGIN;
BEGIN;
DEFINE FIELD nickname ONTABLE user TYPE option<string>;
COMMIT;
COMMIT;
Index built on an existing table without CONCURRENTLY
SurrealDB · beta
warning
free in the CLI, --engine=surrealdb
LK · Locks & blocking
DEFINE INDEX without CONCURRENTLY builds the index over every record already in the table inside the defining statement: the migration waits for the whole build, and the table's writes compete with it until it finishes. CONCURRENTLY builds it in the background, and the index serves queries once it has caught up.
DEFINE INDEX purchase_customer ONTABLE purchase FIELDS customer;
REBUILD INDEX drops the index's entries and builds them again from every record in the table. Without CONCURRENTLY the migration waits for the whole rebuild, and queries that relied on the index work without it until the rebuild ends.
SurrealDB 2.0 replaced scopes and tokens with DEFINE ACCESS, and SurrealDB 3 rejects both statements as a parse error, so the whole migration fails before anything in it runs.
DEFINE SCOPE account SESSION 24h SIGNIN (SELECT * FROM user WHERE email = $email);
SurrealDB 3 renamed SEARCH ANALYZER indexes to FULLTEXT ANALYZER and removed MTREE vector indexes in favour of HNSW. The old syntax is a parse error on 3.x, so a migration written for 2.x fails the whole file after an upgrade.
DEFINE INDEX post_body ONTABLE post FIELDS body SEARCH ANALYZER simple BM25 CONCURRENTLY;
REFERENCE ON DELETE CASCADE makes deleting the referenced record delete every record that points at it, and on through any further cascading references. One DELETE of a parent, from a migration or from the application, can remove far more than the statement names.
DEFINE FIELD author ONTABLE post TYPE option<record<person>> REFERENCE ONDELETECASCADE;
Two indexes over the same fields, in the same order, on one table hold the same entries twice. Queries use one of them, and every write to the table pays to maintain both.
DEFINE INDEX purchase_customer ONTABLE purchase FIELDS customer CONCURRENTLY;
DEFINE INDEX purchase_by_customer ONTABLE purchase FIELDS customer CONCURRENTLY;
An event without WHEN (or with WHEN true) runs its THEN block on every CREATE, UPDATE and DELETE of the table, inside the write. Work meant for one kind of change is paid on all of them, and each write waits for it.
DEFINE EVENT user_audit ONTABLE user THEN (CREATE audit SET user = $value.id, at = time::now());
DEFINE TABLE ... AS SELECT computes the view over every record of the source table when it is defined, then keeps it up to date on every later write to the source. The migration waits for the initial computation, and each write to the source pays for the view from then on.
DEFINE TABLE revenue_by_customer ASSELECT customer, math::sum(total) AS revenue FROM purchase GROUP BY customer;
DEFINE USER ... PASSWORD takes the password as a string literal, the grammar accepts nothing else, so the migration file carries it in plaintext: committed, reviewed, printed by CI and kept in git history. SurrealDB stores only a hash, which does not un-leak the file.
DEFINE USER deploy ONDATABASEPASSWORD'hunter2' ROLES EDITOR;
DEFINE ACCESS ... ALGORITHM HS256 / HS384 / HS512 KEY '...', a WITH ISSUER KEY, and the removed DEFINE TOKEN ... VALUE put a signing secret in the file. Anyone who can read the repository can mint tokens the database accepts, as any record user the access method covers.
DEFINE ACCESS api ONDATABASETYPE JWT ALGORITHM HS512 KEY'k3y-that-signs-everything';
crypto::argon2::generate('...'), or the bcrypt, scrypt and pbkdf2 equivalents, with a string literal stores a hash, but the password it hashes sits in the migration file in plaintext, committed and kept in git history. Seeding an admin record this way publishes its password.
CREATE user:admin SET email = 'admin@example.com', pass = crypto::argon2::generate('changeme123');
A root user with the OWNER role controls every namespace and database on the server, including removing them and defining other users. Created from an application migration, it exists wherever that migration runs, with credentials that live wherever the migration does.
DEFINE USER ops ON ROOT PASSHASH '$argon2id$v=19$m=19456,t=2,p=1$c2FsdA$aGFzaA' ROLES OWNER;
PERMISSIONS FULL on a table, or FOR create, update or delete FULL, lets every record user signed in through an access method write every record in the table, not only their own. Root, namespace and database users bypass table permissions, which is why tests run as root do not notice.
http::get, http::post and the other http functions make a network request from the database server when the statement runs. In a migration that request fires in every environment the migration reaches, and again on every re-run, and the migration now fails when the remote service is down.