Database Migrations Reference
Versioned schema changes, migration frameworks, and safe production migration patterns.
Migration Concepts
A migration is a versioned, reversible change to the database schema. Each migration has an "up" (apply) and "down" (revert) direction. Migrations run in order and track which have been applied.
Why migrations instead of editing the schema directly:
-
Reproducibility — apply the same changes to dev, staging, production
-
Version control — schema changes are committed alongside code changes
-
Rollback — revert a bad migration without manual SQL surgery
-
Team coordination — no conflicting ad-hoc ALTER TABLE statements
Manual Migration Pattern
When not using a migration framework, maintain numbered SQL files.
migrations/ ├── 001_create_devices.up.sql ├── 001_create_devices.down.sql ├── 002_add_vlan_column.up.sql ├── 002_add_vlan_column.down.sql ├── 003_create_events.up.sql └── 003_create_events.down.sql
ALTER TABLE devices ADD COLUMN vlan_id INTEGER DEFAULT 10;
CREATE INDEX idx_devices_vlan ON devices (vlan_id);
DROP INDEX IF EXISTS idx_devices_vlan;
ALTER TABLE devices DROP COLUMN vlan_id;
for f in migrations/*.up.sql; do
echo "Applying: $f"
psql -d myapp -f "$f"
done
Migration Tracking Table
Track which migrations have been applied so they run exactly once.
CREATE TABLE IF NOT EXISTS schema_migrations (
version INTEGER PRIMARY KEY,
name TEXT NOT NULL,
applied_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
SELECT MAX(version) AS current_version FROM schema_migrations;
INSERT INTO schema_migrations (version, name)
VALUES (3, 'create_events');
Alembic (Python / SQLAlchemy)
Alembic is the standard migration tool for SQLAlchemy and FastAPI projects.
pip install alembic
alembic init migrations
alembic revision --autogenerate -m "add vlan column to devices"
alembic upgrade head
alembic downgrade -1
alembic current
alembic history --verbose
def upgrade():
op.add_column('devices', sa.Column('vlan_id', sa.Integer(), default=10))
op.create_index('idx_devices_vlan', 'devices', ['vlan_id'])
def downgrade():
op.drop_index('idx_devices_vlan', 'devices')
op.drop_column('devices', 'vlan_id')
Safe Migration Practices
Adding Columns
-- Step 1: add column (nullable, no lock)
ALTER TABLE devices ADD COLUMN region TEXT;
-- Step 2: backfill in batches
UPDATE devices SET region = 'west' WHERE id BETWEEN 1 AND 1000;
UPDATE devices SET region = 'west' WHERE id BETWEEN 1001 AND 2000;
-- Step 3: add NOT NULL constraint (after backfill)
ALTER TABLE devices ALTER COLUMN region SET NOT NULL;
Adding a column with a DEFAULT value in PostgreSQL 11+ is instant (does not rewrite the table). In older versions or MySQL, it rewrites the entire table and locks it.
Renaming Columns
-- Step 1: add new column
ALTER TABLE devices ADD COLUMN mgmt_ip TEXT;
-- Step 2: copy data
UPDATE devices SET mgmt_ip = ip_address;
-- Step 3: deploy code that reads/writes both columns
-- Step 4: drop old column (after all code uses the new one)
ALTER TABLE devices DROP COLUMN ip_address;
Never rename a column in one step on a production database with running applications. The old column name will cause errors until the code is updated.
Dropping Columns
-- Step 1: deploy code that no longer reads/writes the column
-- Step 2: drop the column
ALTER TABLE devices DROP COLUMN legacy_field;
Adding Indexes
CREATE INDEX CONCURRENTLY idx_devices_region ON devices (region);
CONCURRENTLY allows reads and writes to continue while the index is built. Without it, the table is locked for the entire build duration.
SQLite Migration Limitations
SQLite has limited ALTER TABLE support. You cannot:
-
Drop a column (before 3.35)
-
Rename a column (before 3.25)
-
Add a constraint to an existing column
-- 1. Create new table with desired schema
CREATE TABLE devices_new (
id INTEGER PRIMARY KEY,
hostname TEXT NOT NULL,
mgmt_ip TEXT -- renamed from ip_address
);
-- 2. Copy data
INSERT INTO devices_new (id, hostname, mgmt_ip)
SELECT id, hostname, ip_address FROM devices;
-- 3. Replace
DROP TABLE devices;
ALTER TABLE devices_new RENAME TO devices;
This is destructive — wrap it in a transaction and test on a copy first.
Version Compatibility
| Feature | PostgreSQL | MySQL | SQLite |
|---|---|---|---|
ADD COLUMN (instant) |
11+ |
8.0 (INSTANT) |
Always instant |
DROP COLUMN |
All |
All |
3.35+ |
RENAME COLUMN |
All |
8.0+ |
3.25+ |
Online index build |
CONCURRENTLY |
ALGORITHM=INPLACE |
N/A |
Transactional DDL |
Yes |
No (implicit COMMIT) |
Yes |
PostgreSQL and SQLite support transactional DDL — if a migration fails partway through, the schema change is rolled back. MySQL auto-commits DDL statements, so a failed migration can leave the schema in a broken state.