Chapter 17 · Schema Evolution, Migrations, Testing, and Release Compatibility
Versioned Migrations with PRAGMA user_version
Turn schema changes into ordered, source-controlled release steps using application-owned version metadata, explicit transaction ownership, serialization, and duplicate-execution policy.
Learning outcomes
Once a database ships to users, there is no longer one schema—there is a sequence of schemas in the wild. A migration system answers three questions every time the application opens a database: what version is this file, what transformations are required, and who is allowed to apply them?
Use PRAGMA user_version as an application-owned schema generation number.
Distinguish user_version from SQLite-managed schema_version.
Organize migrations as ordered source-controlled transformations paired with releases.
Serialize migration ownership with an explicit write transaction.
Define a duplicate-execution policy instead of assuming every migration is rerunnable.
Build and test a compact Python migration runner with rollback on failure.
user_version belongs to the application
PRAGMA user_version stores a signed 32-bit integer in the database header. SQLite does not interpret it. That makes it useful as a compact application schema version such as 0, 1, 2. By contrast, schema_version is SQLite’s internal schema-change counter and is checked against prepared statements. Applications may read it for diagnostics, but should not use it as their migration number or write it casually.
PRAGMA user_version; -- application-owned; SQLite does not use itPRAGMA schema_version; -- SQLite-managed schema-change counter-- Application migration may deliberately advance user_version:PRAGMA user_version = 2;-- Do NOT copy recipes that manually set schema_version as app metadata.| Metadata | Owner | Good use |
|---|---|---|
user_version | Your application | Compact current migration generation. |
| Migration history table | Your application | Rich history: ID, checksum, timestamp, release, duration. |
schema_version | SQLite engine | Prepared-statement/schema invalidation. Read for diagnostics; do not repurpose. |
application_id | Your application/file format | Identify that the SQLite file belongs to FieldNotes before migrating it. |
Migration files are release artifacts
A practical repository keeps each forward transformation immutable after release. The file name says order; the application release says when it is required.
migrations/ 001_create_fieldnotes.sql 002_add_device_location.sql 003_normalize_note_severity.sql 004_add_sync_request_log.sqlrelease 2.3.0 requires database user_version >= 4release 2.2.x understands versions 3..4release 2.1.x must never open a version 4 database for writesA migration already applied to real databases should not be edited in place. Fix it with a new migration. Otherwise two files can both report user_version=3 while having different schemas.
Forward migration protocol
The runner should refuse ambiguous states. The simplest robust duplicate policy is expected-predecessor execution: migration 3 runs only if the database reports version 2; if the database is already version 3 or newer, migration 3 is skipped because the runner knows it was already applied. If the database reports an unexpected intermediate state, fail closed.
-- Connection initialization happens first.PRAGMA foreign_keys = ON;PRAGMA busy_timeout = 5000;BEGIN IMMEDIATE; -- claim write/migration ownership early-- Verify current user_version in the host-language runner.-- Apply the exact version N -> N+1 statements.ALTER TABLE device ADD COLUMN location TEXT;PRAGMA user_version = 2;COMMIT;-- Then run structural/business verification outside or after commit.It serializes writers to this SQLite database file. If several application processes start simultaneously, one can hold the migration write transaction while others wait/fail according to busy policy. Deployment orchestration should still ensure older application binaries do not keep writing while a new schema is being introduced.
SQL-only migration versus runner logic
SQL can contain the transformation, but the host language is better at reading version state, choosing which migration file applies, recording logs, checking expected versions, and surfacing errors. Do not try to turn a SQL script into a home-grown conditional programming language.
CREATE TABLE IF NOT EXISTS migration_device( device_id INTEGER PRIMARY KEY, device_code TEXT NOT NULL UNIQUE, status TEXT NOT NULL DEFAULT 'active' CHECK(status IN ('active','inspection_due','retired')));ALTER TABLE migration_deviceADD COLUMN location TEXT;CREATE INDEX idx_migration_device_statusON migration_device(status);A small Python migration runner
This runner uses a real file, turns Python’s implicit transaction behavior off, initializes connection PRAGMAs, and explicitly owns each BEGIN IMMEDIATE/COMMIT. It is intentionally small enough to audit.
from pathlib import Pathimport sqlite3MIGRATIONS = { 1: [ """CREATE TABLE migration_device( device_id INTEGER PRIMARY KEY, device_code TEXT NOT NULL UNIQUE, status TEXT NOT NULL DEFAULT 'active' CHECK(status IN ('active','inspection_due','retired')) )""", ], 2: [ "ALTER TABLE migration_device ADD COLUMN location TEXT", "CREATE INDEX idx_migration_device_status ON migration_device(status)", ],}def user_version(con): return con.execute("PRAGMA user_version").fetchone()[0]def migrate(path: Path, target: int) -> None: con = sqlite3.connect(path, isolation_level=None, timeout=5.0) try: con.execute("PRAGMA foreign_keys=ON") con.execute("PRAGMA busy_timeout=5000") current = user_version(con) if current > target: raise RuntimeError(f"database {current} is newer than app target {target}") for next_version in range(current + 1, target + 1): statements = MIGRATIONS[next_version] con.execute("BEGIN IMMEDIATE") try: # Recheck after owning the write transaction. if user_version(con) != next_version - 1: raise RuntimeError("migration predecessor changed") for sql in statements: con.execute(sql) con.execute(f"PRAGMA user_version={next_version}") con.commit() except Exception: con.rollback() raise finally: con.close()The recheck after BEGIN IMMEDIATE matters when multiple processes race. A runner that reads version 1, waits, then blindly applies “1→2” after another process already migrated can generate duplicate-object failures or worse.
Failure recovery is transaction recovery
If one statement in a migration fails, the runner explicitly rolls back the migration transaction. The database should remain at the predecessor version, not a half-upgraded version with a falsely advanced user_version.
start: user_version = 1BEGIN IMMEDIATE ALTER TABLE ... -- succeeds CREATE UNIQUE INDEX ... -- fails because legacy duplicates exist PRAGMA user_version = 2 -- never reachedROLLBACKend: user_version = 1schema: predecessor schema restored by transaction rollbackFor very large migrations that cannot fit one practical transaction, design an explicit multi-phase protocol with resumable state. Do not silently abandon atomicity halfway through because the script became slow.
user_version versus a migration history table
| Approach | Strength | Tradeoff |
|---|---|---|
Only user_version | Tiny, fast, built into every SQLite file. | Only one integer; no checksums/history. |
| Only history table | Rich metadata and audit trail. | Bootstrap logic is more complex and the table itself is part of the schema. |
| Both | Fast current-generation check plus detailed immutable history. | Runner must keep them consistent in the same transaction. |
A production system often uses both. If you record a migration table, store a stable migration ID and checksum; refuse to proceed if a previously applied migration ID has different content than the packaged release.
Checkpoint
Migration runner decisions
Assume target version 4.
- Database reports user_version 2. Which migrations run?
- Database reports user_version 5. Should the version-4 application “downgrade” automatically?
- Why re-read user_version after BEGIN IMMEDIATE?
- If migration 3 fails, what value should user_version retain?
- Why is schema_version not a substitute for migration metadata?
- When is an idempotent SQL statement still not enough for migration safety?
Review the answers
Run 3 then 4 in order. A version-4 application should normally refuse a newer file unless an explicit backward-compatibility contract exists. Rechecking after claiming the write transaction closes a startup race. A failed migration must retain version 2. schema_version belongs to SQLite and changes for engine reasons such as schema edits/VACUUM. Idempotent syntax does not prove that data transformations, dependent objects, or semantic version transitions are correct.
Bridge to rebuilds with foreign keys
The runner gives migrations ordering and ownership. Lesson 3 applies that discipline to the hardest common SQLite change: rebuilding a referenced table while preserving foreign-key correctness and transforming real data.