Migrate the Schema
Change the wordloop-core Postgres target-state schema and apply it with pg-schema-diff.
Migrate the Schema
Goal
Change the wordloop-core Postgres schema safely using the current declarative target-state workflow.
Wordloop Core does not use timestamped .up.sql / .down.sql migration files. The source of truth is services/wordloop-core/db/schema.sql; the migrator reads that file, compares it with the live database using pg-schema-diff, prints hazards, and applies the generated DDL.
Prerequisites
- Familiarity with Postgres and Data Engineering principles.
- Local infrastructure running so the migrator can connect to Postgres:
./dev start infraHow the Migrator Works
services/wordloop-core/cmd/migrate is a Go command built into the Core image as /app/migrate.
- It loads database settings from environment variables and
.envthrough Core config. - It reads the target schema from
db/schema.sqlunless-schema <path>is provided. - If
-pre-migration <file>is given, it runs that SQL file statement by statement, outside a transaction, before generating the plan (see Pre-migration hooks below). - It ensures the
vectorextension exists in the target and temporary databases. - It creates a temporary database on the same Postgres instance.
- It applies the target schema to the temporary database.
- It asks
pg-schema-diffto generate a plan from the current live schema to the target schema. - It logs each statement, timeout, lock timeout, and hazard.
- In
upmode, it refuses to apply a plan containing a blocking hazard —DELETES_DATA,ACQUIRES_ACCESS_EXCLUSIVE_LOCK,CORRECTNESS, orHAS_UNTRACKABLE_DEPENDENCIES— unless-allow-hazardsis passed (see Blocking hazards below).dry-runalways exits 0; it prints the hazards and warns thatupwould refuse them. - It either exits without changes in dry-run mode or applies the generated SQL in migrate mode.
Flags
| Flag | Purpose |
|---|---|
-schema <path> | Target-state schema file. Defaults to db/schema.sql. |
-pre-migration <file> | Runs an idempotent SQL file, statement by statement and outside any transaction, before the plan is generated. Use this to prepare data (e.g. clearing rows that would fail a type change) so the generated plan is a clean rewrite rather than a failing one. |
-allow-hazards | Required to apply an up plan that contains a blocking hazard. Without it, up refuses and exits non-zero; dry-run is unaffected — it always prints the plan and hazards. |
Blocking hazards
pg-schema-diff classifies every statement in a plan with a hazard type. INDEX_BUILD and INDEX_DROPPED are reported but never block — they fire on every ordinary CREATE INDEX CONCURRENTLY, so blocking on them would break routine index additions. The following hazards do block up unless you pass -allow-hazards:
DELETES_DATAACQUIRES_ACCESS_EXCLUSIVE_LOCKCORRECTNESSHAS_UNTRACKABLE_DEPENDENCIES
A fresh database produces zero blocking hazards for the current schema; you'll only see one when migrating an existing database across a hazardous change (a dropped constraint, a column type rewrite, etc.). Review the plan output before re-running with -allow-hazards — it is not a flag to reach for reflexively.
Pre-migration hooks
A pre-migration file is plain SQL, run one statement at a time, outside a transaction — required because CREATE/DROP INDEX CONCURRENTLY cannot run inside one. Use it when the schema change itself would fail against existing data (a stricter type, a new NOT NULL, a check constraint) and the fix is data cleanup rather than a schema decision. The file should be idempotent: safe to run again against an already-migrated database.
Worked example — vector(512) → vector(192). services/wordloop-core/db/manual/2026-09-09-vector-192.sql prepares people.voice_vector and transcript_segments.feature_vector for the ECAPA-TDNN embedding dimension change (see ADR 0008). Migrating the column type in place fails outright on any row still holding a 512-dimension vector (pq: expected 192 dimensions, not 512) because there is no meaningful conversion between vectors from two different embedding models. The runbook:
- Drops the two HNSW indexes with
DROP INDEX CONCURRENTLY— the plan that follows rebuilds them concurrently, so the exclusive-lock rewrite is not also paying for an index build. - NULLs any vector whose dimensionality is not 192 (
vector_dims(v) <> 192) — those rows are re-embeddable from source audio, so nothing is permanently lost.people.voice_model_statusreverts tountrainedfor cleared rows. - Is idempotent: running it twice is a no-op.
Run it as:
migrate -pre-migration db/manual/2026-09-09-vector-192.sql -allow-hazards up-allow-hazards is still required alongside it: the pre-migration only clears the data that would make the type change fail; the type change itself is still an ACCESS EXCLUSIVE rewrite. Before running it in an environment with real data, count the rows that will lose their vector:
SELECT count(*) FROM people WHERE voice_vector IS NOT NULL AND vector_dims(voice_vector) <> 192;
SELECT count(*) FROM transcript_segments WHERE feature_vector IS NOT NULL AND vector_dims(feature_vector) <> 192;A fresh database (or one already at 192 dimensions and on the current schema) produces zero blocking hazards from this change — the compose and system-test stacks are unaffected. A database already at 192 dimensions but on the pre-branch schema still reports one blocking hazard (a dropped unique constraint on segment_manifest, labelled ACQUIRES_ACCESS_EXCLUSIVE_LOCK); review it and re-run with -allow-hazards.
Steps
1. Edit the target schema
Change services/wordloop-core/db/schema.sql so it describes the final desired schema.
Prefer additive, low-lock changes:
- Add new nullable columns first, or use cheap defaults only when safe for the table size.
- Add new tables empty.
- Add indexes for known access patterns, not speculative future reads.
- Avoid renames, drops, and type rewrites in the same release as the consuming code change.
For multi-release changes, keep the schema compatible across deploys: add the new shape, deploy code that can read/write both shapes where needed, backfill separately, switch readers, then remove the old shape later.
2. Review the generated plan
From the monorepo root:
./dev db dry-runReview the logged DDL and all pg-schema-diff hazards. Treat the dry-run plan as the migration review artifact; there is no checked-in migration version table or down migration to inspect.
3. Apply locally
./dev db migrateThis runs go run cmd/migrate/main.go up from services/wordloop-core against the configured database.
4. Validate the app and repositories
Run the service tests that cover the changed schema and repository behavior. At minimum for Core schema work:
./dev test coreIf the change affects cross-service behavior, add the relevant system or bet tests.
5. Refresh the reference snapshot when needed
./dev db dump applies the target schema locally and writes a reference-only dump to .dev/schema.sql:
./dev db dumpThe dump is not the migration source of truth. Use it for documentation/reference generation only; keep services/wordloop-core/db/schema.sql as the canonical schema.
6. Commit schema, code, and docs together
The PR should include:
- The
db/schema.sqlchange. - Any Core repository/domain/service/API changes required by the schema.
- Updated documentation when tables, columns, workflow, or operational expectations changed.
- Test coverage for the changed behavior.
Deployment Notes
Docker Compose runs the same migrator before the API starts. The wordloop-core-migrate service executes /app/migrate up, and wordloop-core depends on that migration container completing successfully.
Production migrations should use the same target-state schema and migrator. Do not apply manual DDL outside the reviewed workflow; it creates drift that future pg-schema-diff runs must reconcile.
Verification
./dev db dry-runshows either no changes or an expected plan with reviewed hazards../dev db migratecompletes successfully../dev test corepasses, plus any affected system/bet tests.- Database Reference matches
services/wordloop-core/db/schema.sql.
Troubleshooting
./dev db dry-runfails before planning. Confirm Postgres is running with./dev start infra, and checkDB_HOST,DB_PORT,DB_USER,DB_PASSWORD,DB_NAME, andDB_SSLMODE.- The plan contains a destructive or blocking statement. Redesign as expand-contract across releases where possible: add the new shape first, backfill outside the migrator, then remove the old shape in a later change. If the hazard is genuinely intended for this release (reviewed and expected), re-run with
-allow-hazards. uprefuses with "refusing to apply N blocking hazard(s)". This is-allow-hazardsdoing its job —upwill not silently applyDELETES_DATA,ACQUIRES_ACCESS_EXCLUSIVE_LOCK,CORRECTNESS, orHAS_UNTRACKABLE_DEPENDENCIEShazards. Read the printed plan, confirm it is intended, and re-run with-allow-hazards. If the change also requires data cleanup first (a type change that fails against existing rows), write a-pre-migrationfile — see Pre-migration hooks.- A type or constraint change fails partway with a data error (e.g.
pq: expected N dimensions, not M). The schema change is valid but existing data does not satisfy it yet. Write an idempotent-pre-migration <file>that cleans up the offending rows before the plan is generated — see thevector(512)→vector(192)worked example above. - The plan references
vectoror extension errors. The migrator createsvector, but the Postgres image must support pgvector. Local and system-test stacks usepgvector/pgvector:pg15. - The database is in a dirty or partially migrated state. Inspect the live schema and rerun
./dev db dry-run. Because the workflow is target-state based, the next plan is generated from the current live database todb/schema.sql.
See Postgres for the stance that shapes this workflow.