Bolvrk as a tool inside Claude, Cursor and agent frameworks: the self-hosted server on every plan, the hosted server with the full corpus on Team and above, and skills that tell an agent when to use them.
The verifier is a Model Context Protocol server as well as a CLI, so an agent inside Claude, Cursor or any MCP-aware framework calls it directly and reads structured findings instead of shelling out. Every tool result carries the findings JSON contract, the same object bolvrk check --json prints, and a short text summary for the model. Two servers, one contract.
Self-hosted: every plan
npx bolvrk mcp serves over stdio on the machine the agent runs on, with the rules the public CLI bundles. Tools: check (Postgres, SQLite in beta, and an optional database_url for live-schema context from a read-only replica), secrets (text or file paths, always local, values masked) and explain. Nothing leaves the machine unless a tool is given a database to read.
terminal
# Claude Code
claude mcp add bolvrk -- npx -y bolvrk mcp
# Cursor, Claude Desktop and others: mcpServers in their config
{ "mcpServers": { "bolvrk": { "command": "npx", "args": ["-y", "bolvrk", "mcp"] } } }
Hosted: Team and above
https://bolvrk.com/api/mcp speaks Streamable HTTP and authenticates with a team token in the Authorization header. It adds what the hosted service has: the full corpus, live schema through a stored connection (connection_id), the team policy, and the run log, every check an agent makes lands there with source mcp. Tools: check, policy, rules and rule. There is no hosted secrets tool: a credential scan never leaves the machine. On Free and Startup the endpoint answers 402 and points at the self-hosted server.
terminal
# Claude Code
claude mcp add --transport http bolvrk https://bolvrk.com/api/mcp --header "Authorization: Bearer blv_..."
# Cursor, Claude Desktop and others
{ "mcpServers": { "bolvrk": { "url": "https://bolvrk.com/api/mcp", "headers": { "Authorization": "Bearer blv_..." } } } }
The skills library
A server gives an agent the tools; a skill tells it when to use them. The public repository's skills/ folder holds short SKILL.md files, check a migration before committing it and fix findings rather than silencing them, scan a diff or a log for secrets before pasting it anywhere, explain a backfill against a shadow database. Copy one into your agent's skills folder (for Claude Code, .claude/skills/ in the repository or ~/.claude/skills/) and the workflow comes with it.
terminal
# into this repository, for everyone who works in it
git clone --depth 1 https://github.com/bolvrk/bolvrk /tmp/bolvrk && cp -r /tmp/bolvrk/skills/* .claude/skills/
# or for yourself, everywhere
cp -r /tmp/bolvrk/skills/* ~/.claude/skills/
Rule reference
Every rule across Postgres, SQLite 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.