Database Migrations
Teamarr uses a checkpoint + incremental migration system to handle database schema changes safely across versions.
Architecture
Fresh Install Existing Database (v2-v42) Existing Database (v43+)
│ │ │
▼ ▼ ▼
schema.sql checkpoint_v43.py Skip checkpoint
(creates v43) (idempotent → v43) │
│ │ │
└──────────────────────────┴──────────────────────────────┘
│
▼
v44, v45, ... incremental
migrations (connection.py)
Key Principles
- Idempotent: Migrations can be run multiple times safely
- Defensive: Check column/table existence before operations
- Checkpoint-based: Old migrations consolidated, new ones are incremental
Key Files
| File | Purpose |
|---|---|
teamarr/database/schema.sql | Authoritative schema for fresh installs |
teamarr/database/checkpoint_v43.py | Consolidates v2-v43 into single operation |
teamarr/database/connection.py | _run_migrations() orchestrates everything |
How It Works
Fresh Install
schema.sqlcreates database directly at current version (v43+)- No migrations run
Existing Database (v2-v42)
apply_checkpoint_v43()runs- Checkpoint is idempotent - ensures v43 state regardless of starting point
- Handles partial migrations gracefully
- Any v44+ migrations run afterward
Existing Database (v43+)
- Checkpoint is skipped (version check)
- Only v44+ migrations run if needed
Adding a New Migration
1. Update schema.sql
Add new columns/tables and update the default schema version:
CREATE TABLE settings (
...
my_new_setting TEXT DEFAULT 'value',
schema_version INTEGER DEFAULT 44 -- Bump this
);
2. Add Migration to connection.py
Add after the checkpoint call in _run_migrations():
# v44: My Feature
if current_version < 44:
_add_column_if_not_exists(
conn, "settings", "my_new_setting", "TEXT DEFAULT 'value'"
)
conn.execute("UPDATE settings SET schema_version = 44 WHERE id = 1")
logger.info("[MIGRATE] Schema upgraded to version 44")
current_version = 44
3. Write Tests
def test_v44_migration(temp_db):
# Setup v43 database
# Run _run_migrations
# Assert new column exists
Best Practices
Use Idempotent Operations
# Safe to run multiple times
_add_column_if_not_exists(conn, "table", "col", "TYPE DEFAULT val")
# Safe INSERT
conn.execute("INSERT OR IGNORE INTO sports (code, name) VALUES ('x', 'X')")
# Safe UPDATE
conn.execute("UPDATE t SET col = 'new' WHERE col IS NULL")
Check Before Operating
if _table_exists(conn, "my_table"):
columns = _get_table_columns(conn, "my_table")
if "target_col" in columns:
conn.execute("UPDATE my_table SET target_col = ...")
Avoid Non-Constant Defaults
# BAD: SQLite can't add CURRENT_TIMESTAMP default
_add_column_if_not_exists(conn, "t", "created", "TIMESTAMP DEFAULT CURRENT_TIMESTAMP")
# GOOD: Add with NULL, populate separately
_add_column_if_not_exists(conn, "t", "created", "TIMESTAMP")
conn.execute("UPDATE t SET created = CURRENT_TIMESTAMP WHERE created IS NULL")
Available Helper Functions
| Function | Purpose |
|---|---|
_add_column_if_not_exists(conn, table, col, def) | Add column if missing |
_table_exists(conn, table) | Check if table exists |
_get_table_columns(conn, table) | Get column names as set |
_index_exists(conn, name) | Check if index exists |
When to Create a New Checkpoint
Consider a new checkpoint when:
- 15-20+ migrations accumulated since last checkpoint
- Major schema restructure planned
- Migration code becoming unwieldy
To create:
- Copy
checkpoint_v43.pytocheckpoint_vXX.py - Update all schema definitions to match current
schema.sql - Update
connection.pyto use new checkpoint - Old checkpoint can be removed (or kept for users on very old versions)
Pre-Migrations
Some schema changes need to happen before the checkpoint runs (e.g., renaming columns that the checkpoint references). These are handled by dedicated functions called before apply_checkpoint_v43():
| Function | Purpose |
|---|---|
_rename_league_id_column_if_needed | Renames legacy league_id column |
_add_league_alias_column_if_needed | Adds league_alias column |
_add_gracenote_category_column_if_needed | Adds gracenote_category column |
_add_logo_url_dark_column_if_needed | Adds logo_url_dark column |
_add_series_slug_pattern_column_if_needed | Adds series_slug_pattern column |
_add_fallback_columns_if_needed | Adds fallback_provider and fallback_league_id |
_add_tsdb_tier_column_if_needed | Adds tsdb_tier for TSDB free/premium classification |
_migrate_exception_keywords_columns | Restructures exception keyword storage |
_migrate_settings_for_v65 | Channel lifecycle overhaul (v62) |
Pre-migrations are idempotent and only modify the schema if the target column/table doesn’t already exist.
Version History
Current schema version: 67 (25 incremental migrations since checkpoint)
| Version | Type | Description |
|---|---|---|
| 2 | Base | Initial V2 schema |
| 3-42 | Consolidated | Merged into checkpoint_v43 |
| 43 | Checkpoint | Checkpoint baseline |
| 44-67 | Incremental | Individual migrations in connection.py |
Troubleshooting
“no such column” during migration
Add column existence check before UPDATE operations.
Migration runs but nothing changes
Verify schema_version is being updated in the migration.
Fresh install has wrong version
Update schema_version default in schema.sql.
User reports partial state
The checkpoint handles this - it fills in missing pieces idempotently.