ALTER semantics¶
SQLite ALTERs behave differently from every other database dbwarden supports. The native grammar is deliberately minimal, the schema is stored as plain text that ALTER rewrites, and DDL is fully transactional. Understanding these properties explains why dbwarden emits a native ALTER TABLE for some changes and a full table rebuild for everything else.
Why SQLite ALTERs are special¶
Three properties set SQLite apart:
-
A minimal native grammar. Each
ALTER TABLEstatement performs exactly one action, and only a handful of actions exist. There is noALTER COLUMN TYPE, no multi-action comma form, and (before 3.53.0) no way to add or drop a constraint after creation. Anything outside the native forms requires rebuilding the table, and dbwarden generates that rebuild for you. -
The schema is stored as text. SQLite keeps the original
CREATEstatements insqlite_schema(sqlite_master). EveryALTER TABLEmodifies that text and reparses the entire schema; the statement only succeeds if the schema is still valid afterwards. Renames therefore rewrite the stored SQL of other objects (triggers, views, foreign key clauses), not just the table's own row. -
DDL is transactional, but there is only one writer. SQLite transactions are atomic — "all changes within a single transaction in SQLite either occur completely or not at all" (sqlite.org) — so a failed migration rolls back cleanly, unlike MySQL or ClickHouse. The cost is concurrency: one writer at a time, and a rebuild holds the write lock while it copies every row.
Native ALTER support¶
Feature-introduction timeline from the SQLite changelog:
| Form | Since |
|---|---|
ALTER TABLE ... RENAME TO |
3.1.2 (2005) |
ALTER TABLE ... ADD COLUMN |
3.2.0 (2005) |
ALTER TABLE ... RENAME COLUMN |
3.25.0 (2018) |
ALTER TABLE ... DROP COLUMN |
3.35.0 (2021) |
ALTER COLUMN ... SET/DROP NOT NULL, ADD/DROP CONSTRAINT ... CHECK |
3.53.0 (2026) |
Two consequences:
- No batching syntax. One statement, one action. dbwarden never combines SQLite ALTERs — instead its collapse pass folds every change that needs a rebuild into a single rebuild per table (see below).
- dbwarden does not version-sniff. It emits the same SQL against every SQLite version: the four classic forms natively, everything else as a rebuild. The 3.53.0
SET/DROP NOT NULLsyntax is not used, so behavior is identical across versions; a nativeDROP COLUMNdoes require SQLite ≥ 3.35 at apply time (see version gotchas).
ADD COLUMN restrictions¶
From the SQLite ALTER TABLE documentation, the new column may take any form permissible in CREATE TABLE, with these restrictions:
- The column may not have a
PRIMARY KEYorUNIQUEconstraint. - The column may not have a default of
CURRENT_TIME,CURRENT_DATE,CURRENT_TIMESTAMP, or an expression in parentheses. - If
NOT NULLis specified, the column must have a default other thanNULL. - With foreign key enforcement on, a column with a
REFERENCESclause must default toNULL. - The column may not be
GENERATED ALWAYS ... STORED(VIRTUALis allowed).
Two runtime subtleties:
- Since 3.37.0, adding a column with a
CHECKconstraint — or aNOT NULLconstraint on a generated column — validates the new constraint against all preexisting rows and fails theADD COLUMNif any row violates it. Before 3.37.0 the violation went undetected at ALTER time. - That validation makes the ALTER take time proportional to table size. Constraint-free additions only rewrite schema text and run in constant time.
How dbwarden handles it¶
add_column_requires_rebuild in sql_build.py mirrors the SQLite rules: a primary key, UNIQUE, STORED generated, NOT NULL without a default, or non-constant-default column is emitted as a rebuild; anything else is a native ALTER TABLE ... ADD COLUMN.
DROP COLUMN¶
DROP COLUMN was added in 3.35.0 and works only when the column is not referenced elsewhere in the schema. Per the documentation, it fails when the column:
- is a
PRIMARY KEYor part of one, - has a
UNIQUEconstraint, - is indexed, or named in a partial index's
WHEREclause, - is named in a
CHECKconstraint not associated with the column itself, - is used in a foreign key constraint,
- is used in a generated column expression,
- appears in a trigger or view.
Unlike ADD COLUMN, dropping a column rewrites every row, so it takes time proportional to table size even when it is natively supported.
DROP COLUMN corrupted databases before 3.35.5
The DROP COLUMN implementation in 3.35.0–3.35.4 "could corrupt the database file" when the table was rewritten (changelog, fixed in 3.35.5). Do not rely on native DROP COLUMN against SQLite older than 3.35.5 — and never against < 3.35.0, where the statement does not exist.
How dbwarden handles it¶
drop_column_requires_rebuild in sql_build.py checks the same reference list (primary key, index, constraint, generated expression) and falls back to a rebuild when any apply. A rebuild that drops a column discards its data; the rollback restores the column but not its contents, and the migration is reported as a conditional rollback for that reason.
RENAME semantics and stored schema text¶
Renames are the subtlest SQLite ALTERs because they rewrite stored SQL text:
- Since 3.25.0,
RENAME TOandRENAME COLUMNalso rewrite references inside triggers and views that mention the renamed object. - Foreign key
REFERENCESclauses in other tables are rewritten too — only whenPRAGMA foreign_keys=ONbefore 3.26.0, unconditionally since 3.26.0:
foreign_keys |
legacy_alter_table |
FK references rewritten | Version |
|---|---|---|---|
| Off | Off | No | < 3.26.0 |
| Off | Off | Yes | ≥ 3.26.0 |
| On | Off | Yes | all |
| any | On | No | all |
PRAGMA legacy_alter_table=ON(3.25.2+) restores the pre-3.25 behavior where only the object's ownCREATEstatement is edited and references inside trigger/view bodies are left pointing at the old name. It is a per-connection, non-persistent setting intended as a workaround for programs that depend on the old behavior. dbwarden never sets it.
Because the rewrite is textual, an ALTER TABLE also fails — changing nothing — if any row in sqlite_schema does not parse (that strictness can be disabled with PRAGMA writable_schema=ON since 3.38.0, which dbwarden does not use).
The ordering trap¶
The official 12-step procedure for arbitrary schema changes is explicit about ordering:
The safe procedure constructs the revised table definition using a new temporary name, then renames the table into its final name... Renaming the original table away first "might corrupt references to that table in triggers, views, and foreign key constraints."
-- Correct (what dbwarden generates)
CREATE TABLE users__dbw_new (...);
INSERT INTO users__dbw_new SELECT ... FROM users;
DROP TABLE users;
ALTER TABLE users__dbw_new RENAME TO users;
-- Incorrect: renaming the original first can corrupt
-- trigger, view, and FK references to it (3.25.0+)
ALTER TABLE users RENAME TO users_old;
CREATE TABLE users (...);
dbwarden's rebuild always uses the safe create → copy → drop → rename order, with the staging table named <table>__dbw_new. See Table rebuilds for the full generated sequence.
PRAGMA foreign_keys is a no-op inside a transaction¶
From the PRAGMA documentation:
This pragma is a no-op within a transaction; foreign key constraint enforcement may only be enabled or disabled when there is no pending BEGIN or SAVEPOINT.
This is why the official procedure disables foreign keys before starting the transaction, and it constrains migration tooling: dbwarden runs each migration inside a single transaction (see locking below), so a PRAGMA foreign_keys emitted mid-migration would silently do nothing. dbwarden therefore never emits it and relies on the SQLite default (OFF). If your deployment enables foreign keys on the migration connection, turn them off before running the migration — with enforcement on, the DROP TABLE step of a rebuild can be blocked or cascade into referencing tables.
A related pragma, PRAGMA defer_foreign_keys=ON, delays FK enforcement until commit for the current transaction — useful in hand-written data migrations, and automatically reset at each COMMIT or ROLLBACK.
Locking and busy_timeout¶
SQLite allows any number of readers but exactly one writer:
- In rollback-journal mode, a writer must escalate to an
EXCLUSIVElock to commit; active readers block that escalation and the writer fails withSQLITE_BUSYonce the busy timeout expires (locking architecture). - In WAL mode, readers never block the writer and vice versa, but there is still only one writer — a concurrent migration still gets
SQLITE_BUSY.
dbwarden's SQLite lock strategy (dbwarden/lock/sqlite.py) runs the entire migration in one BEGIN IMMEDIATE transaction on the lock connection. That acquires the write lock up front, makes the whole migration atomic, and means a crash mid-migration releases everything with no schema change applied. The busy timeout is controlled by the sqlite_busy_timeout config key and defaults to 0 (fail fast with SQLITE_BUSY rather than queue behind another writer).
The practical consequence for rebuilds: a rebuild copies every row while holding the database-wide write lock. On a large table that is a maintenance-window operation, which is why check-impact flags rebuild-triggering option changes as warnings.
How dbwarden decides: native ALTER vs rebuild¶
The SQLite backend runs a collapse pass (collapse_sqlite_ops in collapse.py) over the diff before any SQL is emitted:
- Ops that SQLite can express natively pass through unchanged:
rename_table,rename_column,add_index/drop_index, and theadd_column/drop_columnvariants described above. - Every remaining op on a table — type, nullability, or default changes, constraint adds/drops,
WITHOUT ROWID/STRICTchanges, generated-column or collation changes — is folded into onerecreate_sq_tableop per table, regardless of how many individual changes were requested. - Index ops on a table being rebuilt are dropped from the op list, because the rebuild recreates the table's indexes itself.
- If the before/after table shapes needed to build the rebuild are unavailable, the original ops are kept so the failure is visible: emit falls back to comment-only statements such as
-- SQLite: ADD CONSTRAINT ... (not supported).
The full trigger matrix is documented in What triggers a rebuild.
dbwarden's batching behavior¶
| Handler | Emits natively | Batching |
|---|---|---|
| SqTableHandler | Rebuild script only | All table/column-meta changes collapse into one rebuild per table |
| ColumnHandler | RENAME COLUMN, ADD COLUMN, DROP COLUMN |
One statement per op |
| RenameTableHandler | ALTER TABLE ... RENAME TO |
One statement per op |
| IndexHandler | CREATE [UNIQUE] INDEX / DROP INDEX |
One statement per op; folded into the rebuild when the table rebuilds |
| ConstraintHandler | — (rebuild via collapse) | Comment-only fallback when the rebuild can't be constructed |
A rebuild is a single MigrationStatement whose SQL is a multi-statement script (create, copy, drop, rename, recreate indexes); the statements execute individually, in order, inside the migration transaction.
Operation catalog¶
Complete reference of the ALTER shapes dbwarden generates for SQLite, with their classification. Two systems apply: the --force gate (blocks migration generation/apply on dangerous changes; shown as the Safety column below, — = not gated) and the impact-plan severity reported by dbwarden check-impact, which additionally rates drop_column as ERROR and drop_index / drop_foreign_key / recreate_sq_table as WARNING.
Native ALTER operations¶
| Operation | Handler | Emitted as | Safety | Notes |
|---|---|---|---|---|
| Rename table | RenameTableHandler | ALTER TABLE old RENAME TO new; |
— | Rewrites trigger/view/FK text (3.25.0+) |
| Rename column | ColumnHandler | ALTER TABLE t RENAME COLUMN a TO b |
— | 3.25.0+ |
| Add column | ColumnHandler | ALTER TABLE t ADD COLUMN c <def> |
INFO | Restrictions above; else rebuild |
| Drop column | ColumnHandler | ALTER TABLE t DROP COLUMN c |
WARNING, --force |
3.35.0+ (≥3.35.5 advised); rewrites all rows |
| Add index | IndexHandler | CREATE [UNIQUE] INDEX ix ON t (...) |
INFO | Partial/expression indexes preserved |
| Drop index | IndexHandler | DROP INDEX ix |
— | Not gated; impact plan rates WARNING |
Rebuild operations (one rebuild per table, any mix of triggers)¶
| Trigger | Safety | Notes |
|---|---|---|
| Column type change | WARNING, --force |
Generic change_column_type classification |
sq_without_rowid / sq_strict change |
WARNING, --force |
Flagged with "(rebuilds the table and copies every row)" |
sq generated column / collation change |
WARNING, --force |
Same message |
| Column nullability / default change | — | Not separately flagged; rebuild is visible in the migration |
Add/drop UNIQUE, CHECK, FOREIGN KEY |
— | Constraints can only enter via the CREATE statement |
ADD COLUMN hitting a native restriction |
INFO (as add column) | PK, UNIQUE, NOT NULL w/o default, non-constant default, STORED generated |
DROP COLUMN hitting a native restriction |
WARNING, --force |
PK, indexed, constraint-referenced, generated-referenced |
The rebuild's rollback is the rebuild in the other direction (rollback_kind="real"), except when columns were dropped — then the rollback restores the columns but not their data and is reported as conditional.
Version-specific gotchas¶
| Version | Issue |
|---|---|
| 3.25.0 | Rename sanity-check false positives roll back valid ALTERs; fixed in 3.25.1 |
| 3.25.0–3.25.x | FK references rewritten on rename only with foreign_keys=ON; unconditional from 3.26.0 |
| 3.35.0–3.35.4 | DROP COLUMN can corrupt the database file; fixed in 3.35.5 |
| < 3.37.0 | ADD COLUMN does not validate new CHECK/NOT NULL constraints against existing rows |
| 3.7.0–3.51.2 | WAL-mode reset bug can corrupt the database when a writer and a checkpoint race; fixed in 3.51.3 (backports 3.44.6, 3.50.7) |
Recommended floor: 3.26.0 for predictable rename behavior, 3.35.5 if you rely on native DROP COLUMN, 3.37.0 for STRICT tables and ADD COLUMN validation, 3.51.3 (or a backport) for WAL deployments.
Related¶
- SQLite overview: table rebuilds,
WITHOUT ROWID/STRICT, generated columns, type fidelity - Migration locking: how the
BEGIN IMMEDIATElock coordinates concurrent runs - Rollback generation: the rollback contract rebuilds satisfy
- Common SQL databases: cross-backend DDL behavior comparison