DEV Community

Cover image for Two branches, the same migration number, one broken deploy. I built a zero-dep linter for that.
benjamin
benjamin

Posted on

Two branches, the same migration number, one broken deploy. I built a zero-dep linter for that.

Here's a failure I've now watched happen on three different teams.

Two people branch off main. Alice adds 0007_add_index.up.sql. Bob adds
0007_add_orders_fk.up.sql. Both PRs pass CI in isolation — of course they do,
each migration is fine on its own. Both merge. Now main has two migrations
claiming version 0007
, and depending on which migration tool you use, the
runner either picks one and silently skips the other, or aborts the entire
deploy with a version-conflict error nobody can reproduce locally.

This isn't an exotic bug. It's a known, recurring limitation
in essentially every sequence-numbered migration tool. The collision is invisible
right up until it runs in the one environment where you least want surprises.

Why nothing already caught it

The frustrating part: the fix is trivial. You just have to look at the
filenames
and notice two of them start with the same number. So why wasn't this
already linted?

Because the existing linters are tied to a framework or need a live database:

  • django-migration-linter — Django only.
  • migration-lint on PyPI — Django / Alembic only.
  • Flyway's own validation — Flyway only, and it wants a DB connection.

If your team uses raw SQL with goose, dbmate, golang-migrate, or a
hand-rolled migrations/ folder — which is a lot of teams — there is nothing
that just reads the directory and tells you it's sane. So everyone re-writes the
same 30-line "check for duplicate numbers" shell script, badly, once.

I got tired of re-writing it. So I made migrolint.

What it checks

migrolint reads only filenames. No database, no framework, no config. Four rules:

Rule Severity Meaning
DUPE_NUM error two migrations share a version number (the collision above)
MISSING_DOWN warning an up migration with no matching down (only flagged if you use up/down splits)
SEQ_GAP warning a hole in an integer sequence — usually a deleted or un-merged migration
BAD_FORMAT warning a file whose name no known convention recognizes
$ migrolint db/migrations
migrolint db/migrations (14 files, 12 migrations)

  ✗ DUPE_NUM     version 0007 used by 2 migrations:
       0007_add_index.up.sql
       0007_add_orders_fk.up.sql
  ⚠ MISSING_DOWN 0009_drop_legacy.up.sql — no matching .down file
  ⚠ SEQ_GAP      missing version(s): 8

1 error, 2 warnings.
Enter fullscreen mode Exit fullscreen mode

Exit code is 1 on errors, 0 when clean — so it drops straight into a
pre-commit hook or a CI step:

- run: npx migrolint --strict   # --strict makes warnings fail too
Enter fullscreen mode Exit fullscreen mode

It speaks your naming convention

The whole trick is parsing version numbers out of filenames across the
conventions people actually use, so you don't have to configure anything:

Convention Example
Flyway V1__init.sql, U1__undo.sql, R__refresh.sql
golang-migrate / dbmate 0001_create_users.up.sql + .down.sql
goose / Rails 20230101120000_create_users.sql (timestamp)
minimalist 1_init.sql, 2-add-index.sql

Timestamp-style versions are recognized but exempt from SEQ_GAP (they're never
meant to be contiguous), and well-known non-migration files like schema.rb and
structure.sql are skipped automatically.

Install

npx migrolint                 # Node
pip install migrolint         # Python — same checks, same flags
Enter fullscreen mode Exit fullscreen mode

Zero dependencies on both sides — pure stdlib. It auto-detects migrations/,
db/migrations/, supabase/migrations/, and a few other common paths, or you
point it at a directory.

A few design choices I'd defend

  • Filenames only, on purpose. No DB connection means it runs in a fraction of a second, in any repo, with no credentials — exactly what you want in a pre-commit hook. The DB-connected checks (does this migration actually apply?) are a different tool's job.
  • Deterministic output. It sorts entries before analyzing, so the verdict doesn't depend on filesystem ordering and the Node and Python ports produce byte-identical results. A mixed-language team gets one answer.
  • Conservative warnings. MISSING_DOWN only fires if your project actually uses .up/.down splits — forward-only setups don't get nagged. The goal is zero false positives, because a linter that cries wolf gets --no-verify'd.

It's MIT, both repos are public:
migrolint (Node) ·
migrolint-py (Python).


I'm curious where the gaps are: what migration-folder convention does your team
use, and what other filename-level mistakes have bitten you
that a tool like
this should catch? Down-migrations that don't actually reverse the up? Timestamp
collisions to the second? Tell me and I'll look at adding rules for them.

Top comments (0)