Migrations and schema versioning for embedded apps
user_version as a migration counter, an idempotent runner, safe table rewrites, the limits of ALTER TABLE, and data migrations.
Versioning the schema
pragma user_version; -- an integer you own, stored in the file header
pragma user_version = 7; -- set after migrations have been applied
-- the runner, in outline
-- 1. read pragma user_version
-- 2. for each migration with index > version, in order:
-- begin immediate
-- apply the statements
-- pragma user_version = index
-- commit
-- 3. if anything throws, roll back and leave the version untouchedimport sqlite3
MIGRATIONS = [
# index 1
"""
create table book (
id integer primary key, title text not null, pages integer
) strict;
create index book_title on book (title);
""",
# index 2
"alter table book add column isbn text;",
# index 3 - a table rewrite, because SQLite cannot drop a column in place
"""
create table book_new (
id integer primary key, title text not null, isbn text, pages integer
) strict;
insert into book_new (id, title, isbn, pages) select id, title, isbn, pages from book;
drop table book;
alter table book_new rename to book;
create index book_title on book (title);
""",
]
def migrate(db_path):
con = sqlite3.connect(db_path, isolation_level=None)
con.execute("pragma foreign_keys = on")
version = con.execute("pragma user_version").fetchone()[0]
for i, statements in enumerate(MIGRATIONS, start=1):
if i <= version:
continue
con.execute("begin immediate")
try:
con.executescript(statements)
con.execute("pragma user_version = " + str(i))
con.execute("commit")
except Exception:
con.execute("rollback")
raise
con.close()pragma user_version = ?does not accept a bound parameter in all builds - build the statement from an integer you control, never from user input.- One transaction per migration keeps the file consistent: either the schema and the version both move, or neither does.
foreign_keysmust be off during a table rewrite, because dropping the old table would cascade or fail. Re-enable it and runpragma foreign_key_checkafterwards.- Application and schema version must be checked together: an old application opening a newer file should refuse to run rather than corrupt it.
⚠️
Migrations run on the user's device, where you cannot fix a mistake with a hotfix. Make every migration idempotent and test the upgrade path from every shipped version, not just from the previous one. A user who skipped four releases must still be able to update.
What ALTER TABLE can and cannot do
| Operation | Supported | Workaround |
|---|---|---|
| Add a column | Yes | alter table t add column c text |
| Rename a table | Yes | alter table t rename to t2 |
| Rename a column | Yes since 3.25 | alter table t rename column a to b |
| Drop a column | Yes since 3.35 | Older versions need a table rewrite |
| Change a type or constraint | No | Create a new table, copy, drop, rename |
| Reorder columns | No | A table rewrite, often not worth it |
| Add a NOT NULL column | Only with a default | Add nullable, backfill, then rewrite |
-- the canonical safe rewrite
pragma foreign_keys = off;
begin immediate;
create table book_new (
id integer primary key,
title text not null,
isbn text unique,
pages integer not null default 0 check (pages >= 0)
) strict;
insert into book_new (id, title, isbn, pages)
select id, title, isbn, coalesce(pages, 0) from book;
drop table book;
alter table book_new rename to book;
create index book_title on book (title);
pragma foreign_key_check;
commit;
pragma foreign_keys = on;
pragma user_version = 4;A rewrite copies the whole table, so on a large database it needs free disk space and takes time. Do it during a maintenance window, or copy in batches into the new table and swap at the end if the file is large.
Data migrations
- Separate schema changes from data changes: the first is fast and atomic, the second can be long and should be resumable.
- Batch a large backfill by rowid and commit every few thousand rows, so progress survives a crash:
where id > ? order by id limit 1000. VACUUMafter a big delete or rewrite to return space to the filesystem and rebuild the file compactly.- Never rely on the application to fix data on read. A migration that runs on every device, once, is far more predictable than code that repairs lazily.
- Keep a migration log table if you need to report which devices have upgraded, but keep
user_versionas the source of truth for the schema.
-- a resumable backfill driven by the application
update book
set slug = lower(replace(title, ' ', '-'))
where id in (select id from book where slug is null order by id limit 1000);
-- after the last batch
create index book_slug on book (slug);
vacuum;FAQ
user_version or a migrations table?
user_version for the schema version, because it is a single integer in the header and cannot drift. A migrations table is useful for logging, but making it the source of truth means a truncated or corrupted row can leave the schema state unknown.
How do I test migrations?
Create an empty database for the oldest supported version, apply every migration in order, and run your query test suite against it. Also test the direct upgrade path from each shipped version to the current one.
Related
Transactions, locking and concurrency Testing, tooling and benchmarks for SQLite apps
Last refreshed 2026-09-18.