Database Migrations
The rules
These rules exist because migrations run on 80+ agency databases simultaneously. Breaking them can corrupt production data or take down multiple agencies at once.
- Only add in
upgrade()— neverALTER TABLE ... DROP COLUMNorALTER TABLE ... MODIFY COLUMN. OnlyCREATE TABLEandADD COLUMN. downgrade()must correctly reverseupgrade()— dropping a table that was created in the same migration is expected and correct.- Keep the chain linear — one head revision at all times. No branching.
- Always review generated migration files — Alembic sometimes detects ghost changes or generates incorrect SQL.
- Use a ticket number in the migration message — makes it traceable.
Commands
# Generate a new migration (after changing models)flask db migrate -m "JMS-1234 add my_new_table"
# Apply pending migrationsflask db upgrade
# Revert the most recent migrationflask db downgrade
# Check current revisionflask db current
# Verify single head (must output exactly one revision)flask db heads
# Show full migration historyflask db history
# Show migration chain summarypython scripts/show_migration_chain.pyThe full migration workflow
-
Make your model changes in
orchid/models/*.py -
Generate the migration
Terminal window flask db migrate -m "JMS-XXXX add thing to model"Alembic inspects your models and the current database state, then generates a migration file in
migrations/versions/. -
Review the generated file
Open
migrations/versions/[hash]_jms_xxxx_...pyand check:# ✅ Good upgrade():def upgrade():op.create_table('my_table', ...)op.add_column('existing_table', sa.Column('new_col', sa.String(255), nullable=True))# ❌ Bad upgrade() — DO NOT COMMIT:def upgrade():op.drop_column('table', 'col') # breaks old instancesop.alter_column('table', 'col', ...) # same problemAlso verify:
down_revisionpoints to the correct parent- No unexpected tables or columns are included
-
Apply locally and test
Terminal window flask db upgradeflask db current # should show your new revision -
Verify single head
Terminal window flask db heads# Should output exactly one revision hash -
Commit model + migration together
Terminal window git add orchid/models/my_model.py migrations/versions/abc123_jms_xxxx.pygit commit -m "JMS-XXXX: add my_new_table"
Migration file anatomy
"""JMS-1234 add delivery_date to case_closing_details
Revision ID: a1b2c3d4e5f6Revises: 9z8y7x6w5v4u ← parent revisionCreate Date: 2025-05-29 10:00:00.000000"""from alembic import opimport sqlalchemy as sa
# Required identifiersrevision = 'a1b2c3d4e5f6'down_revision = '9z8y7x6w5v4u'branch_labels = Nonedepends_on = None
def upgrade(): op.add_column( 'case_closing_details', sa.Column('delivery_date', sa.Date(), nullable=True) )
def downgrade(): op.drop_column('case_closing_details', 'delivery_date')MySQL-specific migrations
Sometimes you need MySQL-specific SQL that Alembic cannot generate automatically:
def upgrade(): # Changing charset requires raw SQL op.execute(""" ALTER TABLE email_message DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci """)
def downgrade(): op.execute(""" ALTER TABLE email_message DEFAULT CHARSET=utf8mb3 """)Fixing a broken migration chain
If you have two heads (usually from a merge conflict where two migrations were created independently):
flask db heads# Shows two revision IDs — need to mergeOption 1: Merge migration (preferred)
flask db merge heads -m "merge migration branches"This creates a new migration that merges the two heads into one. Review the generated merge migration — it usually has empty upgrade() and downgrade() functions.
Option 2: Manual fix
Edit the down_revision in the newer migration file to point to the older one, creating a linear chain.
After fixing:
flask db heads # must show exactly one headThe migration lock
In production, migrations run automatically on EB startup. The migration_lock table prevents concurrent runs when multiple EB instances start simultaneously:
- Instance A starts → tries to acquire lock → succeeds → runs migrations → releases lock
- Instance B starts (at the same time) → tries to acquire lock → waits → lock released → checks: already at latest revision → exits
The lock is stored in the migration_lock table in each agency’s database. If a migration fails mid-run and the lock is not released, you can manually clear it:
DELETE FROM migration_lock;Then investigate and fix the migration before the next startup.