Chapter 17 · Schema Evolution, Migrations, Testing, and Release Compatibility
ALTER TABLE Capabilities and SQLite’s Schema-Rewrite Model
Learn exactly which schema changes SQLite can perform directly, why its schema-as-text design matters, and when a controlled table rebuild is the safer migration primitive.
Learning outcomes
A schema migration is a program that changes the structure and meaning of persistent data. SQLite makes many common changes easy, but it does not pretend that every table redesign can be expressed as one ALTER TABLE clause. The reliable mental model is: understand the currently supported direct operations, understand SQLite’s schema-as-text design, and use a deliberate rebuild when the desired change exceeds those operations.
Identify current direct ALTER TABLE capabilities and their version boundaries.
Explain why SQLite stores schema definitions as SQL text in sqlite_schema and reparses them after alteration.
Apply ADD COLUMN and understand its restrictions against existing rows.
Recognize DROP COLUMN dependency failures before attempting a release migration.
Use legacy_alter_table only as a compatibility switch for old rename behavior, not as a normal modern setting.
Perform a safe create-copy-drop-rename table rebuild and verify dependent objects afterward.
What ALTER TABLE means in current SQLite
SQLite’s direct schema-change surface has expanded over time. Current SQLite 3.53.4 supports table rename, column rename, column addition, column drop, and—new in 3.53.0—setting or dropping a column’s NOT NULL constraint. Older tutorials that say SQLite can only rename tables or add columns are historical, not current.
| Operation | Current behavior | Important boundary |
|---|---|---|
ALTER TABLE t RENAME TO n | Renames the table; modern SQLite updates references in triggers/views and foreign keys. | Do not enable legacy rename behavior unless an old application deliberately depends on it. |
ALTER TABLE t RENAME COLUMN a TO b | Updates the table definition and dependent indexes/triggers/views when the rewrite is unambiguous. | Supported since 3.25.0; fails rather than silently creating semantic ambiguity. |
ALTER TABLE t ADD COLUMN ... | Appends a column to the schema. Simple additions normally do not rewrite every row. | No PRIMARY KEY/UNIQUE; restricted defaults; STORED generated columns cannot be added this way. |
ALTER TABLE t DROP COLUMN c | Rewrites table content to remove that column. | Supported since 3.35.0; fails when the column participates in keys, indexes, FKs, CHECKs, generated expressions, triggers, or views. |
ALTER TABLE t ALTER c SET/DROP NOT NULL | Current 3.53.0+ support for changing NOT NULL. | Do not use this syntax if your application minimum is older than 3.53.0; rebuild instead. |
A migration file must run on the oldest SQLite engine your release supports. “My development laptop has 3.53.4” is not enough reason to use 3.53-only syntax in a mobile, OS-bundled, or older embedded deployment.
Why SQLite schema changes feel different
SQLite stores the original CREATE TABLE, CREATE INDEX, CREATE VIEW, and CREATE TRIGGER SQL in sqlite_schema. A direct ALTER operation rewrites that SQL text and reparses the schema. This design keeps the file format compact and portable, but it means a schema change must leave every dependent definition valid.
SELECT type, name, tbl_name, sqlFROM sqlite_schemaWHERE tbl_name IN ('device', 'maintenance_note') OR name LIKE 'idx_%'ORDER BY type, name;PRAGMA table_xinfo('maintenance_note');PRAGMA index_list('maintenance_note');PRAGMA foreign_key_list('maintenance_note');This inspection is not ceremony. It tells you which objects the migration must preserve or intentionally replace. A table definition is only one part of the database contract.
Simple alteration: add a controlled column
Suppose FieldNotes wants to record where each maintenance note originated. Existing rows need a valid value, so this is a good direct ADD COLUMN case.
DROP TABLE IF EXISTS note_alter_demo;CREATE TABLE note_alter_demo( note_id INTEGER PRIMARY KEY, note_text TEXT NOT NULL);INSERT INTO note_alter_demo(note_text)VALUES ('Inspect coupling'), ('Check bearing temperature');ALTER TABLE note_alter_demoADD COLUMN source_system TEXT NOT NULL DEFAULT 'manual' CHECK (source_system IN ('manual','sensor','import'));SELECT note_id, note_text, source_systemFROM note_alter_demoORDER BY note_id;PRAGMA table_xinfo('note_alter_demo');Expected state: the two preexisting rows read as source_system='manual'. SQLite also validates an added CHECK constraint against existing rows in current releases. By contrast, ADD COLUMN x TEXT UNIQUE is not allowed, and a non-NULL added column requires a non-NULL default when old rows already exist.
DROP COLUMN is not “delete this name from CREATE TABLE”
Dropping a column can fail for reasons that are healthy: SQLite refuses to leave schema objects pointing at something that no longer exists. Treat those failures as dependency information, not as an invitation to disable checking.
DROP TABLE IF EXISTS drop_demo;CREATE TABLE drop_demo( id INTEGER PRIMARY KEY, code TEXT NOT NULL, note TEXT);CREATE INDEX idx_drop_demo_code ON drop_demo(code);-- Current SQLite rejects this while the index depends on code.ALTER TABLE drop_demo DROP COLUMN code;-- A real migration would decide whether the index itself should disappear,-- then remove/recreate dependent objects deliberately.SQLite documents direct sqlite_schema editing as a specialized, dangerous procedure. This course uses ordinary ALTER operations or the general rebuild procedure. A migration should not depend on hand-editing schema text to “make the error go away.”
Rename compatibility and legacy_alter_table
Modern rename behavior was improved in SQLite 3.25.0/3.26.0 so references inside triggers, views, and foreign keys are normally updated. PRAGMA legacy_alter_table=ON restores older rename behavior for applications that depended on it. That makes it a compatibility escape hatch—not a recommended default.
SELECT sqlite_version();PRAGMA legacy_alter_table;PRAGMA foreign_keys;-- Modern application policy:-- leave legacy_alter_table OFF unless supporting a known old behavior contract.A change ALTER cannot express: rebuild the table
Imagine a legacy notes table where severity was free text. The new release wants a strict value set, a normalized legacy spelling, a composite uniqueness rule, and an audit trigger. That is a table redesign, so rebuilding is clearer than trying to force one ALTER statement to do everything.
PRAGMA foreign_keys = OFF;BEGIN IMMEDIATE;CREATE TABLE new_note_rebuild_demo( note_id INTEGER PRIMARY KEY, device_id INTEGER NOT NULL, occurred_at TEXT NOT NULL, severity TEXT NOT NULL CHECK(severity IN ('info','warning','critical')), note_text TEXT NOT NULL CHECK(trim(note_text) <> ''), UNIQUE(device_id, occurred_at, note_text));INSERT INTO new_note_rebuild_demo (note_id, device_id, occurred_at, severity, note_text)SELECT note_id, device_id, occurred_at, CASE severity WHEN 'warn' THEN 'warning' ELSE severity END, trim(note_text)FROM note_rebuild_demo;DROP TABLE note_rebuild_demo;ALTER TABLE new_note_rebuild_demo RENAME TO note_rebuild_demo;CREATE INDEX idx_note_rebuild_device_timeON note_rebuild_demo(device_id, occurred_at);CREATE TRIGGER trg_note_rebuild_nonblankBEFORE INSERT ON note_rebuild_demoWHEN trim(NEW.note_text) = ''BEGIN SELECT RAISE(ABORT, 'blank note');END;PRAGMA foreign_key_check;COMMIT;PRAGMA foreign_keys = ON;The critical order is create new → copy/transform → drop old → rename new. SQLite explicitly warns against starting by renaming the old table, because modern rename propagation can modify views, triggers, and foreign-key references in ways that make the later steps wrong.
Verify the database contract, not only the row count
SELECT type, name, sqlFROM sqlite_schemaWHERE tbl_name='note_rebuild_demo' OR name LIKE 'idx_note_rebuild_%'ORDER BY type, name;PRAGMA table_xinfo('note_rebuild_demo');PRAGMA index_list('note_rebuild_demo');PRAGMA foreign_key_check;PRAGMA integrity_check;-- Constraint rejection is part of verification too.INSERT INTO note_rebuild_demo(device_id, occurred_at, severity, note_text)VALUES (1, '2026-08-12T10:00:00Z', 'impossible', 'should fail');A successful migration should prove structure, data, dependent objects, referential integrity, and expected failure behavior. “The ALTER command returned success” is only one observation.
Checkpoint
Choose direct ALTER or rebuild
For each requested change, decide whether a direct operation is sufficient for the declared runtime.
- Add an optional text column with no special default.
- Add a UNIQUE business key to an existing populated table.
- Rename a column on modern SQLite where dependent views are unambiguous.
- Drop a column used by an index and a view.
- Change a column type while translating legacy values.
- Set NOT NULL when your supported minimum is SQLite 3.45.0.
Review the answers
Direct ADD COLUMN fits the first. A new UNIQUE business key and a type/data transformation generally need a rebuild. Modern RENAME COLUMN fits the third. The indexed/view-referenced drop needs dependency redesign, commonly a rebuild. Because ALTER COLUMN SET NOT NULL is 3.53.0+, a 3.45.0 minimum must use a compatible rebuild instead.
Bridge to versioned migrations
Knowing how to change one schema safely is not enough. Applications need an ordered history: version 1 → 2 → 3, each paired with release code, with exactly one process owning migration execution. Lesson 2 turns these SQL operations into a migration protocol.