Database migrations, version control, creating and applying migrations in TrailBase
Migrations allow you to evolve your database schema over time while maintaining data integrity and enabling reproducible deployments across environments.
In practice, both U and V prefixes work the same way in TrailBase. The distinction is primarily semantic to indicate whether the migration is conceptually reversible.
CREATE TABLE articles ( id BLOB PRIMARY KEY NOT NULL CHECK(is_uuid_v7(id)) DEFAULT (uuid_v7()), author BLOB NOT NULL REFERENCES _user(id) ON DELETE CASCADE, title TEXT NOT NULL, intro TEXT NOT NULL, tag TEXT NOT NULL, body TEXT NOT NULL, image TEXT CHECK(jsonschema('std.FileUpload', image, 'image/png, image/jpeg')), created INTEGER DEFAULT (UNIXEPOCH()) NOT NULL) STRICT;-- Create a view joining articles with profilesCREATE VIEW articles_view AS SELECT a.*, p.username FROM articles AS a LEFT JOIN profiles AS p ON p.user = a.author;
Migrations can contain multiple statements. Each statement should end with a semicolon.
CREATE TABLE todos ( "id" INTEGER PRIMARY KEY NOT NULL, "text" TEXT NOT NULL, "completed" INTEGER NOT NULL DEFAULT 0, "created_at" INTEGER NOT NULL DEFAULT(UNIXEPOCH()), "updated_at" INTEGER NOT NULL DEFAULT(UNIXEPOCH())) STRICT;CREATE TRIGGER _todos__update_trigger AFTER UPDATE ON todos FOR EACH ROW BEGIN UPDATE todos SET updated_at = UNIXEPOCH() WHERE id = OLD.id; END;
-- Add new column with default valueALTER TABLE todos ADD COLUMN priority INTEGER NOT NULL DEFAULT 1;-- Add optional columnALTER TABLE todos ADD COLUMN due_date INTEGER;-- Create index on new columnCREATE INDEX _todos__priority_index ON todos(priority);
SQLite’s ALTER TABLE is limited. You cannot:
Drop columns (in older SQLite versions)
Modify column types
Add columns with NOT NULL without a DEFAULT
For complex changes, use the table recreation pattern below.
-- Start transaction (migrations are already transactional, but explicit for clarity)BEGIN;-- Create new table with desired structureCREATE TABLE users_new ( id INTEGER PRIMARY KEY NOT NULL, email TEXT NOT NULL UNIQUE, username TEXT NOT NULL, -- New column created INTEGER DEFAULT (UNIXEPOCH()) NOT NULL) STRICT;-- Copy data from old tableINSERT INTO users_new (id, email, created) SELECT id, email, created FROM users;-- Drop old tableDROP TABLE users;-- Rename new tableALTER TABLE users_new RENAME TO users;-- Recreate indexesCREATE UNIQUE INDEX _users__email_index ON users(email);CREATE INDEX _users__username_index ON users(username);COMMIT;
-- Single column indexesCREATE INDEX _articles__author_index ON articles(author);CREATE INDEX _articles__created_index ON articles(created);-- Composite indexCREATE INDEX _articles__author_created_index ON articles(author, created);-- Unique indexCREATE UNIQUE INDEX _profiles__username_index ON profiles(username);
-- Create admin user with hashed passwordINSERT INTO _user (email, password_hash, verified, admin)VALUES ('admin@localhost', (hash_password('secret')), TRUE, TRUE);-- Set a username for the adminINSERT INTO profiles (user, username) SELECT user.id, 'Admin' FROM _user AS user WHERE email = 'admin@localhost';
-- Create editors group tableCREATE TABLE editors ( user BLOB NOT NULL, FOREIGN KEY(user) REFERENCES _user(id) ON DELETE CASCADE) STRICT;-- Add initial editorINSERT INTO editors (user) SELECT id FROM _user WHERE email = 'admin@localhost';-- Create helper virtual table for checking membershipCREATE VIRTUAL TABLE _is_editor USING define(( SELECT EXISTS (SELECT * FROM editors WHERE user = $1) AS is_editor));
Applied migrations are recorded in the _schema_history table:
SELECT * FROM _schema_history ORDER BY version DESC LIMIT 5;
version
name
applied_on
checksum
1725019362
create_articles
2024-08-30 15:26:02
2845001429
1725019361
create_profiles
2024-08-30 15:26:02
3891823471
Never modify applied migrations. If a migration’s checksum changes, TrailBase will detect the divergence. Instead, create a new migration to make changes.
For critical migrations, test on a copy of production data:
# Backup production databasecp production/data/main.db test/data/main.db# Test migrationtrail run --data-dir test# Verify resultssqlite3 test/data/main.db
Always take backups before running migrations in production. TrailBase creates automatic backups in traildepot/backups/, but manual backups provide extra safety.
Test migrations against production-sized datasets to catch performance issues:
# Copy production datacp prod/main.db test/main.db# Run migrationtrail run --data-dir test# Check performancesqlite3 test/main.db "EXPLAIN QUERY PLAN SELECT * FROM articles WHERE author = ?;"
3
Document Complex Migrations
Add comments explaining non-obvious changes:
-- Normalize article tags into separate table for better query performance-- and to support multiple tags per article.CREATE TABLE article_tags ( article_id BLOB NOT NULL REFERENCES articles(id), tag TEXT NOT NULL, PRIMARY KEY (article_id, tag)) STRICT;
4
Handle Data Carefully
When migrating existing data, preserve user information:
-- Bad: Loses existing dataALTER TABLE posts ADD COLUMN status TEXT NOT NULL DEFAULT 'draft';-- Good: Migrates existing data appropriatelyALTER TABLE posts ADD COLUMN status TEXT NOT NULL DEFAULT 'draft';UPDATE posts SET status = 'published' WHERE published_at IS NOT NULL;
5
Create Indexes Concurrently
For large tables, consider adding indexes in a separate migration after the table is created:
migrations/main/U1725019362__create_logs.sql
CREATE TABLE logs ( id INTEGER PRIMARY KEY, timestamp INTEGER NOT NULL, message TEXT NOT NULL) STRICT;
migrations/main/U1725019363__index_logs.sql
-- Create index after initial data loadCREATE INDEX _logs__timestamp_index ON logs(timestamp);