Dirty database version: why one migration blocks all the next ones and how to clear the flag without losing data

The service does not come up after a deploy, and the log has a single line:

Dirty database version 17. Fix and force version.

It comes from golang-migrate, the most widely used migration tool in Go projects. The first answer you find for this line is almost always the same: run migrate force 17. Sometimes that is right. But in PostgreSQL force 16 is right more often, and force 17 leaves the schema without migration 17 — with no message to tell you so. Below is how to tell the two cases apart.

What the dirty flag means

golang-migrate keeps its state in the schema_migrations table: a version number and a dirty flag. It applies every migration in three steps:

  1. write version N with dirty = true — “started migration N”;
  2. run the file N_*.up.sql;
  3. write version N with dirty = false — “finished”.

If anything happens between the first and the third step — an SQL error in the migration, an OOM kill, docker kill in the middle of a deploy, a dropped database connection — the table is left with N, dirty = true. From then on golang-migrate refuses to do anything but force: no up, no down, no next migration. That is not stubbornness: from one row in a table the tool cannot know whether migration N applied fully, partly or not at all. A human has to decide by looking at the schema.

To see where it is stuck:

SELECT version, dirty FROM schema_migrations;

What force actually does

migrate force N does not run a single line of SQL. It only rewrites the row in schema_migrations: version N, dirty = false. The command means “I checked, the schema matches version N”. So there are exactly two options:

Erring towards force N when the migration is not in the database is dangerous because it does not show right away. The tool considers the migration done, the next ones apply on top, the service starts. It fails later and somewhere else — on the first insert into a column that does not exist, or with a slow query missing an index that does not exist.

PostgreSQL: the migration rolls back as a whole

This is where PostgreSQL differs from other databases, and it is exactly what makes the generic “force N” advice risky.

By default the golang-migrate PostgreSQL driver sends the whole migration file in a single Exec call with no parameters. For such a call pgx uses the simple query protocol, and PostgreSQL runs several statements that arrive in one simple query as a single implicit transaction. So if any statement in the migration fails — or the process is killed halfway through the file — PostgreSQL rolls back the entire migration: not a single table, column or index from that file stays in the database.

The practical takeaway: if the migration failed with an SQL error, the schema is exactly at N-1. The right move is force N-1, fix the migration and run up again. force N in that case marks as applied a migration that is not in the database.

It is still worth checking one object from file N:

-- did the migration add a column?
SELECT column_name FROM information_schema.columns
WHERE table_name = 'orders' AND column_name = 'archived_at';

-- did it create a table?
SELECT to_regclass('public.order_archive');

If the object is there, the migration managed to commit (this happens when the process died after its commit but before step three), and you need force N. If it is not, force N-1. A regular PostgreSQL migration has no half-applied state.

When “all or nothing” does not hold

ClickHouse and MySQL: compare against the schema

ClickHouse has no transactions for DDL, and in MySQL every DDL statement implicitly commits. In both, a migration with several statements can stop halfway, and the only way to find out where is to compare the migration file with the schema, SHOW CREATE TABLE for every table it touches. Finish what is missing by hand or roll back what was done, then force N or force N-1 accordingly.

The “one statement per migration file” rule makes this much easier: “halfway” then reduces to “applied or not”. ClickHouse has one more quirk — mutations. ALTER TABLE … UPDATE and ALTER TABLE … DELETE run asynchronously and keep going on the server even after the client has disconnected. Unfinished ones show up like this:

SELECT database, table, mutation_id, command FROM system.mutations WHERE NOT is_done;

Step by step

  1. Find the number: SELECT version, dirty FROM schema_migrations; — call it N.
  2. Open N_*.up.sql and work out what it was supposed to do.
  3. Compare with the schema. In PostgreSQL, outside the exceptions above, one object is enough; otherwise check all of them.
  4. Bring the schema to one of two clean states: migration N is there in full, or not at all.
  5. If the migration failed because of an error in the SQL itself, fix the file — otherwise the next up leaves the same flag.
  6. Clear the flag: migrate -path ./migrations -database "$DATABASE_URL" force N (or N-1) and run up.
  7. Make sure SELECT version, dirty FROM schema_migrations; shows the latest version with dirty = false.

Why migrations break in production

An error in the SQL itself is usually caught on staging. In production a migration is more often cut short by its surroundings:

How Gotcha handles it

Gotcha, a self-hosted observability platform, applies its PostgreSQL and ClickHouse migrations with golang-migrate at startup, so it runs into this error like any Go service. The differences are in how it is presented:

Details are in the “If a migration was interrupted” section of the documentation.