Lesson 027: Migration Ordering and Apply-on-Use Gaps
Lesson 027: Migration Ordering and Apply-on-Use Gaps
The Lesson
When a database migration creates a table that new code writes to, the migration must be applied before the code runs — not just before the next CLI invocation. If the code path that triggers the write doesn't call apply_migrations(), the table won't exist at runtime, even though the migration file is on disk and other commands apply it correctly.
Context
A DuckDB-based data warehouse used numbered SQL migration files (001–007) applied by an apply_migrations() function that reads the _migrations table, finds unapplied files, and executes them in order. Each CLI command called apply_migrations(conn) at startup. Migration 006 created two scoring mart tables. Migration 007 created two calendar optimization tables. Both migrations existed on disk but had to be applied via code.
What Happened
- Added migration 006 (
006_create_scoring_tables.sql) to createmart_image_preference_scoreandmart_inter_rater_reliability. The CLI'scompute-scorescommand calledapply_migrations(conn)at the top, so the tables would be created on first run. - Ran
compute-scores— it completed silently with no output. Queried the table —Table with name mart_image_preference_score does not exist. The migration hadn't been applied. - Investigated: the
_migrationstable showed versions 1–5 applied, but not 6. Themigratecommand also showed "no pending migrations" — which was wrong. - Root cause: the
migrateCLI command worked correctly, butcompute-scoreshad a separate connection path that didn't properly trigger migration 006. Runningmigrateexplicitly, thencompute-scores, worked. - The same pattern recurred with migration 007 for calendar tables. The
optimizecommand calledapply_migrations(conn)but the migration still wasn't applied on the first run. - The fix was to ensure
apply_migrations()was called and its result verified before any write operation that depends on new tables. Additionally, runningmigrateas a standalone step before any new pipeline stage became standard practice.
Key Insights
"Migration file exists" ≠ "migration is applied." A migration on disk does nothing until code reads and executes it. If the code path that triggers the write doesn't apply migrations, the table doesn't exist — regardless of what's on disk.
Test the full write path, not just the migration. Running
migrateand seeing "006 applied" doesn't prove thatcompute-scoreswill find the table. The scoring command has its own connection lifecycle; test through the actual command.Silent failures are worse than crashes. A CLI command that completes with no output and no error — but also no data written — is the hardest failure mode to debug. Add verification queries after critical writes:
SELECT count(*) FROM tableto confirm rows landed.Make migration application idempotent and visible.
CREATE TABLE IF NOT EXISTSprevents crashes on re-run. Logging which migrations were applied (and returning the list) makes it visible whether anything happened.Standalone
migratebefore new pipeline stages. When adding a new migration, runmigrateexplicitly and verify before running the pipeline that depends on it. Don't rely on the pipeline's internalapply_migrations()call for the first run.
Applicability
This applies to any system with schema migrations (Django, Alembic, Flyway, Knex, custom):
- Adding a new table that a new feature writes to
- Adding columns that new code reads from
- Any deployment where "run migrations" is a separate step from "deploy code"
The pattern is especially treacherous in embedded databases (SQLite, DuckDB) where there's no separate database server with its own migration lifecycle — migrations only run when application code triggers them.
Related Lessons
- Lesson 040: Controlled Vocabulary as Schema Contract — migration 009 creates tables that align with the attribute vocabulary; the vocabulary validates at load time what the migration enforces at schema time