Features & roadmap

Everything Bolvrk does — and what's next

69 Postgres rules at corpus v0.9.1 today, 35 SQLite rules in alpha, and an honest map of where the verification layer goes from here. A green check is live; everything else is labeled for what it is.

Getting started

One command, no account, no config. The open-source CLI runs the free rules locally — the outage core and credential hygiene; the hosted service runs the same rules on Free with the run log and PR comments, the rest of the corpus and live-schema context on Startup and above, and team policy on Team.

npx bolvrk check migration.sql

# with live-schema context from your own shadow database (Startup and above)
npx bolvrk check migration.sql --db=$SHADOW_DB

# through the cloud, credential-free — structure only, never rows
BOLVRK_TOKEN=blv_... npx bolvrk check migration.sql --db=$SHADOW_DB --remote

# the same credential rules over any file — always local, never uploaded
npx bolvrk secrets "src/**" ".env*"

Deterministic rule engine

The heart of Bolvrk: verification over real parse trees, never regex, never heuristics — so it can judge SQL from any author, human or AI.

Postgres migration safety checks

Locks that block writes, full-table rewrites, constraints that fail only on populated tables, destructive operations, replication hazards, column-type traps, credentials written into the migration.

Read more

Real parse-tree analysis

libpg_query — the exact parser Postgres uses. Keywords inside strings or comments can never trigger a false alarm.

Read more

Severity taxonomy

critical fails production; warning blocks or rewrites at scale; note means "this will work — and then likely regress performance".

Read more

Evidence-backed rules

Every rule ships with dangerous and safe-lookalike fixtures; lock and rewrite claims are validated empirically against Postgres 13–17 in CI.

Read more

False-positive discipline

A standing near-miss battery: safe SQL one token from every blocked pattern, asserted silent on every run. When we cannot prove a claim, we stay silent.

Read more

Fuzz-tested boundaries

Seeded fuzzing over every untrusted input: arbitrary SQL and hostile snapshots must produce a report or a clean parse error — never a crash.

Read more

A corpus of 69 rules

Sequence exhaustion projections, partition attach/detach hazards, operational safety, replication traps — each with fixtures both directions or not at all.

Read more

Credential rules: no secret ever reaches the repo

Plaintext role passwords, user-mapping and subscription credentials, connection strings with embedded passwords, vendor-format API keys and private keys, literals seeded into secret-named columns. Every finding masks the value — the report never repeats a secret.

Read more

Secret scanning for any file: bolvrk secrets

The same credential rules over .env, YAML, JSON, source and shell — line-indexed findings in the same contract, same policy overrides, SARIF for code scanning. Always local: a credential scan never leaves the machine.

Read more

Performance rules: indexing and query health

Redundant indexes, boolean-led indexes, B-tree where GIN belongs, foreign keys losing their covering index, index pile-up on hot tables. Advisory notes that never block unless your policy says so.

Read more

Query verification: bolvrk explain

Read-only EXPLAIN of the migration's own queries against the connected schema — sequential scans of large tables, nested loops over sequential scans, large sorts. Never executes a statement.

Read more

Live-schema context

Connected checks read the shape and traffic of your real schema — and go silent when it proves an operation safe. Structure only: never rows, never values, never a credential.

# audit every byte before it leaves your machine
npx bolvrk check migration.sql --db=$SHADOW_DB --remote --show-payload

Databases

Postgres first and deepest. The seam for more engines exists — one findings contract, one severity taxonomy, a dedicated parser and corpus per engine, each held to the same evidence bar.

The cloud: your system of record

Every connected run remembered — what went wrong, how often, trending where. The run log, PR comments and notifications are on every plan, Free included; validation is never metered. The cloud sells the full corpus, context and memory.

The run log

Every check from every connected client recorded with its findings — the history your team relies on when something does go wrong. Every plan, Free included.

Read more

Insights

Which rules fire most, how the clean-run rate trends, which days went sideways. Recurring findings are process problems — now visible. A window over the run log, nothing deleted: 30 days on Startup, a year on Team, unlimited on Scale.

Read more

Push-based dashboard

State streams to the dashboard over SSE — checks, connections, policy appear as they happen, nothing polls.

Read more

Team roles & tokens

Admin, member, viewer and billing roles, invites that carry a role, GitHub sign-in with zero extra scopes, instant per-token revocation.

Read more

Team policy: block on critical

Platform teams pick the severity that fails CI — critical only, warning and above, or any finding — and tune or silence any rule from the dashboard. On Team and above the policy governs every connected check — CI, bolvrk check --remote and the API alike.

Read more

Connection health

Stored connections are polled for reachability every 15 minutes — structure only. A check whose connection has since gone unreachable carries a stale-schema warning on its page, and a connection whose role could write is flagged so you can swap in the read-only role.

Read more

Findings feedback

"This finding is wrong" on any check page. Disputes are counted per rule in Insights, triaged by us, and feed the false-positive discipline.

Read more

Repository ignores

An ignore map in bolvrk.json silences a rule for a whole repository with a reason that travels with the code; the CLI and the Action send it along, and hosted checks honor it on Startup and above. Every report says how many findings it hid.

Read more

Run log filters

Filter the run log by repository to follow one service through its migrations; Insights break down by repository the same way.

Read more

Slack, Discord & webhook notifications

Findings and clean-run summaries where the team already looks — Slack and Discord incoming webhooks, or any HTTPS endpoint with HMAC-signed payloads.

Read more

Audit log

Who changed the policy, rotated a token, added a connection or moved a plan — every team-level action recorded with its actor, plus sign-ins, refused changes and every token used from a new address. Browsable from the dashboard, included in the export.

Read more

Retention & export

Set how long the run log is kept, and export checks and findings as CSV or JSON at any time — your data, in a shape any tool can read.

Read more

SSO/SAML & SCIM

planned

Single sign-on and directory provisioning for regulated teams. Enterprise plan.

Read more

Verification for the AI era

Models write the SQL; something deterministic has to judge it. Bolvrk runs no model. It is the checker in the loop, never the author.

Beyond schema changes

The mission is bigger than migrations: deterministic verification wherever AI-driven change can hurt a business from within.

Rule reference

Every rule ships with fixture migrations in both directions — dangerous variants that must fire, and safe look-alikes that must stay silent. Lock and rewrite claims are validated empirically against live Postgres before a rule ships. The BC family is built to catch credentials before they are committed — and runs over any file with bolvrk secrets. The SL family is the SQLite corpus, in alpha: it runs in the CLI behind --engine=sqlite and nowhere else yet. Each rule has its own page with the full rationale.

Category
BV001 · DS
DROP COLUMN with live foreign-key references
critical
free in the CLI
DS · Destructive changes

Dropping a column that other tables' foreign keys point at either fails mid-migration or, with CASCADE, silently drops those constraints — leaving orphanable rows in dependent tables.

ALTER TABLE users DROP COLUMN id;
Rule page →
BV002 · CN
ADD COLUMN NOT NULL without default
critical
free in the CLI
CN · Constraints & keys

Adding a NOT NULL column with no default fails on any table that already has rows — existing rows would violate the constraint. The migration errors out in production even though it works on an empty dev database.

ALTER TABLE orders ADD COLUMN region text NOT NULL;
Rule page →
BV003 · LK
Non-concurrent index creation
warning
free in the CLI
LK · Locks & blocking

CREATE INDEX holds a SHARE lock for the entire build, blocking INSERT/UPDATE/DELETE on the table. Build time scales with table size, so a migration that is instant in dev blocks writes for minutes in production.

CREATE INDEX idx_orders_region ON orders (region);
Rule page →
BV004 · RW
Column type change forcing a table rewrite
warning
free in the CLI
RW · Table rewrites

ALTER COLUMN TYPE takes ACCESS EXCLUSIVE and, unless the change is binary-coercible, rewrites every row. The table is completely unavailable — reads included — for the duration.

ALTER TABLE orders ALTER COLUMN id TYPE bigint;
Rule page →
BV005 · DW
Rename or drop breaking the deploy window
warning
free in the CLI
DW · Deploy window

Between the migration running and the last old app instance stopping, old code still queries the old name. A rename or drop makes those queries error immediately — a partial outage that lasts exactly as long as your rollout.

ALTER TABLE users RENAME COLUMN email TO email_address;
Rule page →
BV006 · TX
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.

ALTER TABLE orders ADD COLUMN region text;
CREATE INDEX CONCURRENTLY idx ON orders (region);
Rule page →
BV007 · RW
Volatile column default forcing a table rewrite
warning
hosted, paid
RW · Table rewrites

Adding a column with a constant default is instant on modern Postgres, so teams assume all defaults are. A volatile default must be evaluated per existing row, so Postgres rewrites the whole table under ACCESS EXCLUSIVE — reads and writes blocked for the duration.

ALTER TABLE users ADD COLUMN uid uuid DEFAULT gen_random_uuid();
Rule page →
BV008 · CN
Foreign key added without NOT VALID
warning
free in the CLI
CN · Constraints & keys

Adding a validated foreign key scans every existing row while holding a SHARE ROW EXCLUSIVE lock on both tables. With NOT VALID the constraint applies to new writes instantly, and VALIDATE CONSTRAINT can scan later with a much weaker lock.

ALTER TABLE orders ADD CONSTRAINT fk FOREIGN KEY (user_id) REFERENCES users (id);
Rule page →
BV009 · CN
CHECK constraint added without NOT VALID
warning
hosted, paid
CN · Constraints & keys

Adding a validated CHECK constraint scans the whole table while holding ACCESS EXCLUSIVE — nothing can read or write until the scan finishes. NOT VALID makes the ALTER instant; VALIDATE CONSTRAINT afterwards only takes SHARE UPDATE EXCLUSIVE, which does not block reads or writes.

ALTER TABLE orders ADD CONSTRAINT positive CHECK (total >= 0);
Rule page →
BV010 · DS
DROP with CASCADE hitting unexpected dependents
warning
free in the CLI
DS · Destructive changes

CASCADE resolves the dependency graph at execution time and drops everything in it, without listing what it took. What it removes in production can differ from dev — a view or foreign key added since, gone silently.

DROP TABLE legacy_events CASCADE;
Rule page →
BV011 · LK
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.

ALTER TABLE orders ALTER COLUMN region SET NOT NULL;
Rule page →
BV012 · LK
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.

ALTER TABLE orders ADD CONSTRAINT orders_pk PRIMARY KEY (id);
Rule page →
BV013 · DS
TRUNCATE in a migration
critical
free in the CLI
DS · Destructive changes

TRUNCATE deletes every row irreversibly, takes ACCESS EXCLUSIVE on the table, and cascades to referencing tables when asked. A migration that truncates in dev fixtures does the same to production data.

TRUNCATE orders CASCADE;
Rule page →
BV014 · DS
Unbounded UPDATE or DELETE
warning
free in the CLI
DS · Destructive changes

A WHERE-less UPDATE/DELETE rewrites or removes every row in a single transaction: it locks all rows for the duration, doubles the table in dead tuples, floods WAL, and stalls replicas. Backfills belong in batched jobs, not migrations.

UPDATE orders SET region = 'eu';
Rule page →
BV015 · LK
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.

VACUUM FULL orders;
Rule page →
BV016 · LK
DROP INDEX without CONCURRENTLY
note
free in the CLI
LK · Locks & blocking

DROP INDEX needs ACCESS EXCLUSIVE on the table. The drop itself is instant, but the lock request queues behind any running query touching the table — and every new query then queues behind the lock request. One slow SELECT turns an instant drop into a stall for all traffic.

DROP INDEX idx_orders_region;
Rule page →
BV017 · CN
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.

ALTER TABLE orders ADD CONSTRAINT fk FOREIGN KEY (user_id) REFERENCES users (id) NOT VALID;
Rule page →
BV018 · CN
Table created without a primary key
note
hosted, paid
CN · Constraints & keys

A table without a primary key (or any unique constraint) works until it doesn't: logical replication rejects UPDATE/DELETE without a replica identity, targeted row operations degrade to full scans, and maintenance tooling that addresses rows by key can't help you. Adding a key later on a populated table is exactly the migration this tool exists to warn about.

CREATE TABLE audit_log (entry text, created_at timestamptz);
Rule page →
BV019 · TX
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.

ALTER TYPE order_status ADD VALUE 'archived';
UPDATE orders SET status = 'archived';
Rule page →
BV020 · LK
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.

ALTER TABLE measurements DETACH PARTITION measurements_2024;
Rule page →
BV021 · LK
Materialized view refreshed without CONCURRENTLY
warning
hosted, paid
LK · Locks & blocking

A plain REFRESH MATERIALIZED VIEW takes an exclusive lock on the view while it recomputes — every SELECT against it blocks for the whole rebuild. REFRESH ... CONCURRENTLY lets readers keep the old contents during the rebuild; it requires a unique index on the view.

REFRESH MATERIALIZED VIEW daily_revenue;
Rule page →
BV022 · LK
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.

LOCK TABLE orders IN ACCESS EXCLUSIVE MODE;
Rule page →
BV023 · RP
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 bigint PRIMARY KEY);
Rule page →
BV024 · RW
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.

ALTER TABLE orders ADD COLUMN total_c numeric GENERATED ALWAYS AS (subtotal + tax) STORED;
Rule page →
BV025 · RW
SET TABLESPACE copying the table under full lock
warning
hosted, paid
RW · Table rewrites

Moving a table to another tablespace physically copies every block while holding ACCESS EXCLUSIVE. Copy time scales with table size; the table is unavailable throughout.

ALTER TABLE orders SET TABLESPACE fast_ssd;
Rule page →
BV026 · RW
SET LOGGED / SET UNLOGGED rewriting the table
warning
hosted, paid
RW · Table rewrites

Switching a table between LOGGED and UNLOGGED rewrites the whole table (SET LOGGED additionally writes every row to WAL) under ACCESS EXCLUSIVE.

ALTER TABLE staging_events SET LOGGED;
Rule page →
BV027 · DS
DROP SCHEMA or DROP DATABASE in a migration
critical
hosted, paid
DS · Destructive changes

Dropping a schema removes every object in it in one statement — with CASCADE, without even listing them. Dropping a database is the same at a larger radius. Neither belongs in schema history; both are irreversible.

DROP SCHEMA app CASCADE;
Rule page →
BV028 · DS
DROP OWNED BY erasing everything a role owns
critical
hosted, paid
DS · Destructive changes

DROP OWNED BY removes every object the role owns in the current database and revokes its grants — the blast radius is whatever that role ever created, which nobody can enumerate from the migration text.

DROP OWNED BY deploy_user;
Rule page →
BV029 · DS
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.

ALTER TABLE orders DISABLE TRIGGER ALL;
Rule page →
BV030 · DW
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.

ALTER TABLE users DROP COLUMN legacy_flags;
Rule page →
BV031 · CN
Inline UNIQUE or PRIMARY KEY on an added column
warning
hosted, paid
CN · Constraints & keys

ADD COLUMN ... UNIQUE (or PRIMARY KEY) creates the constraint's index inside the ALTER, under ACCESS EXCLUSIVE — the same trap as ADD CONSTRAINT, hidden in a column definition.

ALTER TABLE users ADD COLUMN email text UNIQUE;
Rule page →
BV032 · RW
CREATE TABLE AS copying data inside the migration
note
hosted, paid
RW · Table rewrites

CREATE TABLE AS runs its query to completion inside the migration's transaction: the copy's duration scales with the source data, the transaction stays open throughout (holding back vacuum and locks), and the copied rows are frozen at migration time — usually a backfill pretending to be schema.

CREATE TABLE orders_archive AS SELECT * FROM orders WHERE closed_at < '2024-01-01';
Rule page →
BV033 · DS
Sequence restarted — duplicate key collisions ahead
note
hosted, paid
DS · Destructive changes

RESTART rewinds a sequence regardless of what values are already in use. If any existing row holds an id at or above the restart point, inserts start failing with duplicate-key errors at some unpredictable later moment.

ALTER SEQUENCE orders_id_seq RESTART WITH 1;
Rule page →
BV034 · LK
Exclusive-lock DDL without a lock_timeout guard
note
hosted, paid
LK · Locks & blocking

An ACCESS EXCLUSIVE request queues behind any long-running query — and every new query queues behind it. Without SET lock_timeout, one slow report turns a metadata-only ALTER into a site-wide stall of unbounded length. A short lock_timeout makes the migration fail fast and retryable instead.

ALTER TABLE orders ADD COLUMN region text;
Rule page →
BV035 · LK
Repeated ALTER TABLE statements on the same table
note
hosted, paid
LK · Locks & blocking

Each ALTER TABLE statement acquires its own ACCESS EXCLUSIVE lock — queueing behind traffic every single time. One ALTER TABLE with comma-separated actions acquires the lock once and does all the work under it.

ALTER TABLE orders ADD COLUMN a text;
ALTER TABLE orders ADD COLUMN b text;
Rule page →
BV036 · LK
VALIDATE CONSTRAINT mixed with exclusive-lock DDL
note
hosted, paid
LK · Locks & blocking

VALIDATE CONSTRAINT deliberately takes only SHARE UPDATE EXCLUSIVE so it can run without blocking — but in the same transaction as ACCESS EXCLUSIVE DDL, the exclusive locks are held while the validation scans the whole table. The non-blocking scan drags the blocking locks out with it.

ALTER TABLE orders ADD COLUMN region text;
ALTER TABLE orders VALIDATE CONSTRAINT orders_total_check;
Rule page →
BV037 · RP
REPLICA IDENTITY FULL logging whole rows
note
hosted, paid
RP · Replication & durability

REPLICA IDENTITY FULL writes the complete old row into WAL for every UPDATE and DELETE, and logical decoding must compare entire rows downstream. On write-heavy tables that is a permanent WAL and CPU tax; an index-based replica identity carries only the key.

ALTER TABLE orders REPLICA IDENTITY FULL;
Rule page →
BV038 · RP
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.

ALTER TABLE orders SET (autovacuum_enabled = false);
Rule page →
BV039 · DW
Enum value renamed inside the deploy window
note
hosted, paid
DW · Deploy window

RENAME VALUE changes the label in place: every app instance still deployed with the old label gets an invalid-enum error on insert or comparison until the rollout completes — the enum flavor of the rename deploy-window break.

ALTER TYPE order_status RENAME VALUE 'pending' TO 'awaiting';
Rule page →
BV040 · TY
timestamp without time zone
note
hosted, paid
TY · Type choices

Plain timestamp stores wall-clock digits with no zone: the moment it crosses a server, client, or DST boundary, the same value means different instants. timestamptz stores an absolute instant and costs the same 8 bytes.

CREATE TABLE events (occurred_at timestamp);
Rule page →
BV041 · TY
char(n) with its padding semantics
note
hosted, paid
TY · Type choices

char(n) space-pads every value to n and strips the padding in surprising places — comparisons and lengths behave differently from every other string type, with no storage or speed benefit over text in Postgres.

CREATE TABLE users (country_code char(2));
Rule page →
BV042 · TY
money type bound to server locale
note
hosted, paid
TY · Type choices

The money type's meaning depends on the server's lc_monetary locale: no currency is stored, fractional precision is fixed, and dumping/restoring across locales reinterprets the values. numeric plus an explicit currency column is the boring, correct answer.

CREATE TABLE orders (total money);
Rule page →
BV043 · TY
serial instead of an identity column
note
hosted, paid
TY · Type choices

serial is a legacy macro: the sequence it creates is only loosely attached, permissions and ownership drift from the column, and anyone can still insert arbitrary values. GENERATED ... AS IDENTITY is the standard replacement with tighter semantics.

CREATE TABLE users (id bigserial PRIMARY KEY);
Rule page →
BV044 · TY
32-bit integer primary key headed for exhaustion
note
hosted, paid
TY · Type choices

An int primary key tops out at 2,147,483,647. Tables that get there discover it as an outage, and the fix — retyping the primary key and every referencing column — is one of the worst migrations there is. bigint costs 4 more bytes now and removes the cliff.

CREATE TABLE orders (id serial PRIMARY KEY);
Rule page →
BV045 · LK
Database-wide REINDEX in a migration
critical
hosted, paid
LK · Locks & blocking

REINDEX SYSTEM and REINDEX DATABASE rebuild every index in the database, system catalogs included — and catalog indexes can never be rebuilt concurrently, so their rebuild takes locks that stall catalog lookups for every session. Runtime scales with total index volume across the database. Whatever a migration needs, it is never this; rebuild the specific index or table instead.

REINDEX DATABASE app;
Rule page →
BV046 · OP
INSERT ... SELECT backfill inside the migration
note
hosted, paid
OP · Operational safety

An unbounded INSERT ... SELECT runs to completion inside the migration's transaction: its duration scales with the source data, the transaction stays open the whole time (holding locks and holding back vacuum), and a failure rolls back everything — a backfill wearing a migration's clothes.

INSERT INTO orders_archive SELECT * FROM orders WHERE closed_at < '2024-01-01';
Rule page →
BV047 · LK
Timeout guard disabled with SET ... = 0
warning
hosted, paid
LK · Locks & blocking

SET lock_timeout = 0 (or statement_timeout = 0) switches the safety off: zero means 'wait forever'. A migration that disables its timeouts can queue behind one slow query indefinitely — with all new traffic queueing behind it — precisely the stall the guard exists to prevent.

SET statement_timeout = 0;
ALTER TABLE orders ADD COLUMN region text;
Rule page →
BV048 · OP
ALTER SYSTEM in a migration
critical
hosted, paid
OP · Operational safety

ALTER SYSTEM edits the server's configuration for every database and every connection, persists in postgresql.auto.conf far beyond the migration, cannot run inside a transaction block, and usually needs a reload or restart to even take effect. Nothing about it is schema — it is cluster administration in migration clothing.

ALTER SYSTEM SET max_connections = 500;
Rule page →
BV049 · PC
Row-level security disabled on an existing table
critical
hosted, paid
PC · Privileges & credentials

DISABLE ROW LEVEL SECURITY switches off every policy on the table at once: from the moment it commits, queries see all rows, not the policy-filtered subset. If RLS was carrying tenant isolation or access control, this single line is a data exposure — and it looks like routine DDL.

ALTER TABLE orders DISABLE ROW LEVEL SECURITY;
Rule page →
BV050 · PC
Write privileges granted to PUBLIC
warning
hosted, paid
PC · Privileges & credentials

GRANT ... TO PUBLIC applies to every role the cluster has now — and every role created later, forever. Granting write privileges (or ALL) on a table to PUBLIC turns one migration line into a standing policy that no future account can be excluded from.

GRANT ALL ON orders TO PUBLIC;
Rule page →
BP001 · IX
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.

CREATE INDEX idx_orders_user ON orders (user_id);
CREATE INDEX idx_orders_user_created ON orders (user_id, created_at);
Rule page →
BP002 · IX
Index led by a boolean or near-constant column
note
hosted, paid
IX · Index hygiene

A B-tree whose leading column has two values splits the table in half at best. The planner will usually ignore it in favor of a sequential scan, and when it does use it the index returns half the table. Either way every write pays to maintain it. The useful form is a partial index — WHERE is_active — that only contains the rows you actually look up.

CREATE INDEX idx_users_active ON users (is_active);
Rule page →
BP003 · IX
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.

CREATE INDEX idx_events_payload ON events (payload);
Rule page →
BP004 · IX
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.

DROP INDEX CONCURRENTLY idx_orders_user;
Rule page →
BP005 · IX
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.

CREATE INDEX CONCURRENTLY idx_events_region ON events (region);
Rule page →
BP006 · QS
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.

CREATE INDEX idx_orders_created_status ON orders (created_at, status);
UPDATE orders SET archived = true WHERE status = 'closed' AND created_at < '2024-01-01';
Rule page →
BP007 · QS
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.

CREATE INDEX idx_users_lower_email ON users (lower(email));
UPDATE users SET verified = true WHERE email = 'a@example.com';
Rule page →
BP008 · QS
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';
Rule page →
BP009 · TS
Column statistics disabled with SET STATISTICS 0
note
hosted, paid
TS · Table settings

SET STATISTICS 0 tells ANALYZE to collect nothing for the column. From then on the planner estimates every predicate on it with hard-coded defaults (selectivity 0.5 for equality, 1/3 for ranges) — row estimates go wrong by orders of magnitude, and with them join order, index choice, and memory sizing for every query that touches the column.

ALTER TABLE orders ALTER COLUMN region SET STATISTICS 0;
Rule page →
BP010 · TS
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.

ALTER TABLE orders SET (autovacuum_vacuum_scale_factor = 0.8);
Rule page →
BP011 · IX
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.

DROP INDEX CONCURRENTLY idx_orders_region;
Rule page →
BS001 · SO
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.

ALTER TABLE orders ADD CONSTRAINT orders_user_fk FOREIGN KEY (user_id) REFERENCES users (id) NOT VALID;
-- ...and no later migration runs VALIDATE CONSTRAINT orders_user_fk
Rule page →
BS002 · SO
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.

-- 0003_cleanup.sql
ALTER TABLE orders DROP COLUMN legacy_flag;
-- 0005_index.sql
CREATE INDEX idx_orders_legacy ON orders (legacy_flag);
Rule page →
BC001 · PC
Role password set in plaintext by the migration
critical
free in the CLI
PC · Privileges & credentials

CREATE ROLE … PASSWORD 'x' puts the database password into a file that is committed, reviewed in pull requests, printed by CI and kept in git history forever — and the server writes the statement to its own log when log_statement covers DDL. Rotating it later does not un-leak it. Postgres accepts an already-hashed SCRAM-SHA-256 verifier in the same position, and the rule stays silent on one; an md5 verifier is reported as a warning because it can be cracked offline.

CREATE ROLE app_user LOGIN PASSWORD 'hunter2';
Rule page →
BC002 · PC
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');
Rule page →
BC003 · PC
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.

INSERT INTO integrations (name, dsn) VALUES ('warehouse', 'postgres://etl:hunter2@warehouse.internal:5432/dw');
Rule page →
BC004 · PC
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.

INSERT INTO settings (key, value) VALUES ('stripe_secret', 'sk_live_51H8zK2eZvKYlo2CabcDEFghij');
Rule page →
BC005 · PC
Secret-named column given a literal value
warning
free in the CLI
PC · Privileges & credentials

A column called password, api_key, secret or token that receives a literal in a DEFAULT, an INSERT or an UPDATE is a credential written into the migration by hand — the seeded admin account with password 'admin', the api_key DEFAULT 'changeme' that ships to every row. The name is the evidence, so this is a warning rather than a certainty: the rule stays silent on hashed values (bcrypt, argon2, SCRAM, pbkdf2), on empty strings and placeholders, and on names that merely contain the word (password_hash, token_type, api_key_id).

INSERT INTO users (email, password) VALUES ('admin@example.com', 'admin123');
Rule page →
BC006 · PC
Secret-named setting assigned a literal value
warning
free in the CLI
PC · Privileges & credentials

DB_PASSWORD=hunter2 in a .env, "client_secret": "…" in a JSON config, api_key = '…' in a settings module, password: … in a YAML manifest — a key whose name says secret, given a literal instead of a reference. The name is the evidence, so this is a warning rather than a certainty: the scanner stays silent when the value is a placeholder, an environment or template reference, a hash, a type annotation, or a word like example or test, and on names that merely contain the word (password_hash, token_type, api_key_id).

DB_PASSWORD=hunter2
STRIPE_SECRET="sk_live_…"
"client_secret": "0f3a…"
Rule page →
SL001 · CN
ADD COLUMN NOT NULL without a default
SQLite · alpha
critical
free in the CLI — --engine=sqlite
CN · Constraints & keys

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 orders ADD COLUMN region TEXT NOT NULL;
Rule page →
SL002 · CN
ADD COLUMN with PRIMARY KEY or UNIQUE
SQLite · alpha
critical
free in the CLI — --engine=sqlite
CN · Constraints & keys

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.

ALTER TABLE orders ADD COLUMN external_id TEXT UNIQUE;
Rule page →
SL003 · CN
ADD COLUMN with a non-constant default
SQLite · alpha
critical
free in the CLI — --engine=sqlite
CN · Constraints & keys

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.

ALTER TABLE orders ADD COLUMN created_at TEXT DEFAULT CURRENT_TIMESTAMP;
Rule page →
SL004 · CN
ADD COLUMN GENERATED ... STORED
SQLite · alpha
critical
free in the CLI — --engine=sqlite
CN · Constraints & keys

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.

ALTER TABLE orders ADD COLUMN total_cents INTEGER GENERATED ALWAYS AS (qty * unit_cents) STORED;
Rule page →
SL005 · CN
ADD COLUMN REFERENCES with a non-NULL default
SQLite · alpha
warning
free in the CLI — --engine=sqlite
CN · Constraints & keys

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.

ALTER TABLE orders ADD COLUMN warehouse_id INTEGER REFERENCES warehouses(id) DEFAULT 1;
Rule page →
SL006 · RW
DROP COLUMN rewrites the table
SQLite · alpha
warning
free in the CLI — --engine=sqlite
RW · Table rewrites

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.

ALTER TABLE orders DROP COLUMN legacy_flag;
Rule page →
SL007 · DW
RENAME COLUMN or RENAME TABLE during a deploy
SQLite · alpha
warning
free in the CLI — --engine=sqlite
DW · Deploy window

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.

ALTER TABLE orders RENAME COLUMN qty TO quantity;
Rule page →
SL008 · LK
ADD COLUMN with a CHECK constraint scans the table
SQLite · alpha
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.

ALTER TABLE orders ADD COLUMN priority INTEGER DEFAULT 0 CHECK (priority BETWEEN 0 AND 9);
Rule page →
SL009 · CN
Table rebuild with foreign keys still enforced
SQLite · alpha
warning
free in the CLI — --engine=sqlite
CN · Constraints & keys

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.

CREATE TABLE orders_new (id INTEGER PRIMARY KEY, qty INTEGER NOT NULL);
INSERT INTO orders_new SELECT id, qty FROM orders;
DROP TABLE orders;
ALTER TABLE orders_new RENAME TO orders;
Rule page →
SL010 · TX
PRAGMA that is a no-op inside a transaction
SQLite · alpha
warning
free in the CLI — --engine=sqlite
TX · Transactions

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.

BEGIN;
PRAGMA foreign_keys = OFF;
DROP TABLE orders;
COMMIT;
Rule page →
SL011 · OP
Foreign keys switched off and not back on
SQLite · alpha
warning
free in the CLI — --engine=sqlite
OP · Operational safety

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.

PRAGMA foreign_keys = OFF;
DROP TABLE orders;
Rule page →
SL012 · CN
Foreign keys re-enabled without a foreign_key_check
SQLite · alpha
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.

PRAGMA foreign_keys = OFF;
BEGIN;
DROP TABLE orders;
ALTER TABLE orders_new RENAME TO orders;
COMMIT;
PRAGMA foreign_keys = ON;
Rule page →
SL013 · DS
Unbounded UPDATE or DELETE
SQLite · alpha
warning
free in the CLI — --engine=sqlite
DS · Destructive changes

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.

UPDATE orders SET status = 'archived';
Rule page →
SL014 · LK
VACUUM in a migration
SQLite · alpha
warning
free in the CLI — --engine=sqlite
LK · Locks & blocking

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.

VACUUM;
Rule page →
SL015 · LK
REINDEX of everything
SQLite · alpha
warning
free in the CLI — --engine=sqlite
LK · Locks & blocking

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.

REINDEX;
Rule page →
SL016 · LK
Index build on an existing table
SQLite · alpha
note
free in the CLI — --engine=sqlite
LK · Locks & blocking

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.

CREATE INDEX orders_status_idx ON orders (status);
Rule page →
SL017 · TY
INT PRIMARY KEY is not a rowid alias
SQLite · alpha
note
free in the CLI — --engine=sqlite
TY · Type choices

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.

CREATE TABLE orders (id INT PRIMARY KEY, qty INTEGER);
Rule page →
SL018 · TY
AUTOINCREMENT where a plain INTEGER PRIMARY KEY would do
SQLite · alpha
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.

CREATE TABLE orders (id INTEGER PRIMARY KEY AUTOINCREMENT, qty INTEGER);
Rule page →
SL019 · DS
Table rebuild that drops the old table before copying its rows
SQLite · alpha
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.

CREATE TABLE orders_new (id INTEGER PRIMARY KEY, qty INTEGER NOT NULL);
DROP TABLE orders;
ALTER TABLE orders_new RENAME TO orders;
Rule page →
SL020 · TY
CREATE TABLE without STRICT
SQLite · alpha
note
free in the CLI — --engine=sqlite
TY · Type choices

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.

CREATE TABLE orders (id INTEGER PRIMARY KEY, qty INTEGER, note TEXT);
Rule page →
SL021 · IX
Foreign key column without an index
SQLite · alpha
note
free in the CLI — --engine=sqlite
IX · Index hygiene

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.

CREATE TABLE orders (id INTEGER PRIMARY KEY, user_id INTEGER REFERENCES users(id));
Rule page →
SL022 · DW
Rename with legacy_alter_table on
SQLite · alpha
warning
free in the CLI — --engine=sqlite
DW · Deploy window

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".

PRAGMA legacy_alter_table = ON;
ALTER TABLE orders RENAME TO purchases;
Rule page →
SL023 · DS
Dropping what a view or trigger in this migration depends on
SQLite · alpha
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.

CREATE VIEW open_orders AS SELECT id, status FROM orders WHERE status = 'open';
ALTER TABLE orders DROP COLUMN status;
Rule page →
SL024 · CN
PRIMARY KEY column that accepts NULL
SQLite · alpha
note
free in the CLI — --engine=sqlite
CN · Constraints & keys

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.

CREATE TABLE orders (ref TEXT PRIMARY KEY, qty INTEGER);
Rule page →
SL025 · TX
Write migration in a DEFERRED transaction
SQLite · alpha
note
free in the CLI — --engine=sqlite
TX · Transactions

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;
ALTER TABLE orders ADD COLUMN region TEXT;
COMMIT;
Rule page →
SL026 · TX
BEGIN inside a transaction, or COMMIT without one
SQLite · alpha
critical
free in the CLI — --engine=sqlite
TX · Transactions

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;
ALTER TABLE orders ADD COLUMN region TEXT;
COMMIT;
Rule page →
SL027 · DS
Unbounded DELETE that cascades to child tables
SQLite · alpha
warning
free in the CLI — --engine=sqlite
DS · Destructive changes

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.

CREATE TABLE lines (id INTEGER PRIMARY KEY, order_id INTEGER REFERENCES orders(id) ON DELETE CASCADE);
DELETE FROM orders;
Rule page →
SL028 · IX
Index on a non-deterministic expression
SQLite · alpha
critical
free in the CLI — --engine=sqlite
IX · Index hygiene

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.

CREATE INDEX orders_shuffle ON orders (id + random());
Rule page →
SL029 · TY
AUTOINCREMENT on a WITHOUT ROWID table
SQLite · alpha
critical
free in the CLI — --engine=sqlite
TY · Type choices

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.

CREATE TABLE sessions (id INTEGER PRIMARY KEY AUTOINCREMENT, token TEXT) WITHOUT ROWID;
Rule page →
SL030 · OP
ATTACH DATABASE in a migration
SQLite · alpha
warning
free in the CLI — --engine=sqlite
OP · Operational safety

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;
INSERT INTO orders SELECT * FROM legacy.orders;
Rule page →
SL031 · IX
Redundant index
SQLite · alpha
note
free in the CLI — --engine=sqlite
IX · Index hygiene

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.

CREATE INDEX orders_user ON orders (user_id);
CREATE INDEX orders_user_created ON orders (user_id, created_at);
Rule page →
SL032 · IX
Indexes built before the bulk copy in a rebuild
SQLite · alpha
note
free in the CLI — --engine=sqlite
IX · Index hygiene

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.

CREATE TABLE orders_new (id INTEGER PRIMARY KEY, user_id INTEGER, qty INTEGER) STRICT;
CREATE INDEX orders_new_user ON orders_new (user_id);
INSERT INTO orders_new SELECT id, user_id, qty FROM orders;
Rule page →
SL033 · TS
Text or blob primary key on a rowid table
SQLite · alpha
note
free in the CLI — --engine=sqlite
TS · Table settings

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.

CREATE TABLE sessions (token TEXT PRIMARY KEY NOT NULL, user_id INTEGER) STRICT;
Rule page →
SL034 · TS
Journal mode set to something other than WAL
SQLite · alpha
note
free in the CLI — --engine=sqlite
TS · Table settings

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.

PRAGMA journal_mode = DELETE;
Rule page →
SL035 · OP
Connection-scoped pragma in a migration
SQLite · alpha
note
free in the CLI — --engine=sqlite
OP · Operational safety

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.

PRAGMA synchronous = OFF;
PRAGMA mmap_size = 268435456;
ALTER TABLE orders ADD COLUMN region TEXT;
Rule page →

Want something moved up this page?

The roadmap bends toward what connected teams actually need — tell us.

Talk to us