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.

Beginner115–140 minutesuser_version + Python migration runnerSQLite 3.53.4 baselineExplicit BEGIN IMMEDIATE · duplicate policyLast reviewed: August 2026

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?

01

Use PRAGMA user_version as an application-owned schema generation number.

02

Distinguish user_version from SQLite-managed schema_version.

03

Organize migrations as ordered source-controlled transformations paired with releases.

04

Serialize migration ownership with an explicit write transaction.

05

Define a duplicate-execution policy instead of assuming every migration is rerunnable.

06

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.

sql · two different version concepts
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.
MetadataOwnerGood use
user_versionYour applicationCompact current migration generation.
Migration history tableYour applicationRich history: ID, checksum, timestamp, release, duration.
schema_versionSQLite enginePrepared-statement/schema invalidation. Read for diagnostics; do not repurpose.
application_idYour application/file formatIdentify 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.

text · example migration directory
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 writes

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

sql · transaction boundary for one migration
-- 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.
BEGIN IMMEDIATE is not a cluster coordinator

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.

sql · migration 001: SQL body
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')));
sql · migration 002: SQL body
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.

python · version-gated runner
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.

text · failure-injection expectation
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 rollback

For 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

ApproachStrengthTradeoff
Only user_versionTiny, fast, built into every SQLite file.Only one integer; no checksums/history.
Only history tableRich metadata and audit trail.Bootstrap logic is more complex and the table itself is part of the schema.
BothFast 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.

  1. Database reports user_version 2. Which migrations run?
  2. Database reports user_version 5. Should the version-4 application “downgrade” automatically?
  3. Why re-read user_version after BEGIN IMMEDIATE?
  4. If migration 3 fails, what value should user_version retain?
  5. Why is schema_version not a substitute for migration metadata?
  6. 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.

Authoritative references

Keep knowledge open

Help the academy stay free and grow.

If these tutorials save you time, a small donation supports new lessons, technical review, diagrams, examples, and long-term maintenance.

ETHEthereum / ERC-20 only
0x716c4Ab160C4B66F31a28AE2448BfF68fc3a2ef0

Send only assets compatible with the Ethereum/ERC-20 network. Do not send TRC-20/TRON assets.