Skip to main content

DEPLOY.DRIFT.DIVERGENT — Both sides have it, and describe it differently

  • Category: safety
  • Severity: medium
  • Level: 0
  • Downtime class: online — reporting a difference changes nothing and takes no lock
  • Stability: stable
  • Suites: deploy
  • Applies to: PostgreSQL, MySQL

What it reports

An object present under one name on both sides, described differently — one finding per object, with the attributes that differ named individually rather than a whole-object "these are not equal".

A finding carries the field, the live value and the expected value: a column's type, nullability, default or collation; an index's columns, uniqueness or method; a constraint's definition.

The class where the tool is most able to be wrong about itself

The other two classes ask does this exist, which is hard to get wrong. This one asks are these two descriptions the same, and the honest answer depends entirely on how the descriptions were written down.

The same column can come back as character varying(255) from one reading and varchar(255) from another. The same default arrives as now() here and CURRENT_TIMESTAMP there — MySQL stores those two spellings differently depending on whether the migration wrote default now() or default (now()). An index's column list can differ only in whitespace.

None of those is drift, and a comparison that reported them would produce findings the tool caused itself — the worst shape a comparison layer can take, because the person reading it has no way to tell a real difference from an artefact.

So both sides are reduced to a canonical form before anything is compared, and that layer is a foundation of this feature rather than a convenience. Its own guard works in both directions: a difference it fails to erase is a false finding, and a difference it erases too eagerly is a real one going unreported. The second is the more dangerous, and it is why the canonicalizer is held to a test that compares against the raw catalog as well.

What to do about it

Decide which side is right. That is a judgment this check deliberately does not make.

The migration state is right → the database was changed by hand. Write a migration that brings the object back in line. Read the field-level diff first: a type widening is routine, a type narrowing can fail on existing rows, and a collation change on an indexed column rebuilds the index.

The database is right → the migration is wrong. This happens when a migration was edited after it ran somewhere. The fix is a new migration, never an edit: the old one is recorded as applied everywhere else, and editing it makes those environments diverge from the file describing them.

Neither is wrong, and the difference is intentional — a production-only fillfactor, a region-specific collation. That belongs in the exclude file, where it becomes a recorded decision with a name rather than a finding somebody learns to scroll past.

Why medium

A divergence is real and worth acting on, and it is also the class most exposed to a normalization gap. The severity says look at this, not stop the deploy — and a project that wants a divergence to stop a deploy raises the gate rather than the severity, which keeps the decision where it belongs: with the project, not with the rule.

What it does not claim

  • It names the attributes that differ, not the reason they differ. Which side is correct is a decision about intent, and no comparison of two schemas can make it.
  • A difference the canonical form erases is invisible here, by design. That is the trade this class is built on: two spellings of one definition are not drift.
  • It does not rank one differing attribute above another. A nullability change and a comment change arrive at the same severity; the field-level diff is what lets you weigh them.
  • It is not a statement about object types it could not read. A type one side could not cover is a named blind spot and makes the run undetermined rather than clean.
  • It writes nothing and locks nothing. The database being examined is only ever read.