Schemas Change. Plan for It.
The first version of your schema is never the last. Columns get added, tables get split, indexes get rethought. The question isn't whether your schema will change — it's whether the change will land cleanly on every laptop, server, and user device that already has an older copy of the database.
That's what migrations are for: a sequence of small, ordered scripts that take a database from version N to version N+1. Run them in order, and any database catches up to current. Skip the discipline, and you end up with "works on my machine" bugs that take all afternoon to track down.
SQLite gives you exactly one built-in tool for this: PRAGMA user_version. It's a 32-bit integer the database stores for you, untouched by SQLite itself. You decide what it means.
A fresh database starts at 0. Set it to whatever migration number you've just applied. Read it on startup to know where you are.
A Minimal Migration Loop
The mental model: each migration is a numbered SQL script. Your app reads the current user_version, runs every script with a higher number in order, and updates user_version after each one.
Here's migration 1 — creating the initial schema:
Two things to notice. The whole thing is wrapped in BEGIN; ... COMMIT; so it's atomic — if the CREATE TABLE fails, user_version doesn't get bumped and you can fix and retry. And PRAGMA user_version = 1 is the last statement before the commit, so the version flips only if everything else succeeded.
Now suppose you need to add a created_at column. That's migration 2:
A database at version 0 runs both. A database at version 1 runs only the second. A database at version 2 runs nothing. The order is the contract.
What ALTER TABLE Can and Can't Do
SQLite's ALTER TABLE is deliberately narrow. It supports:
ADD COLUMN— append a new column with an optional default.DROP COLUMN— remove a column (since 3.35).RENAME COLUMN— rename a column (since 3.25).RENAME TO— rename the table itself.
That's it. You can't change a column's type, change NOT NULL, alter a CHECK constraint, or add a FOREIGN KEY to an existing column.
-- Not supported:
ALTER TABLE users ALTER COLUMN email TYPE VARCHAR(255);
ALTER TABLE users ADD CONSTRAINT users_email_check CHECK (email LIKE '%@%');
When you need a change SQLite can't do directly, the official recipe is "rebuild the table." It's more verbose but completely reliable.
Rebuilding a Table for Bigger Changes
The pattern is: create a new table with the shape you want, copy the data over, drop the old one, rename the new one into place. All inside a transaction.
The full SQLite docs call this the 12-step recipe and add a few extra cautions for triggers, views, and foreign-key references — worth reading once before doing it on a production schema. For most cases the four-step version above is enough.
A heads-up: if you have foreign keys pointing at the table you're rebuilding, run PRAGMA foreign_keys = OFF before the migration and PRAGMA foreign_keys = ON after. Otherwise the DROP TABLE can break referential integrity mid-flight.
Driving Migrations from Your Application
The bookkeeping is simple enough that you can write it yourself. In Python with the standard library:
The key invariants:
- Migrations are numbered consecutively starting at 1. No gaps, no reordering.
- Each migration is wrapped in a transaction along with the
PRAGMA user_version = Nbump. - Once a migration is committed and shipped, you never edit it. New changes go in a new migration.
That last rule is the one teams break most often. If you edit migration 3 after a colleague's database has already applied it, their database is silently out of sync with yours forever.
Recording an Audit Trail
user_version tells you where a database is. It doesn't tell you when each step ran or what it did. A small bookkeeping table fixes that:
Now you have a row per migration with a name and timestamp — handy when debugging "why does this database have a column the code doesn't expect?"
PRAGMA user_version is still the source of truth for the loop; the table is for humans.
Rollback: What Transactions Give You, and What They Don't
SQLite's DDL is transactional. If migration 5 starts creating a table, copying data, and bumping user_version, and the copy fails halfway through, ROLLBACK undoes everything — including the CREATE TABLE. The database is exactly as it was before BEGIN.
That covers failed migrations. It doesn't cover migrations that committed successfully and that you now regret. For those, you write a separate down-migration — a script that undoes the change. SQLite has no automatic reverse. If migration 7 added a column, the down version drops it. If migration 7 dropped a column, the down version can't recover the data; the best it can do is recreate the column empty.
In practice, many small projects skip down-migrations entirely and rely on backups for "undo." That's a valid choice as long as you take the backups.
A Few Habits That Save Pain Later
- One migration per logical change. A migration that adds three unrelated columns is harder to review and harder to revert than three migrations.
- Test migrations against a copy of production. Schema changes can be slow on big tables; finding out in production is no fun.
- Never edit a shipped migration. Add a new one.
- Back up first. A quick
.backupin the CLI or a file copy when the database is closed is cheap insurance before any non-trivial migration. - Watch out for
PRAGMA foreign_keys. Turn it off during table rebuilds, back on after.
For larger projects, reach for a dedicated tool — Alembic with SQLAlchemy, golang-migrate, Knex, Flyway. They handle ordering, concurrent runners, and team conventions you'd otherwise reinvent. The principles are the same as the loop above; the tool just removes the boilerplate.
Next: WAL Mode and Concurrency
Migrations usually run while the application is offline or holding an exclusive lock. The rest of the time, your database is serving reads and writes from multiple connections at once — and SQLite's default journal mode isn't always the best fit. The next page covers WAL mode, what it changes, and when to switch.
Frequently Asked Questions
How do I version an SQLite schema?
SQLite has a built-in 32-bit integer slot per database called user_version, accessed via PRAGMA user_version. Read it on startup, compare to the latest migration number your code knows about, and run the missing migrations in order. No extra table required, though many apps add one for an audit trail.
Can I rollback an SQLite migration?
Wrap each migration in BEGIN; ... COMMIT;. If anything inside fails, ROLLBACK undoes the whole step — schema changes and data changes alike, since SQLite's DDL is transactional. For rolling back a migration that already committed, you need a separate down-script you wrote yourself; SQLite won't generate one for you.
Why is ALTER TABLE limited in SQLite?
SQLite supports ALTER TABLE ADD COLUMN, RENAME TABLE, RENAME COLUMN, and DROP COLUMN, but not arbitrary changes like altering a column's type or constraints. The workaround is the 12-step recipe: create a new table with the desired shape, INSERT INTO new_table SELECT ... FROM old_table, drop the old, and rename the new.
Should I use a migration tool or write my own?
For small apps, a hand-rolled loop over numbered .sql files driven by PRAGMA user_version is maybe 30 lines of code and works fine. For larger projects, tools like Alembic (Python), golang-migrate (Go), or Knex (Node) handle ordering, locking, and team workflows you'd otherwise reinvent.