Skip to content

0004. Let golang-migrate own the schema

Status: Accepted Date: 2026-09-01

Context

A generated service needs a migration tool before M1's persistence layer can exist at all, and that tool has to work the same way in a developer's laptop, in CI's throwaway containers, and in a production deployment step that should not need the whole application to run.

Decision

We use golang-migrate, with plain .up.sql / .down.sql file pairs as the only migration format. There is no alembic/ directory and no alembic_version table anywhere in the project — confirmed directly: the only alembic on disk lives inside the service's .venv, installed as a library, not as a migration tool with its own directory.

Alternatives considered

  • Alembic. Rejected as the migration tool itself for two reasons. Autogeneration produces migrations nobody reads closely, because the diff between two sets of SQLAlchemy models is not the same thing as a reviewed change to a live schema. And Alembic's migration runtime is Python, so running it means the migration step needs the application's full dependency tree; golang-migrate is one static binary in its own small image, with no Python interpreter and no import of service code.
  • Hand-run SQL. Rejected: no version tracking, no reversibility, and no gate stopping an environment from silently missing a change or applying one twice.

Consequences

Migrations are reviewable SQL files rather than generated Python, and the migration image that runs in production is small, language-agnostic and independent of the application image — a clean Kubernetes init container or pre-deployment job that never imports service code.

Alembic is not gone from the project; it is kept, deliberately, as a dev-only dependency used for exactly one job. pyproject.toml declares it under [dependency-groups] dev with a comment stating why: compare_metadata() is the comparison engine behind the model/schema drift gate, the integration test that catches a SQLAlchemy model changed without a matching migration. Deleting the dependency silently removes that gate, which is why the comment is there at all — without it, the presence of a migration library the project supposedly doesn't use reads as a mistake waiting to be cleaned up.

The cost golang-migrate itself imposes is the dirty migration state: a migration that fails partway leaves the schema_migrations table marked dirty, and the tool refuses to proceed until a human runs migrate force after confirming the database's real state by hand. That failure mode is real, not hypothetical, which is why docs/runbook.md carries a worked recovery entry for it rather than leaving it to be rediscovered during an incident.

Full reasoning: spec section 6.1.